前阵子接手一个代号为financial-services的长期维护任务,最初以为它只是某个普通的订单服务,结果把模块边界打开之后才发现,账户、交易、计费、对账、清结算几乎全都挂在里面。这类服务在行业内通常被归到"金融服务域",落到工程上就是一套围绕资金流展开的微服务集群,而 financial-services 往往就是其中最核心的编排入口。这篇文章我会站在实际落地者的角度,从领域拆解、账户建模、幂等设计、对账实现到最终一致性逐层讲清楚,并把项目推进过程中踩过的坑一并写出来。如果你正在做内部结算系统、支付中台,或者想在个人项目里加入金融场景,完全可以把这篇当作一份工程参考。
1. 从项目代号到核心领域:financial-services 到底在解决什么问题
1.1 一个代号背后的业务集合
很多人看到 financial-services 以为它是一个服务,真实情况是一组服务加一堆文档的总称。我接手时的第一件事不是看代码,而是先梳理 API 清单和 ER 图。当时列出来的入口包括账户开户、冻结、解冻、交易受理、交易查询、对账文件解析、退款、调账,看起来每个都很简单,但它们共用同一套资金能力。
这里的核心不是"有多少个接口",而是"每一笔钱都要能说得清来源和去向"。业务系统可以允许订单状态模糊一点,金融域不行——不能多付、不能少扣、不能重复入账、不能凭空产生一笔余额。所以 financial-services 的第一原则是资金安全,第二原则才是业务响应速度。
一个常见的误区是试图在订单服务里直接维护余额字段,省掉金融域这一层。订单是业务视角,账户和交易是资金视角,混在一起就会出现这种问题:用户退款后订单显示已退款,账户余额却没恢复;或者两笔并发请求同时扣款,余额被扣成负数。把金融能力拆成独立服务,不是为了微服务而微服务,而是让资金变化只有一条受控的路径。
1.2 服务边界:金融域与业务域的分工
项目里最终把金融域拆成了六个模块:路由接入、账户、交易、清算、对账、风控。边界划分花了大约两周,这比写代码的时间还长,但非常值得。
| 模块 | 核心职责 | 关键实体 | 边界提醒 |
|---|---|---|---|
| 路由接入 | 统一接收业务方请求,分发到交易模块 | 渠道配置、商户产品配置 | 只做参数校验和签名验签,不写账户 |
| 账户 | 维护余额、冻结、解冻、账户流水 | 账户表、流水表、可用余额 | 余额只能在账户模块内变更 |
| 交易 | 受理支付、退款、撤销,维护交易状态机 | 交易主表、交易明细、幂等表 | 交易状态与账户流水分离 |
| 清算 | 计算渠道手续费、结算金额,生成结算单 | 清算批次、结算明细 | 只算钱,不动账 |
| 对账 | 下载并解析渠道文件,逐笔比对差异 | 对账批次、差异记录、差错单 | 发现差异只记录,不自动改余额 |
| 风控 | 校验限额、频次、黑名单,输出拦截结果 | 规则配置、拦截日志 | 失败时返回明确原因码 |
边界划错的表现是循环调用。比如账户模块为了生成流水去查交易状态,交易模块又为了校验余额去查账户表,最后服务之间纠缠不清,连事务边界都没法定。我最终的划分原则很简单:金融域只处理和钱直接相关的状态,业务状态交给上层;账户模块不关心这笔钱买的是什么,交易模块不关心余额存了多少。
1.3 技术全景与角色定位
金融服务项目在技术上并不玄乎,但选型必须保守。我们用的是 Spring Boot + MySQL + RabbitMQ,缓存只用来做查询加速,不参与资金计算;任何余额读写都直接走数据库事务,避免缓存与数据库不一致带来的对账问题。
MySQL 在这个场景里仍是最稳的选择,因为金融核心链路的数据量通常不是问题,问题是要求可追溯、可核对、可恢复。Redis 可以放在对账文件暂存、热点配置等场景,但绝不能放在扣款链路里。分布式事务也没选,后面在讲最终一致性时会详细说为什么。
如果你的项目和技术栈不同也没关系,重点是理解每个模块的职责和边界,这套领域模型可以平移到任何语言和框架上。
2. 账户与余额模型:资金系统里最容易被做错的地基
2.1 余额为什么不能只是一行数字
新手做账户模块,最容易写出的表结构是:
CREATE TABLE account ( account_no VARCHAR(32) PRIMARY KEY, balance DECIMAL(12,2) NOT NULL DEFAULT 0, update_time DATETIME );扣款时一条 UPDATE:
UPDATE account SET balance = balance - 100 WHERE account_no = 'P001';如果这是个单人、单次的演示项目,这么写没问题。一旦放到真实并发场景,问题立刻冒出来:两笔请求同时读到余额 500,各自扣 100 后都写回 400,实际应该剩下 300。即使 MySQL 的行锁能避免"丢失更新",这种直接改余额的方式也无法回答"这 500 是怎么变成 400 的"。
资金系统的核心要求有两个:可追溯和可恢复。要做到这两点,余额必须由流水推导而来,或者至少每次余额变化都对应一条不可篡改的流水记录。我常跟团队说一句话:余额不是状态,余额是结果。
2.2 事件溯源思路:让流水先行的记账模式
financial-services 的账户模块没有采用纯事件溯源,而是采用了"流水先行 + 余额冗余"的模式。先定义账户流水表:
CREATE TABLE account_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(32) NOT NULL, request_no VARCHAR(64) NOT NULL, balance_type TINYINT NOT NULL COMMENT '1-可用余额变更,2-冻结余额变更', change_type TINYINT NOT NULL COMMENT '1-入账,2-出账,3-冻结,4-解冻,5-冲正', amount DECIMAL(12,2) NOT NULL, before_balance DECIMAL(12,2) NOT NULL, after_balance DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-有效,1-冲正', created_at DATETIME NOT NULL, UNIQUE KEY uk_request_account (request_no, account_no, change_type) ) ENGINE=InnoDB;关键点在于request_no。每一笔流水必须携带一个全局唯一的业务请求号,这个请求号同时是幂等键。事务里先校验余额,再插入流水,最后更新账户余额,全部在同一数据库事务内完成。
账户余额表保留余额字段是出于性能考虑——查询余额不需要每次 SUM 全部流水。但这张表里额外增加了version列,作为乐观锁:
CREATE TABLE account ( account_no VARCHAR(32) PRIMARY KEY, balance DECIMAL(12,2) NOT NULL, frozen_balance DECIMAL(12,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL );扣款逻辑用 Java 伪代码表达就是这样:
@Transactional public void deduct(AccountCommand cmd) { // 1. 幂等检查:同一个 request_no 是否已处理 if (accountFlowMapper.exists(cmd.getRequestNo(), cmd.getAccountNo())) { return; } // 2. 加行锁读取账户 Account account = accountMapper.selectForUpdate(cmd.getAccountNo()); if (account.getBalance().compareTo(cmd.getAmount()) < 0) { throw new InsufficientBalanceException(); } // 3. 先记流水 BigDecimal after = account.getBalance().subtract(cmd.getAmount()); accountFlowMapper.insert(buildFlow(account, cmd, after)); // 4. 再更新余额 accountMapper.updateBalance(cmd.getAccountNo(), cmd.getAmount().negate()); }为什么是"先记流水、再更新余额"?因为流水是账本,余额是汇总。如果先更新余额后写流水,流水插入失败时余额已经变了,事务回滚后虽然数据能恢复,但排查问题时缺少一条可对账的记录。反过来先写流水,即使余额更新异常,流水的存在也能帮助快速定位。
2.3 冻结与解冻:可用余额和冻结余额的拆分
真实业务里还有一类高频场景——冻结。用户发起提现、预授权、纠纷处理时,钱不能直接从可用余额扣走,而是先冻结。这里如果只用一个余额字段会很难处理,所以账户表里要拆出balance(可用余额)和frozen_balance(冻结余额)。
冻结操作不是把 balance 减掉再把 frozen_balance 加上这么简单。从语义上,冻结是把"可用额度"转成"不可用额度",但资金所有权没有变。冻结流水要记录原始业务单号,解冻时通过原单号反向去找冻结流水,避免任意解冻。
冻结的核心 SQL 逻辑:
UPDATE account SET available_balance = available_balance - #{amount}, frozen_balance = frozen_balance + #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND available_balance >= #{amount} AND version = #{expectVersion};这里用乐观锁替代了行锁。两者的取舍是:行锁在事务内更直接,但遇到长事务时持有时间过长;乐观锁在冲突不高时性能更好,失败后由上层重试。账户扣款我推荐行锁,冻结这类低频操作推荐乐观锁,原因是冻结流程通常还会伴随其他校验逻辑,事务时间偏长,没必要把行锁一直握在手里。
2.4 出现负余额怎么办:流水冲正
即使做了全套校验,线上还是可能出现异常,比如渠道回调重复、人工误操作。这时候绝对不能直接改余额把负数纠正过来,正确做法是冲正。
冲正不是 DELETE 一条流水,而是新增一条反向流水,把原来那笔标记为"已冲正",同时余额做反向变更。所有痕迹都保留在账本里,审计时能看到完整的资金轨迹。我用一个很朴素的原则要求团队:账本只做加法,不做减法;纠错的方式永远是再记一笔,而不是抹掉一笔。
这在业务上也有意义——如果发现某笔扣款错误,用户可能已经基于余额做了下一步操作,直接抹掉记录会导致后续对不上。冲正留下了"发生过错误并已纠正"的证据,这才是资金系统该有的样子。
3. 支付受理与幂等设计:重复回调这道坎不过去就别上线
3.1 一次线上事故引出的幂等设计
项目联调阶段出过一次很典型的事故:测试人员在支付结果页连续点了两次"确认支付",前端防重没生效,两个请求同时打到交易服务。第一次事务提交成功,第二次因为余额不足被拒绝,但此时渠道网关也做了重试,回调报文重复到达。处理逻辑里没有幂等判断,直接按"支付成功"更新了订单,还各加了一笔余额流水,用户账户凭空多出 100 元。
排查的过程让我意识到,支付链路的幂等不能只靠前端防重,必须在接口层、消息层、数据库层同时设防。任何一次外部调用都可能被重复发送,不是用户点的,就是网关重试或网络抖动导致的。只要最终结果是幂等的,重复多少次都不会出错。
3.2 幂等键的三层设计
交易服务里我设计了三层幂等键,缺一不可。
| 层级 | 幂等键 | 作用 | 示例 |
|---|---|---|---|
| 接口层 | 业务请求号 request_id | 业务方生成,防止同一业务动作重复受理 | 商户订单号 + 操作类型 |
| 渠道层 | 渠道交易号 channel_trade_no + event_type | 网关回调可能以不同事件多次到达 | payment.success、refund.success |
| 数据库层 | request_no + change_type + account_no | 防止流水重复入账 | 一次支付不能产生两笔入账流水 |
真实处理回调时,逻辑是这样的:
@Transactional public void handleChannelCallback(ChannelCallback cb) { // 1. 幂等表插入,利用唯一键去重 try { callbackRecordMapper.insert(cb.getChannelTradeNo(), cb.getEventType()); } catch (DuplicateKeyException e) { // 已处理过该事件,直接返回 return; } // 2. 根据 event_type 找到对应的交易记录 TradeOrder order = tradeOrderMapper.selectByChannelTradeNo(cb.getChannelTradeNo()); if (order == null) { throw new TradeNotFoundException(); } // 3. 校验当前交易状态是否允许该跳转 if (!order.getStatus().canTransferTo(cb.getEventType())) { return; } // 4. 执行账户入账/出账 accountService.record(cb...); // 5. 更新交易状态 tradeOrderMapper.updateStatus(order.getId(), order.getStatus().next(cb.getEventType())); }关键的细节在第三步:不是所有回调都需要处理。比如交易已经关闭,此时又来一个支付成功回调,不能盲目入账,必须先判断状态机是否允许。支付成功、退款成功、关闭这三个事件之间是有序的,乱序到达时要能够安全丢弃或进入人工处理。
3.3 交易状态机:显式流转,禁止裸 UPDATE
交易订单不能只有一个状态字段,否则时间一长就没人知道哪些状态是合法的。我在项目里用枚举约束状态机:
public enum TradeStatus { PENDING, // 已受理,等待支付 PROCESSING, // 支付处理中 SUCCESS, // 支付成功 FAILED, // 支付失败 REFUNDING, // 退款中 REFUNDED, // 已退款 CLOSED; // 已关闭 public boolean canTransferTo(String eventType) { switch (this) { case PENDING: return "payment.success".equals(eventType) || "payment.fail".equals(eventType) || "payment.close".equals(eventType); case PROCESSING: return "payment.success".equals(eventType) || "payment.fail".equals(eventType); // 其他状态类似 } return false; } }这个枚举是所有状态变更的唯一入口。任何地方要改订单状态,都必须先拿到当前状态,再调用canTransferTo判断,不允许直接UPDATE trade_order SET status = 'SUCCESS'。
早期我们吃过一次亏:运维为了修一个脏数据,手动把某笔订单从 CLOSED 改回 PENDING,结果渠道的重试回调再次到达,重新触发了一次入账。从那以后数据库账号的 UPDATE 权限收掉,状态变更只能走服务接口,并且接口里必须带当前状态快照做条件更新。
3.4 回调乱序与延迟到达
真实网络环境里,回调可能先到失败后到成功,或者退款回调比支付回调先到。处理这类乱序的方案是:回调事件也走一条独立的顺序判定,用渠道交易号 + 事件类型 + 渠道侧的最终状态做判断。如果本地交易已经在 SUCCESS,再来 payment.fail 就直接忽略;如果本地还是 PENDING,先收到 refund.success 就要标记异常,进入人工核实,不能自动改成退款成功。
延迟到达的回调更麻烦。用户已经发起退款,退款成功事件还没回来,支付成功的补发回调却到了。这种情况没有完美的自动化方案,我的处理是:交易存在"退款中"或"已退款"状态时,任何支付成功类回调都只记录日志,不操作余额,并触发一个告警通知运营确认。宁可慢一步,不能错一步。
4. 对账与清结算:让每一分钱都有据可查
4.1 为什么必须做对账
账户和交易模块做得再严谨,也只能保证系统内部一致。渠道侧的网络抖动、超时、风控拦截、人为退款,都会导致我方状态和渠道方状态产生差异。如果没有对账,这些差异可能要等用户投诉才会暴露,那时不仅影响体验,资金追回也困难。
financial-services 里对账的定位是最后一道防线。它不参与实时链路,而是以 T+1 的方式拉取渠道文件,逐笔比对交易号、金额、状态、手续费,输出差异清单。对账模块启动后,我才算敢把系统真实交易量放开。
4.2 对账文件解析:先统一口径,再逐笔核对
渠道方给的对账文件格式五花八门,有 CSV、Excel、固定长度文本、JSON。我第一版的实现直接按渠道格式解析,结果每接一个新渠道就重写一遍解析逻辑,维护成本极高。
后来的做法是定义统一的内部对账模型:
public class ReconciliationRecord { private String channelNo; // 渠道编号 private String channelTradeNo; // 渠道交易号 private String localTradeNo; // 我方交易号 private BigDecimal amount; // 金额 private String status; // 渠道侧状态 private LocalDateTime tradeTime; private BigDecimal channelFee; // 渠道手续费 }每个渠道写一个转换器,把原始文件行转成ReconciliationRecord,后续的比对逻辑全部针对统一模型。解析文件时要做行级容错:某一行字段缺失或类型错误,不能导致整个文件加载失败,正确的行照常处理,异常行单独记录到错误明细表,等人工排查。我们真实遇到过渠道文件里某一行金额多了个千分位逗号,第一版解析直接抛异常中断,后面全部停摆。加上行级容错之后,这类问题的影响面从"全量中断"降到了"单条待处理"。
4.3 差异分类与差错处理流程
比对后的差异分成三类:
本地有、渠道无:本地显示交易成功,渠道文件里没有。可能是渠道文件生成时刻,该笔交易还没有最终落账,也可能是渠道漏出账。这种差异会触发"渠道侧待确认",先不操作余额,等渠道人工查询。
渠道有、本地无:渠道侧扣了款,本地没有交易记录。通常是异步回调丢失,我方没有感知。这种差异需要先到渠道侧查询原始请求,确认是我们发出的交易后,按照本地补单流程重建交易和流水。
金额不一致:本地金额和渠道金额不一致。这类情况不允许自动处理,直接标记为高风险差错,转人工核查。真实项目里出现过渠道手续费计算口径变化导致的金额差异,自动化处理很容易误判。
差错处理有一个铁律:对账模块只生成差异记录和差错单,不做任何自动改账操作。改账必须走账户模块的调账接口,并且带审批记录。哪怕差异已经 100% 确认,调用调账接口时也要走一遍完整的流水和幂等逻辑。
4.4 清结算模型:把渠道手续费算明白
对账之后是清结算。很多项目把"清算"和"结算"混在一起,实际上清算算的是钱,结算动的是账。
清算模块按批次计算每个商户的应收金额、渠道手续费、平台分成,生成结算单。结算单是纯计算,不会直接改账户。后面的结算动作才通过账户模块给商户入账。简单表结构如下:
CREATE TABLE settlement_batch ( id BIGINT AUTO_INCREMENT PRIMARY KEY, merchant_no VARCHAR(32) NOT NULL, batch_date DATE NOT NULL, total_amount DECIMAL(12,2) NOT NULL, fee_amount DECIMAL(12,2) NOT NULL, net_amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL COMMENT '0-待结算,1-已结算,2-结算失败', created_at DATETIME NOT NULL, settled_at DATETIME );清算模块最容易忽略的是"重复计算"。同一笔交易不能被两个批次重复计入,所以交易明细表要增加settlement_status字段和batch_id标记。批次计算时锁定批次状态,结算完成后状态置为终态,不允许回滚重算。如果发现某批数据算错,正确做法是新增一个冲正结算批次,而不是修改原批次。
5. 服务间通信与最终一致性:本地消息表模式的实战选择
5.1 为什么不选分布式事务
金融域涉及账户、交易、清算、消息等多个模块,按照微服务的习惯自然会想到分布式事务。我们评估过可靠性,但最终没采用。
分布式事务的主要问题是性能损耗和协调复杂度。全局事务需要在所有参与方之间维持锁和协调状态,吞吐量大幅下降,这对于支付核心链路是难以接受的。更重要的是,资金系统真正需要的往往不是"同时成功",而是"最终一致且不产生错账"。
以支付成功为例:交易状态要改为成功,账户要入账,消息要发出。这三个动作如果拆到三个服务里去做分布式事务,原理上可行,但任何一个参与方宕机都可能导致整个链路长时间阻塞。金融系统的可用性要求决定了我们不能接受这种模式的脆弱性。
5.2 本地消息表:把可靠性建立在数据库事务上
项目里采用的最终一致性方案是经典的本地消息表。核心思路是:业务操作和消息写入放在同一个本地事务里,事务提交后消息一定存在;后台任务扫消息表,把未发送的消息投递到 MQ,投递成功后标记已发送。
支付成功后要发一条通知用户、同时更新交易状态的场景,落地的伪代码如下:
@Transactional public void confirmPayment(TradeOrder order) { // 1. 业务操作:更新交易状态 tradeOrderMapper.updateStatus(order.getId(), TradeStatus.SUCCESS); // 2. 业务操作:账户入账 accountService.deposit(order.getAccountNo(), order.getAmount(), order.getRequestNo()); // 3. 同一个本地事务里写消息 outboxMapper.insert( new OutboxMessage("trade.trade-success", order.getId(), payload) ); }消息表和业务数据在同一个数据库里,利用本地事务保证原子性。之后由一个轮询任务扫描处于"待发送"状态且未超过重试次数的消息,发送到 MQ。这种模式牺牲了一点实时性,但换来了极强的可靠性——只要数据库不丢,消息就不会丢。
消息表结构:
CREATE TABLE outbox_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, topic VARCHAR(64) NOT NULL, biz_id VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待发送,1-已发送,2-发送失败,3-已确认', retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );这里有三个容易被忽略的点。第一,轮询发送和业务消费方之间的确认机制,消息发送成功后不能马上丢弃,要等消费方 ack 后再更新状态。第二,重试必须有退避策略,不能无脑循环轰炸 MQ,next_retry_time用指数退避。第三,超过最大重试次数的消息必须进入死信表,并触发告警,不能悄无声息地消失。
5.3 订阅方的幂等消费
消息模式里,消费方必须保证幂等,因为消息可能被重投。我们约定消费端所有处理函数都使用消息里的biz_id做唯一约束,处理前先查一遍是否已处理,已处理则直接 ack。这和第三章讲过的数据库层幂等是同一个思路,只是落地到了消息消费端。
曾经有个同事问:既然 MQ 本身有 at-least-once 保证,为什么还要自己做幂等?答案是:MQ 的可靠投递只能保证消息不丢,不保证不重复;而且消息消费成功后,如果中间件在 ack 阶段超时,大概率会重投。任何标榜"不重复"的中间件都可能因为网络原因出现重复,所以幂等必须由业务自己负责。
5.4 我的一些参数配置经验
项目运行一段时间后,我对轮询任务做了一些调优:
- 轮询间隔设为 2 秒,批量拉取 100 条待发送消息,单条发送失败不影响其他消息。
- 消费方处理超时时间建议设成 10 秒,超时不要立即重试,先看日志定位。
- 重试次数上限设在 15 次,间隔依次是 1 分钟、5 分钟、15 分钟、1 小时……
- 超过 15 次的消息进入死信表,每天 10 点巡检一次,人工介入。
这套参数不是拍脑袋定的,而是根据业务对"实时性"和"可靠性"的容忍度倒推出来的。支付成功通知用户可以延迟几十秒,用户不会感知;但如果消息丢了,账就对不上,代价就大了。所以宁可把重试生态做得保守一点,也不能为了省事绕过重试机制。
6. 复盘:项目推进过程中踩过的几个坑
6.1 余额更新和流水写入拆到了两个事务里
早期版本为了"性能",把扣款操作拆成两步:一个事务写流水,另一个事务更新余额。结果有一次应用在流水写完、余额更新前重启,重启后余额没变,流水却多了,对账时怎么都对不上。
后来统一改成单事务内完成"校验余额、写流水、更新余额、写消息",彻底消灭了这类中间状态。这个教训让我明白,资金链路里不要为了性能去拆事务,先把正确性保住,性能优化可以通过批量、异步等方式另找空间。
6.2 回调幂等键没有包含事件类型
一开始幂等表的主键只用了渠道交易号,结果退款成功回调和支付成功回调同用一个交易号,后来的事件直接覆盖了前面的事件状态。
改成"渠道交易号 + 事件类型"联合唯一之后,支付成功和退款成功才能作为两条独立事件各自处理。这个细节很容易被忽略,但一旦漏掉,线上会发生状态错乱。
6.3 对账文件解析缺少行级容错
渠道对账文件是 CSV 格式,我们第一版解析在遇到一个格式异常的行时直接抛异常,整个批次失败。后来加了行级容错:解析失败的行单独记录错误原因,成功行照常进入比对逻辑。这个改动看起来不大,却避免了大量"一个坏数据挂掉全量对账"的运维事件。
6.4 状态机缺乏逆向流转保护
交易状态从 CLOSED 被运维手动改回 PENDING 那次事故,让我意识到状态机不能只存在于代码注释里。后来我在交易状态变更的唯一方法里增加了状态流转规则校验,任何非法跳转直接抛异常;同时数据库字段也加了条件更新校验,确保并发环境下状态不会跳变。现在即使有 DBA 权限的人去手改数据,也只能改字段值,绕不过状态校验逻辑。
做金融项目这么多年,最大的体会是"慢就是快"。只要每一步都可追溯、可恢复,短期看起来多写了很多代码,长期省下的排查成本却是巨大的。如果你也在做类似的 financial-services 项目,先把账户流水、幂等、对账这三件事想清楚,再考虑性能和架构扩展,方向就不会跑偏。