分布式事务的坑,我替你踩过了
上周五晚上九点半,办公室里只剩我和运维小哥大眼瞪小眼。
产品经理临时提了个需求:“用户支付成功后,要同步更新会员等级、积分和优惠券库存。”听起来人畜无害对吧?但问题在于——这三个服务分别跑在三个不同的微服务里,数据库也各自独立。
我当时心里咯噔一下:这不就是经典的分布式事务场景吗?
作为一个从测试转开发刚满三年的“半路出家选手”,我对事务的理解还停留在 @Transactional 和 rollback 的层面。但现实狠狠给我上了一课——单体应用里的 ACID,在微服务架构下直接碎成渣。
为什么这事这么难?
先说说我现在的处境。入职新公司才两个月,团队用的是 Spring Cloud + Java 17 + MySQL 的经典组合。项目 deadline 就在下周上线,而这个“同步更新”逻辑如果没处理好,轻则数据不一致,重则资损事故——到时候背锅的大概率是我这个“新人”。
我翻了翻公司内部 Wiki,发现之前也有类似需求,但解决方案五花八门:有的用定时对账,有的靠人工补单,甚至还有一段注释写着:“此处已知有数据不一致风险,业务可接受”。看得我头皮发麻。
于是周末两天,我泡在 VSCode 里(插件装了一堆,比如 GitLens、Error Lens、Rust Analyzer——最近迷上 Rust 了,虽然还没敢在生产环境用),疯狂翻书、查文档、看开源项目。顺手把《数据密集型应用系统设计》又啃了一遍,这本书真是分布式系统的圣经。
市面上的方案,真的能打吗?
分布式事务的主流解法,大致分这么几类:
1. 两阶段提交(2PC)——理论上完美,现实中劝退
Java 里可以用 Atomikos 或 Bitronix 实现 JTA 事务。配置起来那叫一个酸爽:
@Configuration
public class JtaConfig {
@Bean
public UserTransaction userTransaction() throws SystemException {
UserTransactionImp userTransactionImp = new UserTransactionImp();
userTransactionImp.setTransactionTimeout(300);
return userTransactionImp;
}
@Bean
public TransactionManager transactionManager() throws SystemException {
return new UserTransactionManager();
}
}
但问题来了:2PC 要求所有参与者长时间锁定资源。在高并发场景下,数据库连接池直接被打爆。我们压测时,TPS 从 1200 直接掉到 80,DBA 看到监控图差点报警。
结论:除非是银行核心系统那种对一致性要求极高、并发极低的场景,否则别碰。
2. TCC(Try-Confirm-Cancel)——灵活但开发成本爆炸
TCC 的思路很清晰:把一个业务操作拆成三步。
- Try:预留资源(比如冻结库存)
- Confirm:真正执行(扣减库存)
- Cancel:释放预留(解冻)
听上去很美,但每个服务都得写三套逻辑,还得处理各种异常回滚。更坑的是,Confirm 和 Cancel 必须保证幂等——不然网络抖动重试一次,用户积分就多加两次。
我们团队试了一个简化版,结果光是幂等校验的 token 生成和存储,就写了 200 行代码。产品经理还在群里问:“怎么三天还没搞定?” 我默默关掉了聊天窗口。
3. 本地消息表 + 轮询——土但有效
这是很多老厂用的方案。核心思想是:在本地事务里同时写业务数据和消息记录,然后靠后台任务轮询发送消息。
比如支付成功后:
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO local_message (msg_id, content, status)
VALUES ('pay_123', '{"userId":1001,"points":50}', 'pending');
COMMIT;
然后有个定时任务扫描 local_message 表,把状态为 pending 的消息发到 MQ。
优点?简单、稳定、兼容现有架构。
缺点?轮询有延迟,消息表会膨胀,还得处理重复消费。
但我们线上跑了几个月,除了偶尔 DBA 抱怨“这张表怎么又涨到 10GB 了”,基本没出过大问题。
最终选择:Saga 模式 + 可靠事件
综合评估后,我们决定采用 Saga 模式,配合 可靠事件(Reliable Event) 机制。
Saga 的核心是:把一个长事务拆成多个本地事务,每个步骤都有对应的补偿操作。如果某一步失败,就逆向执行前面的补偿逻辑。
举个例子:
- 扣款成功 → 发送“扣款完成”事件
- 收到事件 → 更新会员等级 → 发送“等级更新”事件
- 收到事件 → 增加积分 → 发送“积分增加”事件
如果第三步失败,就触发“回滚积分” → “回滚会员等级” → “退款”。
关键是如何保证事件不丢失?我们用了 RocketMQ 的事务消息。
// 发送半消息
TransactionSendResult sendResult = rocketMQTemplate.sendMessageInTransaction(
"pay-topic",
MessageBuilder.withPayload(event).build(),
null
);
// 本地事务执行(扣款+写订单)
@Transactional
public void executeLocalTx() {
orderService.create(order);
accountService.deduct(userId, amount);
}
RocketMQ 会先发一个“半消息”,等本地事务 commit 后再真正投递。即使应用 crash,Broker 也会回查事务状态。
这套方案上线后,数据一致性达标,TPS 也没掉多少。最重要的是——不用改现有服务结构,只需要在关键节点加上事件发布和监听。
区块链?别闹了
说到这儿,可能有人会问:“你们考虑过用区块链做分布式事务吗?”
说实话,我们技术总监上周还真提过这茬。他说:“区块链不是天然解决信任和一致性问题吗?”
我当场就懵了。赶紧翻了翻白皮书,又查了 Trae(我们公司内部的一个技术雷达工具)上的评估报告。结论很明确:区块链不适合做业务系统的分布式事务。
原因很简单:
- 性能太差:比特币每秒 7 笔交易,以太坊也就 30 左右。我们支付系统峰值 5000 TPS。
- 成本太高:每次写链都要付 Gas 费,算下来比数据库贵几十倍。
- 过度设计:我们根本不需要“去中心化”,公司内部服务本来就是可信的。
最后我在技术评审会上委婉地说:“要不咱们先把 MySQL 主从同步配好吧?” 全场笑翻,这事也就不了了之了。
生产环境踩过的雷
方案定了,不代表万事大吉。上线头三天,我还是被几个问题折磨得睡不着觉。
雷区一:补偿操作失败怎么办?
Saga 的补偿操作本身也可能失败。比如“回滚积分”时,用户账号被注销了,接口返回 404。
我们的解法是:补偿操作必须无限重试 + 人工介入兜底。
- 消息队列设置最大重试次数(比如 16 次,指数退避)
- 失败消息进入 dead-letter queue
- 运维每天早上看一眼告警面板,手动处理异常单
雷区二:事件顺序错乱
由于网络延迟,可能出现“积分增加”事件先于“会员等级更新”到达。虽然业务上影响不大,但日志排查时简直噩梦。
解决方案:给事件加上 sequence number,消费者按序处理。或者干脆设计成“最终一致即可”,不强求顺序。
雷区三:本地事务和消息发送的原子性
早期我们用的是“先发消息,再执行本地事务”。结果有一次 MQ 宕机,消息没发出去,但本地事务已经 commit,导致下游没收到通知。
后来改成 RocketMQ 事务消息,才算彻底解决。记住:任何“先 A 再 B”的操作,在分布式系统里都是定时炸弹。
经验总结:什么场景用什么方案?
经过这次折腾,我整理了一个简单的决策表:
| 场景 | 推荐方案 | 适用条件 |
|---|---|---|
| 强一致性、低并发 | 2PC / XA | 银行转账、金融清算 |
| 高并发、可接受短暂不一致 | Saga + 可靠事件 | 电商下单、支付 |
| 业务简单、容忍延迟 | 本地消息表 | 内部系统、非核心链路 |
| 跨组织、不可信参与方 | 区块链(谨慎) | 供应链金融、存证 |
对我们这种互联网公司,Saga + MQ 是性价比最高的选择。
写在最后
从测试转开发这三年,我越来越体会到:分布式系统没有银弹,只有权衡(trade-off)。
以前做测试时,总觉得开发写的代码漏洞百出;现在自己写,才发现不是他们不想写好,而是现实约束太多——deadline、人力、历史包袱……
不过话说回来,能亲手设计并落地一套分布式事务方案,还是挺有成就感的。昨天上线后,我特意看了眼监控面板,所有服务状态绿油油的,心里一块石头总算落地。
对了,最近我还打算用 Rust 重写一个轻量级的 Saga 协调器练练手。毕竟 Rust 的所有权模型在并发安全上太香了,说不定哪天就能替代 Java 在某些场景的地位?(当然这话千万别让 Java 老哥们听见)
如果你也在踩分布式事务的坑,欢迎留言交流。
记住:你不是一个人在战斗,我们都在屎山里仰望星空。

评论 0