分布式事务解决方案:我踩过的坑和学到的经验
在互联网行业工作了这么多年,分布式系统几乎是每个后端开发者都要面对的“成人礼”。尤其当你负责一个核心交易系统时,数据一致性就成了头等大事。而在这个过程中,分布式事务是绕不开的一道坎。
今天我就想结合自己亲身参与的一个真实项目案例,来聊聊我们在做一次跨多个服务、多个数据库的订单支付流程中,是如何一步步应对和解决分布式事务问题的,以及在这条路上踩过的一些坑和后来总结出的经验。
背景介绍:为什么我们需要处理分布式事务?


我之前负责的是电商平台的核心交易系统。这个系统的模块拆得比较细:有订单中心、支付中心、库存中心、用户中心、积分系统等多个微服务,各个服务之间通过 RPC 接口相互调用。
举个最典型也是最常见的业务场景:
用户提交订单后,需要:
- 扣除库存(库存中心)
- 更新订单状态为“已支付”(订单中心)
- 生成支付记录并通知第三方支付平台(支付中心)
- 增加用户的积分(用户中心)
这些操作分布在四个不同的服务里,使用各自的数据库。一旦其中任何一个步骤失败,整个流程必须回滚,否则就会出现钱扣了但库存没减,或者订单状态变好了但积分没加的情况——这就是典型的分布式事务需求。
问题描述:早期我们怎么做的?遇到了哪些问题?

起初我们为了快速上线,采用了一个简单的做法:本地事务 + 异步补偿机制(定时任务)。
比如订单支付成功后:
- 订单中心将订单状态改为“已支付”;
- 支付中心记录一笔支付流水;
- 库存中心异步发送一个 MQ 消息去扣除库存;
- 用户中心也接收一个 MQ 消息增加积分。
理论上只要某个环节失败,定时任务会去扫描异常订单做补偿。
但现实很残酷:系统刚上线就出了好几个严重 bug:
- 库存被多扣或漏扣:MQ 发送失败、消费者崩溃、重试逻辑不严谨
- 积分发放异常:MQ消息重复消费导致积分数目翻倍
- 订单状态与实际不符:网络超时引发状态不一致
虽然最终通过定时任务兜底能解决一部分问题,但用户体验很差,而且运维成本高到难以承受。更别说在高峰期,光看报警日志都能让你血压飙升。
所以我们意识到:这套“土办法”只能短期应急,真正要支撑高并发、高可靠的服务,必须引入更规范、更稳定的分布式事务方案。
解决思路:选型和权衡


当时市面上主流的方案有几个方向:
- 两阶段提交(2PC)
- TCC(Try-Confirm-Cancel)模式
- Saga 模式
- 基于消息队列的事务性消息
- Seata 等开源框架
我们团队做了对比分析,最终选择了以 Seata 为主、结合 TCC 的策略落地。理由如下:
- Seata 提供了较完整的分布式事务支持,兼容 Spring Cloud 和 Dubbo
- 我们业务场景偏向金融级,对一致性要求很高
- 有些关键路径可以使用 AT 模式简化开发,性能也能接受
- 对于非数据库资源(如积分操作),采用 TCC 模式进行补充
整体架构调整
我们将原有的订单支付流程改造为以下结构:
OrderService (TM)
→ InventoryService (RM)
→ PayService (RM)
→ UserService (RM)
TM(Transaction Manager)即事务协调者,由 OrderService 扮演;其他服务作为 RM(Resource Manager),各自管理自己的数据库资源,并在全局事务中注册。
同时,我们在每台节点上都部署了 Seata Server(TC 组件),用来协调整个事务的状态流转。
关键代码实践:如何接入 Seata

下面是一个简化的代码示例,展示如何在一个订单支付方法中开启分布式事务:
@GlobalTransactional
public void payOrder(String orderId) {
try {
orderService.updateOrderToPaid(orderId); // 修改订单状态
inventoryService.reduceInventory(orderId); // 扣库存
payService.createPaymentRecord(orderId); // 创建支付记录
userService.addUserPoints(orderId); // 增加积分
} catch (Exception e) {
throw new RuntimeException("支付失败", e);
}
}
只需要加一个 @GlobalTransactional 注解,Seata 就会在背后完成:
- 开启全局事务
- 各个服务执行分支事务(Branch Transaction)
- 最终提交或回滚整个事务
是不是特别简单?确实,Seata 的优势就在于它对业务代码侵入性较小,只需要注解就能控制事务边界。
数据库层面的配合
为了配合 Seata 的 AT 模式,我们的各个数据库表都加上了 undo_log 表,用于事务回滚时恢复原始数据:
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB;
这是 Seata 实现自动补偿的重要前提。
遇到的坑与调试经验
说实话,接入 Seata 并不是一帆风顺的,下面几个坑都是我们真实踩过的,分享出来希望你们能避免。
坑1:事务传播性搞不清楚,结果没生效
一开始我们在调用其他服务的时候,是这样写的:
@GlobalTransactional
public void payOrder(String orderId) {
orderService.updateOrderToPaid(orderId);
otherService.invoke(); // 其他服务内部也有注解?
}
结果发现事务根本没起作用。查了半天才发现是因为默认的事务传播机制是 Propagation.REQUIRED,但跨服务调用时如果没有正确透传 xid,事务上下文就断开了。
解决方法:确保服务之间的调用走的是 Seata 自动注入的代理类,RPC 框架要做对应的集成,比如 Dubbo 或 Feign 需要引入适配器组件。
坑2:幂等性没有做好,导致重复扣款或库存
在某个压测环境中,我们发现部分订单的积分被加了两次,库存也被扣了两次。原因是某些分支事务回滚后重试,但下游服务没做幂等校验。
解决方法:所有分支操作必须带上唯一标识符(比如订单ID+服务类型),并在入库前检查是否已经执行过该事务分支。
坑3:Seata Server 宕机导致事务卡住
我们有一次生产环境升级 Seata Server,忘记先停业务流量,导致很多事务处于中间态(prepared),迟迟没法释放锁。
解决方法:
- 升级前务必做灰度发布和流量切换
- 监控 Seata 日志中的
timeout和hang状态 - 自动清理机制也要跟上(比如写个巡检脚本定期解锁)
效果与收益:稳定性和体验双提升
自从全面接入 Seata 并优化好相关流程后,整个系统的数据一致性得到了极大提升:
- 订单支付成功率提高了 98%
- 人工客服因数据不一致导致的咨询量下降 75%
- 运维同学再也不用天天盯着定时任务跑补偿逻辑了
- 同时,我们也实现了更好的可扩展性,在后续增加新的服务时,只要按 Seata 的规范接入,即可无缝加入事务流程
更重要的是:我们终于能自信地说,“我们的订单支付链路,现在是可靠的”。
总结与建议:写给后来者的几点心得
如果你也在设计分布式系统,或者正在考虑分布式事务的实现方案,我想给你几点实用建议:
✅ 方案选型要考虑长期维护成本
不要为了“快上线”牺牲可维护性。像 Seata 这样的框架虽然一开始需要花时间搭环境、做集成,但它带来的稳定性价值远高于短期节省的时间。
✅ 优先选择轻量级、侵入少的方案
除非你有非常复杂的业务规则,否则尽量少写 TCC 的 Confirm/Cancel 方法,太容易出错。AT 模式更适合大多数常规场景。
✅ 不要把所有业务压在一个大事务里
事务越长,出错概率越高,性能损耗也越大。合理拆分业务流程,必要时允许“最终一致”,反而更容易实现高性能。
✅ 注意幂等、重试、超时、限流等周边机制
这些都是分布式系统中不可忽视的配套工程。一个完善的事务系统不仅需要事务机制本身,还需要周边保障措施的支持。
✅ 加强监控和告警体系
建议将 Seata 的事务状态、各分支执行情况统一接入监控平台,做到问题第一时间发现,而不是靠客户投诉才知道“又有订单扣库存失败了”。
这是我个人从一次真实项目中学到的关于分布式事务的经验。说到底,真正的技术成长往往不是来自文档里的理论,而是来自那些凌晨三点还在查日志的日日夜夜。希望这篇文章对你有所启发,也欢迎留言交流你的经验和想法!

评论 0