ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

financial-services实战:Spring Boot微服务下的数据建模与事件驱动设计

financial-services实战:Spring Boot微服务下的数据建模与事件驱动设计 去年年底我把一个做了半年的个人项目推倒重写最终落地的产物就是一个非常朴素的financial-services。这个项目没有炫酷的前端界面没有复杂的机器学习模型它是一个纯粹的后端服务负责一件事把个人和家庭分散在银行卡、信用卡、支付宝、微信里的收支记录统一收口然后自动打上分类标签按月生成预算报表并对异常支出做提醒。我写这篇文章的原因很简单市面上讲“账本App”和“记账工具”的文章很多但真正把financial-services当成一个独立后端项目来拆解、讲清楚数据模型、事务边界、幂等方案和部署细节的很少。我知道很多开发者手里都有一个类似的项目要么挂在简历上要么是自用工具但大部分都卡在“功能能跑”和“生产可用”之间的那道坎上。这篇博文就是围绕我自己从零搭建financial-services的完整过程来写的适合有 Spring Boot 基础、想了解金融服务类项目建模细节的后端开发者也适合正在考虑自建个人财务系统的技术爱好者。你可以把它当成一份可直接参考的工程笔记而不是产品评测。1. 项目拆解financial-services 到底在做什么1.1 一句话讲清楚核心业务流程financial-services的核心流程可以压缩成一条链路多渠道导入交易流水标准化数据进行账户余额计算再按预算规则做实时扣减最后产出维度不同的报表。展开来说用户手动添加一笔支出或者通过 CSV 导入支付宝账单之后服务端要做四件事判断这笔交易属于哪个账户、校验该账户当前状态是否允许入账、更新账户的流水记录、同时异步触发预算模块的金额统计。这听起来像是一个普通的记账系统但我真正想解决的痛点是“手工记账的根本矛盾”——大多数人的财务情况并不是单一账户而是三张银行卡加两张信用卡再加微信零钱它们各自有独立的余额和账单日。如果不在底层把“账户”和“交易”这两个概念分开后续所有统计都是乱的。所以我给自己定的设计原则是一个账户可以有无数条交易记录但交易记录不能反过来直接修改账户余额——余额永远是账户表中的一个冗余字段它的正确性由流水表来保证。这样设计的好处是当用户对一笔历史交易做修改或删除操作时我们不需要去逐个更新账户余额而是通过重放该账户的全部流水来重新计算余额。虽然实时性上比直接updatebalance字段差一点但数据一致性逻辑简单了许多也更容易排查那些“莫名其妙多了一分钱”的问题。1.2 四类核心参与者与权限边界在financial-services里我把业务对象收敛成四类用户、账户、交易、预算。用户是最外层的主权对象他可以有多个账户账户是资金容器分为资产账户和负债账户两类交易是账户的流水事件必须有一个明确的类型比如收入、支出、转账预算则是用户在某个月份对某个分类设置的上限。权限边界是根据“谁的数据谁能动”来划分的。用户只能操作自己名下的账户和交易但转账交易比较特殊它涉及两个账户必须校验当前登录用户对两个账户都有操作权限。我在这里吃过一次亏最初只用transaction.accountId做归属校验导致用户可以把资金从别人的账户里转出来。后来我加了一个transfer_participant表把每一笔转账的源账户和目标账户都记录在内再做二次权限校验这个问题才算是彻底解决。如果要把这套模型推广到家庭场景还需要引入“家庭成员”的角色但当前版本我没做那么重。我把家庭需求往后放了原因是核心权限模型一旦复杂化项目节奏会拖慢我更想先把单人使用的闭环跑通。文章后面会简单提一下扩展方向。1.3 为什么需要异步编排而非同步调用financial-services早期版本是典型的同步单体逻辑保存交易的时候同步更新账户余额同步更新预算已用金额同步刷新统计报表。这种写法在并发量很低的时候没有任何问题但很快就暴露出两个隐患一是单次请求耗时过长因为要操作多张表二是如果预算服务的一个字段校验失败会导致整笔交易入账回滚连账户流水都保存不了。于是我决定把链路拆成同步和异步两部分。同步部分只做最核心的两件事保存交易流水、写入本地事件表。异步部分负责消费事件更新账户快照、计算预算消耗、生成报表增量数据。异步编排的好处是账单导入这类高频操作不再被预算计算这类相对慢的任务拖住用户的感知响应时间可以控制在 200ms 以内。同时每个异步任务都可以独立重试不会因为某一个下游服务出问题而影响主流程。我会在第三章详细讲这种“本地事务 异步处理”的最终一致性方案这里先只给出整体思路同步保证核心数据不丢异步保证扩展逻辑不影响主流程。2. 技术选型与架构设计2.1 技术服务组件选型为什么是 Java 21 Spring Boot 3 PostgreSQL先交代一下最终版本的技术栈后端是 Java 21 Spring Boot 3.2数据库是 PostgreSQL 15缓存和分布式锁用 Redis消息中间件用的是 Kafka本地调试也可以用 Redpanda部署环境是 Docker Compose监控用 Prometheus Grafana。选 Java 21 不是因为追求新版本而是 Spring Boot 3.x 已经进入强制要求 Java 17 以上的阶段而 Java 21 的虚拟线程可以让我在处理 IO 密集型任务时用更简单的同步写法却依然保持高吞吐。这里有一个非常现实的考虑financial-services里有很多文件解析、数据库查询和外部 API 调用的 IO 等待如果使用传统线程池要么调优线程数要么引入 WebFlux 写锯齿形回调。虚拟线程让我可以直接用restTemplate同步调用的方式写代码不用刻意做响应式改造。PostgreSQL 的选择同样是基于功能而非流行度。相比 MySQLPostgreSQL 有一个我非常依赖的特性BIGINT类型资源占用小NUMERIC可以精确存储金额避免浮点数误差。更重要的是它对 JSONB 的支持让我可以在交易表里保留一个额外的ext字段用来存导入渠道回传的原始数据这个字段的内容虽然不参与常规查询但在排查历史问题时非常管用。有些人可能会觉得个人项目用 Kafka 是杀鸡用牛刀但我的理解是如果整个项目的目的就是学习并模拟生产级金融服务的复杂度那引入 Kafka 是合理的。它让我能练习事件驱动的应用题而不是什么新鲜事都堆在一个进程里。当然我会在下文给出一套简化替代方案比如用 Redis Stream 代替 Kafka。2.2 微服务划分account / transaction / budget / reportfinancial-services在物理上不是一个大单体而是按照领域边界拆成的四个模块也可以视作四个服务。它们分别是账户服务account-service、交易服务transaction-service、预算服务budget-service和报表服务report-service。账户服务管理账户的开户、销户、冻结状态等交易服务负责流水的创建和查询预算服务负责按月统计分类金额并在超支时发送预警事件报表服务则从流水表中按照日、周、月维度聚合成指标。每个服务都有自己的数据库 schema服务之间通过 HTTP 接口同步调用但关键事件通过 Kafka 异步通知。做这种拆分时我最深刻的体会是服务边界不能凭空画必须从业务行为里找缝隙。一开始我把“预算”功能塞在“交易”模块里结果交易服务的表越建越多事务边界越来越长最后实在理不清了才拆出来。现在每个服务只对自己领域内的表负责跨服务的数据需求一律通过“查询接口事件通知”组合解决比如预算服务不会直接查询交易表而是消费交易创建成功后发出的事件来更新自己的统计字段。2.3 中间件与基础设施Redis 缓存、Kafka 事件流、Docker ComposeRedis 在financial-services里承担了三种职责分布式锁、幂等判重、热点数据缓存。其中幂等判重我会在第五章单独展开这里先说说热点数据的缓存策略。用户的账户基本信息会频繁被交易服务读取但它本身变化频率很低所以我会在 Redis 里缓存一份序列化后的账户对象设置 10 分钟过期。为了保证写操作之后的缓存一致性我采用Cache Aside模式更新数据库成功后删除缓存下一次读取时再回填。Kafka 在这个项目里主要是作为领域事件通道使用。比如交易创建成功后交易服务会往transaction-createdtopic 里发一条消息预算服务和报表服务都订阅这个 topic各自消费并更新自己的数据。这种模式让我可以独立扩展消费能力也方便在测试环境通过暂停消费来模拟下游故障从而验证重试机制。至于 Docker Compose我用它编排本地环境里的 PostgreSQL、Redis、Kafka 和项目本身。真正上线时我会把 Kafka 替换成托管版本但本地开发阶段用 Docker Compose 明显降低了搭建成本。我还写了一个docker-compose.dev.yml额外挂载了pgadmin和kafdrop两个辅助工具前者方便查看数据库表内容后者可以直观看到 topic 里的消息内容排查问题时很有帮助。3. 数据模型与业务规则设计3.1 从“余额字段”到“流水事实表”的演进financial-services的数据模型经历了两次比较大的重构第一次发生在我意识到“账户余额不应该是一个可修改的字段”的时候。早期设计确实很天真账户表里有一个balance字段用户每次新增一笔支出业务层就直接做一次balance balance - amount的减法。这个方案在前 100 笔数据时看不出来问题但一旦涉及到转账、退款、手工纠错你就很难说清楚账户余额是怎么变成现在这个数的了。重构后的方案是建立一张流水表transaction_record包含account_id、amount、directionIN/OUT、typeEXPENSE/INCOME/TRANSFER_OUT/TRANSFER_IN、transaction_time、category_id等字段。账户表只保留一个latest_balance作为展示用的冗余值它的更新逻辑由“重放流水”算法统一派生。也就是说每当需要计算某个账户的最新余额我可以根据该账户全部交易记录求和得到也可以定期用快照加增量流水的方式计算。这套逻辑参考了银行对账单的设计思路流水是事实余额是视图。这种设计带来的直接收益是排查成本大幅下降。一个用户投诉说余额不对我可以直接按时间倒序拉出这个账户的流水逐步手工推演而不是去翻业务代码猜哪个 update 语句覆盖了之前的更新。3.2 预算模型按月周期与分类约束的设计细节预算模块是financial-services里容易被人忽略但实际很考验细节的部分。预算表的核心字段包含user_id、category_id、budget_month口径为YYYY-MM、limit_amount和used_amount。麻烦的是used_amount的更新方式。如果每发生一笔支出就去累加used_amount那么遇到用户修改交易类型或删除交易的时候就会出问题。比如用户把一笔原本属于“餐饮”的交易改成“交通”那么餐饮预算的used_amount要减少交通预算的要增加如果只靠简单的更新逻辑很容易漏掉一种情况。我最终采用的方式是预算服务不直接保存used_amount的实时累计值而是每天凌晨通过离线任务汇总当月的分类支出生成一个汇总快照同时实时消费交易创建事件对当天的支出增量做原子累加更新。这个方案虽然多了一次离线任务但至少保证了月末对账的准确性。具体实现上我用了一个budget_schedule表记录每个预算周期最后汇总到的cursor_date避免重复统计。3.3 最终一致性本地消息表与事件重放机制的配合既然有异步消费就会遇到数据一致性的经典问题本地事务已经提交但发送 Kafka 消息的时候失败怎么办又或者消息被消费者成功消费但消费者在更新自己的数据库时失败了怎么办我的做法是使用本地消息表。在交易服务里transaction_record和domain_event两张表在同一个数据库里并且同时被同一个本地事务包裹。只有流水记录成功写入事件才会被标记为待发送。后台有一个定时任务每 5 秒扫描一次domain_event表里status PENDING的记录把事件发送到 Kafka收到确认后把状态更新为SENT如果超时则重新发送。消费者的处理逻辑是幂等的所以同一个事件被发送两次甚至多次也不会产生重复的预算统计。这套方案的好处是逻辑直白不依赖分布式事务框架也和 Java 生态中的 Spring Boot 兼容性很好。坏处是需要自己维护事件表中的数据时间久了会有积压所以务必给事件表加上created_at和send_count两个字段定时任务做数据清理时就会按这两个字段判断。4. 实操过程从 API 到数据库的一个完整交易入账实现4.1 交易接口的代码骨架与 Controller 层设计这里直接展示一个“创建支出交易”的接口骨架。先看 Controller 层的核心代码RestController RequestMapping(/api/v1/transactions) public class TransactionController { private final CreateTransactionService createTransactionService; public TransactionController(CreateTransactionService createTransactionService) { this.createTransactionService createTransactionService; } PostMapping public ResponseEntityTransactionResponse createExpense(RequestBody Valid CreateTransactionCommand command, RequestHeader(X-User-Id) String userId) { return ResponseEntity.ok(createTransactionService.createExpense(command, userId)); } }很多初学者最容易忽略的地方是用户身份来源。我在做这个项目时没有引入完整的 Spring Security而是先通过网关解析 JWT把用户 ID 放在请求头X-User-Id里。Controller 层拿到这个头之后会在 Service 层里校验这个用户是否有权操作目标账户。这种方式在微服务划分下比较实用因为每个服务之间不需要关心登录态的完整细节只要信任上游网关传过来的请求头即可。如果你把financial-services当成一个对外提供 REST API 的开源项目来使用我建议你把X-User-Id的校验逻辑抽成一个自定义注解 HandlerInterceptor否则每个 Controller 方法里都要手动写String userId参数代码会很冗余。4.2 Service 层如何保障“流水与事件”同生共死Service 层是整个交易入账的核心因为要同时保证“写流水”和“写事件”在同一个本地事务里。下面是一个简化版实现重点在事务注解和幂等字段上。Service public class CreateTransactionService { private final TransactionRepository transactionRepository; private final DomainEventRepository domainEventRepository; private final IdempotentCache idempotentCache; Transactional(rollbackFor Exception.class) public TransactionResponse createExpense(CreateTransactionCommand command, String userId) { String idempotencyKey command.getIdempotencyKey(); if (idempotentCache.exist(idempotencyKey)) { throw new DuplicateRequestException(重复请求); } // 1. 校验账户归属 AccountSnapshot account accountClient.getAccount(command.getAccountId(), userId); if (!account.isActive()) { throw new AccountInactiveException(账户状态不可用); } // 2. 构建并保存交易流水 TransactionRecord record TransactionRecord.createExpense( command.getAccountId(), command.getAmount(), command.getCategoryId(), command.getDescription() ); transactionRepository.save(record); // 3. 构建并保存领域事件 DomainEvent event DomainEvent.fromTransaction(record); domainEventRepository.save(event); // 4. 记录幂等键 idempotentCache.record(idempotencyKey, record.getId(), Duration.ofMinutes(30)); return TransactionResponse.from(record); } }你可能注意到了我在同一个事务里既操作了数据库也操作了 Redis 的幂等键。这里有一个比较微妙的点如果 Redis 写入成功了但数据库回滚了那么幂等键就会“误伤”下一次请求导致用户重试时收到重复请求错误。所以我的做法是把幂等键的写入放到事务提交之后通过TransactionSynchronizationManager.registerSynchronization回调来完成。上面这个简化代码为了展示方便写在一起你真正实现时可以调整。4.3 持久化层与建表语句中的关键约束持久层我用 Spring Data JPA但个人建议在写实体类时不要直接使用ManyToOne关联查询因为跨服务边界的实体关系容易产生隐式查询。我的做法是每个实体类只保留外键 ID比如交易记录里只有accountId而不是整个 Account 对象。这样方便服务解耦。表结构定义中有一个约束值得单独说明交易表的transaction_no字段必须是全局唯一。它是每笔交易的外部标识可以由调用方传入也可以由服务生成。如果是通过导入账单产生的交易我通常把账单号、交易日期、金额拼起来做 MD5作为transaction_no。这样做的好处是在重复导入同一个账单的时候可以直接通过唯一索引拦截减少不少后续手工过滤的工作。CREATE TABLE transaction_record ( id BIGSERIAL PRIMARY KEY, transaction_no VARCHAR(64) NOT NULL, account_id BIGINT NOT NULL, amount NUMERIC(12,2) NOT NULL, direction VARCHAR(8) NOT NULL, transaction_type VARCHAR(16) NOT NULL, category_id BIGINT, transaction_time TIMESTAMP NOT NULL, description VARCHAR(255), ext JSONB, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT uk_transaction_no UNIQUE (transaction_no) );我给transaction_time建了索引因为用户最常做的操作就是按时间范围查询流水。分类统计和大额支出查询时我会在category_id和amount上建联合索引。实际操作中索引宁缺毋滥毕竟个人项目的数据量不会太大但过早的优化会让整个表结构变得笨重。5. 常见问题与排查技巧实录5.1 幽灵流水并发重复提交的幂等处理与 Redis 误伤排查我在上线第一周就碰到了“幽灵流水”问题。用户快速点击两次提交按钮结果创建了两笔一模一样的消费记录。这其实就是没有做幂等导致的。解决思路是引入幂等键。用户在创建交易前先从后端获取一个clientToken之后每次提交都带上这个 token。后端收到请求后先检查 Redis 中是否存在该 token如果存在就拒绝重复请求如果不存在就继续执行。这里有一个非常容易踩的坑如果在事务内部写入幂等键事务回滚会导致 token 残留。所以我后来专门把一个IdempotentFilter放在了事务外层来做两层检查第一层在 Controller 入口快速拦截第二层在 Service 内部用事务内的数据做最终校验防止并发穿透。排查时还有一个思路在日志里同时打印transaction_no和idempotency_key一旦发现同样的transaction_no出现两次就可以快速定位是“没走幂等逻辑”还是“幂等键生成规则有缺陷”。5.2 预算超支却查不到时间窗口和时区问题预算模块上线的第二个星期有用户反馈说“我 3 月 1 日早上消费了为什么 3 月的预算统计没把它算进去”。最终发现原因是时区。用户的账单导入时间默认用的是服务器所在的 UTC 时间但用户实际消费发生在北京时间 3 月 1 日凌晨服务器时间还是 2 月 28 日晚上。这个问题并不难解决但很容易被忽略。我的做法是交易表中增加一个local_transaction_time字段保存用户当地的消费时间所有关于预算和报表的统计都以这个字段为准。另外在创建预算周期时也明确按月周期的起点是“用户所在时区的每月 1 日凌晨”。如果你在做其他国际化项目记得时刻把“数据库时间”、“服务器时间”和“业务时间”区分开。5.3 Kafka 消费积压导致的“账实不符”用 Kafka 作为异步事件通道之后我曾遇到过最典型的问题是某次数据库连接池配置不当导致报表服务消费事件的速度远低于交易服务生产事件的速度积压了十几万条消息。用户去看报表时数据慢了差不多半天于是产生“账实不符”的错觉。排查步骤分三步先查kafka.consumerLag 指标确认哪些消费者组积压再查消费者的日志看主要耗时点在哪里最后针对耗时点做优化比如把批量处理从单条改成批量。实施批量消费时要注意幂等性因为 Kafka 在批量提交 offset 时如果失败有可能会重放一部分已处理的事件。我在消费端使用transaction_no作为去重依据对“重复事件”直接跳过才彻底解决了这类问题。如果你不想在生产环境里引入 Kafka建议使用 Redis Stream 来替代。它提供消费者组和 pending list 机制在低并发场景下的运维成本更低是个人项目非常好的过渡方案。6. 影响范围与后续扩展方向6.1 从个人记账工具到家庭财务管理目前financial-services只支持一个用户独立管理自己的多个账户。如果想把它扩展到家庭场景需要引入“家庭组”概念让多个成员共享账户但各自拥有独立的预算观察视图。之前停掉的家庭功能应该重新提上日程也就是设计household_member表和成员角色字段。不过我的建议是不要一开始就把权限模型做得太重先支持“创建家庭组、邀请成员、成员可见全部账户”这种简单模式等用户习惯后再增加“只读成员”和“独立预算”等细分权限。这种扩展不会破坏现有交易和账户模型因为底层的交易记录和账户都存在核心域里家庭模式只是在上面加了一个聚合根。唯一要注意的是所有查询都必须带上household_id不然会出现跨家庭的数据泄露风险。6.2 金融级安全的进一步改造空间作为个人项目financial-services现在的安全等级只是一个可运行的水平还不配叫“金融级”。如果真的把它放到生产环境还有几个地方需要补一是敏感数据至少要字段级别的加密存储包括账户卡号、用户手机号二是接口需要增加更细粒度的限流与风控规则比如单用户每天最多创建多少笔交易单笔最大金额限制异常时间段的交易进行二次验证三是审计日志必须完整记录谁在什么时间修改了哪条交易数据而且这些日志不能只存在普通日志文件中要入数据库并保留至少一年。这些改造说起来容易做起来很花时间。我建议按照“先加密、再审计、最后风控”的顺序逐渐完善不要一次性把所有功能都加上否则项目很容易再次陷入“什么都有但什么都没做好”的状态。6.3 给同样在自研财务服务的开发者的三条建议第一先把流水模型做好再往上堆功能。很多人一开始就想着做漂亮的图表和报表但底层数据模型一团糟后面所有统计功能都会在同一处地方反复返工。第二为每一笔交易保留一个“原始数据字段”哪怕是 JSONB 或者一个简单的字符串都会让你排查问题时省一半力气。第三同步转异步的时候一定要先想清楚幂等方案而不是先追求消息队列带来的架构优越感否则消费重放和重复统计会让你在凌晨三点被自己的报警电话吵醒。写在最后的一个实操经验如果这篇文章只能让你记住一段内容我希望是这句话financial-services这类项目的复杂度从来不在“增删改查”本身而在“如何在数据丢失和重复之间找到一条可接受的一致性路线”以及“如何让自己在一个月以后还能看得懂当初的业务规则”。我个人在整个项目里最有成就感的一刻不是系统第一次通过压力测试而是我终于把一笔 0.01 元的微信红包余额偏差排查清楚最后发现是导入时没有对“收入”和“支出”的类型前缀做统一规范化。从此我在所有涉及金额字段的入口都强制加了一个类似MoneyNormalizer的组件专门处理金额舍入、正负号和币种转换。你也应该在项目早期就做这样的公共层而不是等到累计了上百个接口之后再统一重构。以上是financial-services从设计到实现再到踩坑的一条完整记录。如果你也正在做类似的东西建议从最小的流水模型开始先跑通一两个核心接口再逐步加入事件和报表。希望这些经验能让你少走几段弯路。
返回列表