分布式事务这玩意儿,不搞懂真的睡不着觉(尤其当你有两个娃还要改 Bug 的时候)
上周五晚上十一点半,我刚把两个小祖宗哄睡——一个三岁会翻墙,一个一岁会拆家。正打算打开 IntelliJ,突然手机「叮」一声:项目告警!订单创建成功但库存没扣!
操,又是分布式事务的问题。
我揉了揉眼睛,心里默默问候了一遍产品经理(不是真骂哈,我们 PM 其实人不错,就是需求写得像谜语)。这事说来话长:我们公司做的是一个电商 SaaS 平台,给中小商家提供“运营”工具,比如库存管理、订单同步、营销活动等。系统早几年是单体架构,Java 写的,跑得好好的。后来业务一火,用户量猛增,老板大手一挥:“微服务!上云原生!K8s 走起!”
于是,订单服务、库存服务、优惠券服务全拆开了,各跑各的 Pod,数据库也分库分表。结果?事务一致性直接崩了。
为什么分布式事务这么要命?
在单体应用里,一个 @Transactional 注解就搞定的事,在微服务世界里能让你掉光头发。比如下面这个经典场景:
- 用户下单 → 调用 订单服务 创建订单
- 同步调用 库存服务 扣减库存
- 如果库存不足,整个流程要回滚
听起来简单吧?但现实是:
- 库存服务可能网络超时(别问,问就是北京晚高峰通勤时连 Wi-Fi 都卡)
- 订单服务写库成功,但调库存时 JVM 挂了(对,就是你重启 K8s Pod 那一刻)
- 更惨的是:库存扣了,订单没建成功 —— 用户白拿货!
去年双11,我们就因为这问题被客户投诉到客服爆线。运维老哥半夜打电话给我:“兄弟,快看看,又丢库存了!” 那会儿我老婆正在产房生二胎,我一手抱娃一手 ssh 进生产环境,心态差点炸裂。
我试过的几种方案(以及踩过的坑)
1. 两阶段提交(2PC)?别闹了!
一开始我们天真地用了 XA 协议 + Atomikos(Java 里常见的 JTA 实现)。代码大概是这样:
@GlobalTransactional
public void createOrder(OrderDTO order) {
orderService.save(order);
inventoryService.decreaseStock(order.getProductId(), order.getQuantity());
}
看起来很美好,对吧?但上线三天就崩了:
- 性能极差:所有参与服务都要锁住资源等协调者指令,高峰期 TPS 直接腰斩
- 单点故障:协调者挂了,所有事务卡死,数据库连接池爆满
- K8s 不友好:Pod 随时被调度,网络抖动直接导致事务悬挂
最后只能灰度下线。运维同事吐槽:“你们 Java 程序员是不是以为全世界都等着你 commit 啊?”
2. 消息队列 + 最终一致性(目前主力方案)
痛定思痛,我们转向了更接地气的 本地消息表 + RocketMQ 事务消息 方案。核心思想就一句:先干,干完发消息,别人慢慢跟上。
架构设计
[订单服务]
│
├─ 1. 开启本地事务:插入订单 + 插入"待发消息"记录
│
├─ 2. 提交本地事务
│
├─ 3. 发送 RocketMQ 事务消息(half message)
│
└─ 4. MQ 回查本地消息状态,确认是否 commit
│
▼
[库存服务] 消费消息 → 扣库存 → 更新消息状态为已处理
关键代码(Java + Spring Boot)
订单服务 - 发送事务消息
@Transactional
public void createOrderWithTxMsg(OrderDTO order) {
// 1. 保存订单
Order savedOrder = orderRepository.save(order);
// 2. 插入本地消息表(状态=待发送)
Message msg = new Message();
msg.setBusinessType("DECREASE_STOCK");
msg.setPayload(JsonUtil.toJson(Map.of("orderId", savedOrder.getId(), "productId", order.getProductId())));
msg.setStatus(MessageStatus.PENDING);
messageRepository.save(msg);
// 3. 发送 RocketMQ 事务消息
TransactionMQProducer producer = rocketMQTemplate.getProducer();
TransactionSendResult sendResult = producer.sendMessageInTransaction(
"ORDER_TOPIC",
MessageBuilder.withPayload(msg.getPayload()).build(),
msg.getId() // 传入本地消息ID作为参数
);
}
RocketMQ 回查监听器
@Component
public class OrderTransactionListener implements TransactionListener {
@Autowired
private MessageRepository messageRepository;
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 这里其实不会执行,因为我们已经提前写了本地消息
return LocalTransactionState.UNKNOW;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String localMsgId = (String) msg.getProperty("localMsgId");
Message localMsg = messageRepository.findById(localMsgId).orElse(null);
if (localMsg == null) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
// 如果本地消息存在且状态为PENDING,说明本地事务已提交,可以commit MQ消息
return LocalTransactionState.COMMIT_MESSAGE;
}
}
库存服务 - 消费消息(带幂等)
@RocketMQMessageListener(topic = "ORDER_TOPIC", consumerGroup = "inventory-group")
public class InventoryConsumer implements RocketMQListener<String> {
@Transactional
public void onMessage(String payload) {
StockDecreaseRequest req = JsonUtil.fromJson(payload, StockDecreaseRequest.class);
// 幂等检查:是否已处理过该订单
if (processedOrderService.exists(req.getOrderId())) {
return; // 已处理,直接返回
}
// 扣库存
inventoryService.decrease(req.getProductId(), req.getQuantity());
// 标记为已处理
processedOrderService.markAsProcessed(req.getOrderId());
}
}
坑点总结
- 幂等性必须做:MQ 可能重复投递(比如消费者 ack 前 crash),否则库存可能被多扣
- 本地消息表要定期清理:我们加了个定时任务,3天前的已处理消息自动归档
- 监控要到位:Prometheus + Grafana 监控“消息积压量”、“处理失败率”,一旦异常立刻告警
3. Seata?还在观望中…
最近团队也在评估 Seata(阿里开源的分布式事务框架)。它支持 AT 模式(自动补偿)、TCC 模式等,看起来很香。但考虑到:
- 学习成本高(又要学新注解、新配置)
- 对数据库有侵入(AT 模式要建 undo_log 表)
- 我们现有 MQ 方案已经稳定跑了几个月
所以暂时没动。不过听说隔壁组用 Seata + TCC 做支付对账,效果不错。等哪天我娃睡整觉了(大概要等他上小学?),再深入研究吧。
生产环境运维经验(血泪教训)
| 问题 | 解决方案 | 教训 |
|---|---|---|
| 消息消费失败堆积 | 自动重试 + 死信队列 + 人工干预页面 | 别指望自动恢复,关键业务必须有人盯 |
| 本地事务与消息发送不一致 | 严格保证“先写 DB 再发消息”顺序 | 任何异步操作都不能放在事务外 |
| 库存超卖 | Redis 预扣 + DB 最终校验 | 热点商品一定要加分布式锁(Redisson) |
| 调试困难 | 全链路 Trace ID 透传 | 没有 traceId 的日志等于没有 |
特别提醒:千万别在事务里调远程服务! 我见过新人这么写:
@Transactional
public void badExample() {
orderRepo.save(order);
restTemplate.postForEntity("http://inventory/decrease", ...); // 危险!
}
一旦远程调用超时,事务长时间不提交,数据库连接耗尽,整个服务雪崩。这种代码出现在 CR 里,我会直接打回去并附上一句:“兄弟,你今晚不用回家了。”
效果如何?值不值得折腾?
自从上了 MQ 最终一致性方案后:
- 数据不一致率从 0.5% 降到 0.001% 以下
- 双11 零库存事故
- PM 终于敢给我们提“跨服务原子操作”的需求了(虽然我还是会翻白眼)
最重要的是——我现在能在娃睡后安心写代码了。虽然还是经常凌晨三点被 PagerDuty 叫醒,但至少不是因为“库存丢了”这种低级问题。
写在最后
分布式事务没有银弹,只有适合当前业务阶段的权衡。如果你的项目刚起步,单体 + 数据库事务完全够用;如果已经微服务化,最终一致性 + 强监控 是最务实的选择。
别被那些“强一致性”“完美 ACID”的 PPT 忽悠了。在真实的运营场景里,可用性 > 一致性(当然也不能太离谱)。毕竟,用户更关心能不能下单,而不是你的 CAP 理论选了哪两个。
哦对了,这篇文章是我在凌晨 1:17 写的。老大刚刚在群里 at 我:“明天上线新活动,库存预热脚本跑了吗?” —— 得,又是一个不眠夜。
但没办法,谁让我是个奶爸程序员呢?代码要写,娃也要哄。好在,这两个 skill tree 点满了,抗压能力直接拉满。
共勉。

评论 0