1. 背景:我遇到什么业务问题,为什么要引入分布式事务
2024 年 Q3,我们负责的某城商行互联网核心系统上线了 II 类户跨行转账功能。业务量不大,日均交易约 12 万笔,峰值 QPS 约 800。上线两周后,运营反馈一个异常现象:用户收到扣款短信,但收款方迟迟不到账 ,客服工单日均 40+ 起,投诉率环比上升 0.3 个百分点。
排查发现,问题集中在转账链路的"记账 + 通知"环节。我们的转账服务调用链是:账户服务(扣款)→ 账务流水落库 → 消息服务(发通知)。最初实现是本地事务 + 异步消息:扣款和流水在同一个本地事务里提交,然后通过 MQ 异步通知下游清算系统。
表面看没问题,但实际运行中,MQ 发送是"先提交事务、再发消息"的经典顺序。一旦事务提交成功、消息发送前进程宕机或网络抖动,就会出现扣款成功但通知丢失。更麻烦的是,我们当时为了"保证不丢消息",在发送失败时做了本地重试,重试 3 次仍失败就丢弃并记录 error 日志------结果就是上面说的"幽灵失败"。
为什么要引入分布式事务? 因为转账本质是跨账户、跨系统的写操作:扣款方账户、收款方账户、清算系统分属不同数据库和不同服务,任何一个环节失败,都必须保证"要么全部成功、要么全部回滚"。本地事务管不了跨库跨系统的一致性,这正是分布式事务要解决的问题。
2. 踩坑与现状:尝试过程遇到的故障、约束与痛点
第一版方案其实是教科书里的"本地消息表":在扣款事务里同时写一条 t_transfer_msg 消息表记录,事务提交后由定时任务扫表补发。这个方案理论上能保证最终一致,但我们踩了三个坑:
坑一:消息表与业务表耦合,事务放大。 转账事务原本只锁账户行,现在还要写消息表,事务时间从平均 8ms 涨到 23ms,锁等待导致账户热点行冲突率上升,P99 延迟从 120ms 飙到 480ms。
坑二:定时任务扫表有延迟窗口。 我们扫表间隔设了 5 秒,但高峰期消息积压,实际补发延迟达到 30~60 秒。对金融场景,用户 30 秒收不到到账通知就会反复查账,体验极差。
坑三:也是最致命的------消息表状态与业务状态可能不一致。 定时任务补发时,如果下游清算系统已经因为超时做了冲正,消息再发过去就会造成重复入账 。我们线上就出现过一笔 5000 元的转账被清算系统入账两次的事故,最终靠人工对账才追平。
复盘结论:本地消息表适合"业务方自己消费消息"的场景,但我们的下游是独立的清算系统,双方状态无法在同一个事务里对齐,消息表只能保证"消息不丢",保证不了"业务状态一致"。这正是需要引入分布式事务框架的典型信号。
3. 方案:我们怎么选型、改造,给出具体做法
3.1 选型:2PC、TCC 与 Saga 的对比
我们评估了三个候选方案,核心指标是:一致性强度、对业务侵入度、回滚可行性、性能损耗。
| 方案 | 一致性 | 业务侵入 | 回滚能力 | 性能损耗 | 适用场景 |
|---|---|---|---|---|---|
| 2PC(XA) | 强一致 | 低(数据库层) | 自动回滚 | 高(锁持有整个事务) | 短事务、低并发、同构数据库 |
| TCC | 最终一致 | 高(需写 Try/Confirm/Cancel) | 业务补偿 | 中(Try 阶段短锁) | 跨系统、需明确补偿语义 |
| Saga | 最终一致 | 中(需写补偿事务) | 业务补偿 | 低 | 长事务、异步流程 |
我们最终选了 Seata 1.7.0 的 TCC 模式,理由有三:
- 转账是典型的"预留---确认"语义:扣款可以拆成"冻结资金(Try)→ 解冻并扣减(Confirm)→ 解冻并退回(Cancel)",天然契合 TCC 模型。
- 性能可控:Try 阶段只做冻结,不真正扣减,锁持有时间从 23ms 降到 5ms 以内。
- Seata 在金融行业落地案例多,社区活跃,且我们已有的注册中心是 Nacos 2.2.3,Seata 原生支持。
3.2 实操:Seata TCC 改造落地
3.2.1 环境与依赖
xml
<!-- Spring Boot 2.7.18 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2021.0.5.0</version>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.7.0</version>
</dependency>
Seata Server 部署在 3 台 4C8G 的 ECS 上,注册到 Nacos,配置中心也用 Nacos。事务分组名 transfer_tx_group。
3.2.2 账户服务的 TCC 接口
java
@LocalTCC
public interface AccountService {
@TwoPhaseBusinessAction(name = "freezeBalance",
commitMethod = "confirmFreeze", rollbackMethod = "cancelFreeze")
boolean freezeBalance(@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
boolean confirmFreeze(BusinessActionContext ctx);
boolean cancelFreeze(BusinessActionContext ctx);
}
实现类里,Try 阶段执行 UPDATE t_account SET frozen_balance = frozen_balance + ? WHERE account_no = ? AND available_balance >= ?,Confirm 阶段执行 UPDATE t_account SET available_balance = available_balance - ?, frozen_balance = frozen_balance - ?,Cancel 阶段执行 UPDATE t_account SET frozen_balance = frozen_balance - ?。
关键点 :Try 阶段必须校验余额充足,否则直接返回 false 触发全局回滚;Confirm/Cancel 必须幂等 ,用 frozen_balance 字段做状态判断,避免重复执行。
3.2.3 转账编排
java
@GlobalTransactional(name = "transfer_tx", timeoutMills = 30000)
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
accountService.freezeBalance(fromAccount, amount); // Try:冻结扣款方
accountService.freezeBalance(toAccount, amount.negate()); // Try:冻结收款方(负数表示增加)
transferRecordService.createRecord(fromAccount, toAccount, amount); // 本地事务写流水
// 全部 Try 成功后,Seata 自动提交 Confirm
}
这里有个设计细节:收款方也走冻结,是为了保证"扣款成功、入账失败"时能整体回滚,避免单边账。
3.3 踩坑记录:三个真实报错与解决
3.3.1 坑一:No available service 报错
上线第一天,压测 200 并发时日志刷出:
io.seata.common.exception.FrameworkException: No available service
at io.seata.discovery.registry.RegistryFactory.getInstance(RegistryFactory.java:52)
排查:Seata Client 连不上 Server。检查 Nacos 注册列表,发现 Server 注册的是内网 IP,而 Client 在另一网段,无法路由。
解决 :在 registry.conf 里显式指定 Server 的 public IP,并确认 8091 端口在安全组放通。改完后重试,注册成功。
3.3.2 坑二:Branch transaction rollback failed 与脏写
压测 500 并发时,出现:
Branch transaction rollback failed, xid=192.168.1.10:8091:123456, branchId=789012
排查 :这是 TCC 的 Cancel 执行失败。看日志发现是 frozen_balance 字段被并发覆盖------两个事务同时冻结同一账户,Cancel 时用 frozen_balance - ? 直接减,把另一个事务的冻结额也减掉了。
解决 :Cancel 改成条件更新 ,只有 frozen_balance >= amount 才执行扣减,否则返回 false 触发重试;同时给 t_account 表加 version 乐观锁字段,更新时校验版本号。
3.3.3 坑三:Confirm 幂等失效导致重复入账
灰度期间,一笔 2000 元转账在 Confirm 阶段网络超时,Seata 重试 Confirm,结果入账两次。
排查 :Confirm 的 SQL 是 available_balance = available_balance + ?,没有幂等判断。重试时再次执行,就重复加钱了。
解决 :在 t_transfer_record 表加 status 字段,Confirm 前先查状态,status = 'CONFIRMED' 则直接返回成功;同时给 (xid, branch_id) 建唯一索引,从数据库层面兜底防重。
4. 权衡:这么做有什么代价,什么场景不建议这么干
改造完成后,我们做了 3 轮压测和 2 周灰度观察,核心指标如下:
| 指标 | 改造前(本地消息表) | 改造后(Seata TCC) |
|---|---|---|
| 转账事务平均耗时 | 23ms | 6ms |
| P99 延迟 | 480ms | 150ms |
| 消息丢失/幽灵失败率 | 0.08%(日均约 96 笔) | 0 |
| 客服工单(日均) | 40+ | 3(均为咨询类) |
| 对账差异笔数(日终) | 5~8 笔 | 0 |
灰度两周,累计处理交易 168 万笔,未出现一笔单边账或重复入账。压测 800 并发下,Seata Server 的 CPU 峰值 45%,内存稳定在 2.5G,无 OOM。
但代价也很明确:
- 多部署一套 Seata Server:3 台 4C8G 的 ECS,加上 Nacos 注册与配置中心,运维成本上升。
- 业务侵入高:每个参与方都要写 Try/Confirm/Cancel 三套方法,代码量翻倍,且幂等、防脏写全靠 SQL 写得严谨。
- Try 阶段仍持有资源锁:虽然比 2PC 短,但分支多或单分支耗时长时,锁等待依然会放大。
什么场景不建议这么干:
- 纯查询或单库事务:杀鸡用牛刀,本地事务 + 消息表足够。
- 长事务、多节点编排:TCC 的 Try 阶段会持有资源锁,超过 10 个分支或单分支超过 2 秒,性能会急剧恶化,这种情况建议用 Saga。
- 无法定义补偿语义的业务:比如"发送短信"这种不可逆操作,TCC 的 Cancel 无法真正回滚,别硬套。
- 团队没有 DBA 或对 SQL 不敏感:TCC 的幂等和防脏写全靠 SQL 写得严谨,写不好就是新的数据事故源。
5. 总结:适用边界,哪些业务不要照搬
分布式事务没有银弹。本地消息表解决"消息不丢",TCC 解决"业务状态一致",两者适用边界完全不同。我们这次踩的坑,本质是拿"消息可靠性方案"去解决"业务一致性"问题。
适合照搬 TCC 的业务:
- 跨系统、跨数据库的写操作,且业务能清晰拆出"预留---确认---取消"三个阶段,比如转账、下单锁库存、积分扣减。
- 对一致性要求高,但能接受"短暂不一致、最终一致"的金融/交易类业务。
- 已有注册中心和配置中心,愿意为 Seata Server 多部署一套服务。
不要照搬的业务:
- 单库事务或纯查询,本地事务足够。
- 长事务、多节点编排,用 Saga 更合适。
- 不可逆操作(发短信、发邮件),TCC 无法真正回滚。
- 团队 SQL 功底薄弱,幂等和防脏写写不严谨,TCC 反而引入新事故源。
选型前先问自己:你要的到底是"消息不丢",还是"账不能错"?