分布式事务解决方案:最佳实践
上周五晚上十点半,我正瘫在工位上啃冷掉的鸡腿堡,突然收到运维老哥甩来的告警:订单服务和账户服务的数据对不上了。当时我脑子里就一句话:“完犊子了,这周又得通宵。”
其实也不是第一次遇到这种问题了。自从两个月前我从上一家公司跳槽到这家金融科技初创公司(对外我们叫“FinTech”,对内我们管自己叫“修 bug 的”),分布式事务这玩意儿就成了我的噩梦。尤其是最近产品那边为了蹭热点,硬要在系统里加个“区块链积分兑换”模块——别误会,不是真上链,就是用了个 Hyperledger Fabric 模拟一下,结果把整个支付流程搞得支离破碎。
我是个干了五年后端的老兵,平时喜欢抠开源项目源码,比如 Seata、RocketMQ、ShardingSphere 这些。但说实话,在真正高并发、强一致性要求的金融场景下,理论和现实总是隔着十万八千里。这次事故也让我意识到,光看 GitHub star 数是没用的,得实战出真知。
所以今天这篇技术分享,不讲什么大道理,就聊聊我在实际项目中踩过的坑、调过的参,以及最终怎么把这套分布式事务方案给稳住的。希望对同样在水深火热中的 Java 后端兄弟们有点帮助。
一、问题背景:一个看似简单的转账需求,搞出三套系统
事情起源于产品经理小王(对,就是那个总在周五下午提新需求的家伙)的一句话:“我们要支持跨账户实时转账,还要能回滚,不能丢一分钱。”
听起来很合理,对吧?但在我们的架构里,这笔转账涉及三个独立的服务:
- 订单服务(OrderService):负责生成交易单
- 账户服务(AccountService):负责扣款和入账
- 积分服务(PointsService):配合区块链模块,记录用户积分变动
每个服务都有自己的数据库,MySQL + ShardingSphere 分库分表,部署在 Kubernetes 集群上。前端调用时,只发一个 /transfer 请求,后端需要保证这三个操作要么全成功,要么全失败。
理想很丰满,现实很骨感。上线第一天,测试同学就跑来问我:“你这系统是不是有 bug?我转了 100 块,钱扣了,积分没加,订单还是‘处理中’状态。”
我当时内心 OS:“这哪是 bug,这是经典的分布式事务一致性问题啊!”
二、方案选型:TCC?Saga?还是消息队列?
一开始团队内部吵翻了天。有人主张用 TCC(Try-Confirm-Cancel),觉得控制精细;有人推 Saga 模式,说适合长流程;还有人(比如我)想直接上 RocketMQ 事务消息,简单粗暴。
我们做了个对比表格,结合业务特点和技术栈:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TCC | 强一致性,回滚可控 | 开发成本高,需手动写 Try/Confirm/Cancel 逻辑 | 金融核心交易,如支付、转账 |
| Saga | 流程灵活,适合长事务 | 最终一致性,补偿逻辑复杂 | 订单履约、物流跟踪 |
| RocketMQ 事务消息 | 侵入性低,天然支持异步 | 依赖 MQ 可靠性,需处理消息幂等 | 异步解耦场景,如积分发放 |
考虑到我们是金融科技公司,对资金安全的要求是“宁可慢一秒,不能错一分”,最终决定主流程用 TCC,辅以 RocketMQ 事务消息做异步补偿(比如失败后的告警通知、人工干预入口)。
至于区块链?那玩意儿只是前端展示用的噱头,后端根本不参与一致性保证——别被名字唬住了,它连数据库都不是。
三、落地实践:Seata + 自定义 TCC 接口
我们选了 Seata 作为分布式事务框架。理由很简单:阿里系开源,社区活跃,Java 生态友好,而且源码我读过两遍(虽然现在有些地方改得我都认不出来了 😅)。
1. 架构图(文字版)
前端 → API Gateway → OrderService (发起方)
↘ AccountService (参与者)
↘ PointsService (参与者)
↑
Seata TC (事务协调器)
2. 关键代码:自定义 TCC 接口
Seata 要求每个参与方实现 @TwoPhaseBusinessAction 注解的方法。以账户服务为例:
@Service
public class AccountTccServiceImpl implements AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Override
@TwoPhaseBusinessAction(name = "deductBalance", commitMethod = "commitDeduct", rollbackMethod = "rollbackDeduct")
public boolean deductBalance(BusinessActionContext context, Long userId, BigDecimal amount) {
// Try 阶段:冻结资金
String txId = context.getXid();
AccountFrozen frozen = new AccountFrozen();
frozen.setUserId(userId);
frozen.setAmount(amount);
frozen.setTxId(txId);
frozen.setStatus("TRYING");
accountMapper.insertFrozen(frozen); // 插入冻结记录
// 检查余额是否足够(这里简化了,实际有并发锁)
BigDecimal balance = accountMapper.getBalance(userId);
if (balance.compareTo(amount) < 0) {
throw new RuntimeException("Insufficient balance");
}
return true;
}
public boolean commitDeduct(BusinessActionContext context) {
// Confirm 阶段:扣减真实余额,删除冻结记录
String txId = context.getXid();
accountMapper.confirmDeduct(txId);
return true;
}
public boolean rollbackDeduct(BusinessActionContext context) {
// Cancel 阶段:释放冻结资金
String txId = context.getXid();
accountMapper.deleteFrozen(txId);
return true;
}
}
注意点:
- Try 阶段不能直接扣钱,只能“预留”资源(比如冻结)
- 所有操作必须幂等!因为网络超时可能导致重复调用
- 数据库表要加唯一索引(如
tx_id),防止重复插入
3. 发起方(OrderService)怎么用?
@RestController
public class TransferController {
@GlobalTransactional // Seata 全局事务注解
@PostMapping("/transfer")
public Result transfer(@RequestBody TransferRequest request) {
// 1. 创建订单
orderService.createOrder(request);
// 2. 调用 TCC 接口
accountService.deductBalance(request.getFromUserId(), request.getAmount());
accountService.addBalance(request.getToUserId(), request.getAmount());
// 3. 积分服务(同理)
pointsService.awardPoints(request.getFromUserId(), 10);
return Result.success();
}
}
看起来很美,对吧?但现实狠狠打了我脸。
四、踩坑实录:那些让我想砸电脑的瞬间
坑 1:Seata 的 AT 模式 vs TCC 模式混淆
一开始我偷懒用了 Seata 默认的 AT 模式(自动 undo_log),结果在高并发下频繁出现死锁。后来才发现,AT 模式依赖数据库行锁,在分库分表场景下性能极差,而且无法处理“冻结余额”这种业务逻辑。
教训:金融场景别图省事,TCC 虽然代码多,但可控。
坑 2:网络超时导致的“悬挂”问题
有一次,AccountService 在 Try 阶段执行成功了,但响应超时,Seata 协调器以为失败了,于是直接调用 Cancel。结果用户的钱既没扣,也没释放(因为 Cancel 时找不到冻结记录)。
解决方案:
- 所有 TCC 接口必须记录完整的事务上下文(xid + stage)
- 启动定时任务扫描“悬挂事务”(即只有 Try 没有 Confirm/Cancel 的记录),人工或自动修复
坑 3:前端重试导致重复提交
前端同学为了“用户体验”,在请求失败时自动重试 3 次。结果同一个转账请求被提交了三次,幸好我们在 OrderService 加了 分布式幂等 Key(基于 user_id + order_no + timestamp 生成 token),否则真的要背锅了。
@PostMapping("/transfer")
public Result transfer(@RequestHeader("X-Idempotency-Key") String idempotencyKey, ...) {
if (redis.exists(idempotencyKey)) {
return Result.success("Already processed");
}
redis.setex(idempotencyKey, 3600, "processed");
// ...业务逻辑
}
五、性能优化:从 50 TPS 到 1200 TPS
刚上线时,压测结果惨不忍睹:50 TPS,CPU 打满。领导脸色都绿了:“这还怎么扛双11?”
我们做了几件事:
- 减少 RPC 调用次数:把 AccountService 和 PointsService 的 TCC 调用合并成一个 batch 接口
- 异步化非核心路径:比如积分变动日志写入 Kafka,而不是同步调用
- Seata 配置调优:
seata: service: vgroup-mapping: default_tx_group client: rm: report-success-enable: false # 减少无用上报 tm: commit-retry-timeout: 1000ms rollback-retry-timeout: 1000ms - 数据库连接池优化:HikariCP maxPoolSize 从 20 调到 50,并启用 statement cache
最终在 8 核 16G 的 Pod 上,稳定达到 1200 TPS,P99 延迟 < 300ms。虽然比不上纯本地事务,但在分布式场景下已经算不错了。
六、运维经验:线上如何监控和兜底
再完美的方案也怕网络抖动、机房断电。所以我们加了几道保险:
- 事务状态大盘:用 Prometheus + Grafana 监控 Seata 的 active transaction count、timeout rate
- 自动补偿 Job:每天凌晨扫描 24 小时内未完成的事务,尝试重试或标记为异常
- 人工干预后台:运维可以直接点击“强制回滚”或“强制提交”(当然,要三级审批)
最骚的是,我们甚至在前端加了个“事务追踪”页面(产品经理非要的),用户输入订单号就能看到当前事务处于哪个阶段——虽然 99% 的用户根本看不懂,但至少显得我们“技术很牛”。
七、总结:分布式事务没有银弹,只有权衡
写这篇文章的时候,窗外已经天亮了。回想这两个月,从被分布式事务折磨到逐渐摸清门道,最大的感悟是:
在金融系统里,一致性永远比性能重要,而可观测性比代码优雅更重要。
TCC 虽然啰嗦,但它给了我们对资金流向的完全控制权;Seata 虽然偶尔抽风,但它的社区响应速度确实快(感谢阿里兄弟们);至于区块链?下次产品经理再说“上链”,我就把这篇文档甩他脸上。
最后送大家一句我司墙上贴的话(老板花 200 块淘宝买的):“Code is cheap, show me the data.”
共勉。
—— 一个刚入职俩月、还在适应新公司咖啡机操作方式的 Java 后端

评论 0