分布式事务解决方案:从翻车现场到稳如老狗的最佳实践
上周五晚上 10 点,我坐在成都春熙路附近一家还没打烊的咖啡馆里,盯着监控面板上那条不断告警的曲线,一边啃着冷掉的冒菜,一边在心里默默问候某个产品经理祖宗十八代——对,就是我自己。
没错,我是个“前·产品经理”,现在在一家中型电商公司做后端开发。当初转岗的理由也很简单:天天画原型、写 PRD、被研发怼需求不闭环,最后终于忍无可忍,自己撸起袖子下场写代码。结果?产品没做好,代码倒是越写越秃了。
但说真的,从 PM 转技术最大的好处是:我比大多数程序员更懂“业务为什么非得这么搞”。比如今天要聊的分布式事务问题,就源于去年双 11 前的一个“史诗级需求”。
起因:一个看似人畜无害的“库存+订单”联动需求
事情是这样的:我们有个新业务线要做“秒杀 + 预售”混合模式。用户下单时,系统要同时:
- 扣减商品库存(调用库存服务)
- 创建订单(调用订单服务)
乍一看,两个接口串起来就行。但现实狠狠扇了我一巴掌——网络不可靠、服务会超时、数据库可能挂。上线第一天压测,10% 的请求出现了“扣了库存但没生成订单”的情况,直接导致超卖。运维小哥当场裂开,测试妹子追着我问:“你不是产品经理出身吗?怎么连数据一致性都不考虑?”
那一刻,我深刻理解了什么叫“自己挖的坑,含泪也要填完”。
分布式事务?别被名字吓住,本质就是“要么全成功,要么全回滚”
先说结论:没有银弹,只有权衡。分布式事务的核心矛盾在于:CAP 定理告诉我们,强一致性(C)和高可用(A)不能兼得。而业务又要求“不能丢单、不能超卖”,那怎么办?
我翻遍了 Seata、TCC、Saga、消息队列最终一致性……甚至把 RocketMQ 的事务消息源码都拉下来 debug 过(感谢 Apache 开源精神)。最后结合我们团队的技术栈(Spring Cloud + MySQL + K8s 部署),选了一条性价比最高、运维成本最低的路:基于可靠消息的最终一致性方案。
💡 为什么没选 Seata AT 模式?
因为我们库存表加了复杂的行锁逻辑,Seata 的全局锁容易引发死锁;而且团队没人敢在大促前上新中间件,怕背锅。
实战:用 RocketMQ + 本地事务表搞定一致性
架构设计
核心思路就一句话:先发消息,再执行本地操作,失败就重试。但细节决定成败。
我们做了三层保障:
- 本地事务表:下单前,先把“待发送的消息”写入
message_outbox表,和订单创建在一个 DB 事务里。 - 定时补偿任务:后台 Job 每 30 秒扫一次未发送成功的消息,重新投递。
- 消费者幂等:库存服务收到消息后,先查是否已处理过该订单 ID,避免重复扣减。
-- 本地事务表结构(MySQL)
CREATE TABLE message_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
message_id VARCHAR(64) NOT NULL UNIQUE, -- 全局唯一消息ID
topic VARCHAR(128) NOT NULL,
body JSON NOT NULL,
status TINYINT DEFAULT 0, -- 0:待发送 1:已发送 2:发送失败
retry_count INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_retry (status, retry_count)
);
关键代码片段(Spring Boot)
@Transactional
public Order createOrder(OrderRequest request) {
// 1. 本地创建订单(状态为"待支付")
Order order = orderRepository.save(buildOrder(request));
// 2. 插入消息到 outbox 表(和订单同一个事务!)
MessageOutbox outbox = new MessageOutbox();
outbox.setMessageId(UUID.randomUUID().toString());
outbox.setTopic("ORDER_CREATED");
outbox.setBody(JsonUtil.toJson(order));
outbox.setStatus(0); // 待发送
messageOutboxRepository.save(outbox);
return order;
}
// 异步发送消息(可由定时任务或 MQ producer 触发)
@Scheduled(fixedDelay = 30_000)
public void sendPendingMessages() {
List<MessageOutbox> pending = messageOutboxRepository
.findByStatusAndRetryCountLessThan(0, 5); // 最多重试5次
for (MessageOutbox msg : pending) {
try {
rocketMQTemplate.syncSend(msg.getTopic(), msg.getBody());
msg.setStatus(1); // 标记成功
} catch (Exception e) {
msg.setRetryCount(msg.getRetryCount() + 1);
if (msg.getRetryCount() >= 5) {
msg.setStatus(2); // 永久失败,需人工介入
alertService.sendAlert("消息发送失败,请检查: " + msg.getMessageId());
}
}
messageOutboxRepository.save(msg);
}
}
库存服务的幂等处理
@RocketMQMessageListener(topic = "ORDER_CREATED", consumerGroup = "inventory-group")
public class OrderCreatedConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
OrderEvent event = JsonUtil.fromJson(message, OrderEvent.class);
// 幂等检查:是否已处理过此订单?
if (deduplicationService.isProcessed(event.getOrderId())) {
log.info("订单 {} 已处理,跳过", event.getOrderId());
return;
}
try {
inventoryService.decreaseStock(event.getSkuId(), event.getQuantity());
deduplicationService.markAsProcessed(event.getOrderId()); // 记录已处理
} catch (InsufficientStockException e) {
// 库存不足?回滚订单状态!
orderService.cancelOrder(event.getOrderId());
}
}
}
踩过的坑 & 生产环境血泪经验
消息 ID 必须全局唯一
我们一开始用订单 ID 当消息 ID,结果订单取消后重试,消息 ID 重复,MQ 直接丢弃。后来改用UUID+ 业务 ID 双保险。补偿任务别太频繁
刚上线时补偿任务每 5 秒扫一次,DB CPU 直接飙到 90%。调到 30 秒后稳如老狗。死信队列一定要配
有次库存服务升级,消费逻辑变更,旧消息一直失败。幸好配了 DLQ(Dead Letter Queue),没让消息无限重试拖垮系统。监控比代码更重要
我们在 Grafana 上建了三个关键指标看板:- 未发送消息堆积量
- 消息重试次数分布
- 消费失败率
大促期间靠它提前发现了一次数据库连接池耗尽的问题。
性能对比:几种方案实测数据(K8s 集群,4C8G * 3 节点)
| 方案 | TPS | 平均延迟 | 运维复杂度 | 适合场景 |
|---|---|---|---|---|
| 本地事务表 + MQ | 1200 | 45ms | ★★☆ | 中高并发,容忍秒级最终一致 |
| Seata AT 模式 | 800 | 120ms | ★★★★ | 强一致要求,中小流量 |
| TCC | 600 | 200ms+ | ★★★★★ | 金融级场景,愿意写大量补偿逻辑 |
| Saga(编排式) | 900 | 80ms | ★★★☆ | 长流程,多服务协同 |
注:测试基于 JMeter 模拟 1000 并发用户,MySQL 8.0,RocketMQ 5.0
从数据看,消息最终一致性方案在吞吐和延迟上优势明显,而且团队学习成本低——毕竟大家都会用 MQ。
面试题 & 求职建议:别只会背概念!
最近帮公司面试几个后端候选人,问“如何保证分布式事务一致性”,80% 的人张口就是“用 Seata”、“用 RocketMQ 事务消息”。但一追问细节:
- “Seata 的 undo_log 表结构了解吗?”
- “如果 MQ 发送成功但本地事务提交失败怎么办?”
- “补偿任务如何避免重复消费?”
立马哑火。
我的建议:
- 别死记方案名称,重点讲清楚你在什么场景下用了什么方案,为什么选它,遇到什么坑。
- 手绘架构图比背八股文有用 100 倍。面试官想看的是你的权衡能力。
- 如果你像我一样是转行选手,突出你的业务理解力——比如:“作为前 PM,我知道超卖对 GMV 的影响比系统延迟更致命,所以优先保一致性”。
写在最后:技术没有标准答案,只有合适与否
现在回头看那个双 11,虽然加班到凌晨是常态,但看到系统扛住 5 倍流量、0 超卖事故,还是挺爽的。成都的生活节奏慢,但技术人的战场从不平静。
分布式事务这东西,说到底不是炫技,而是在业务约束、团队能力和系统稳定性之间找平衡点。如果你也在为类似问题头疼,不妨试试“本地事务表 + 可靠消息”这套组合拳——它可能不够 fancy,但足够稳。
对了,上周那个冒菜店老板已经认识我了,每次见我都笑:“程序员又来 debug 人生了?”
我笑着点头,心想:人生哪有 bug,都是 feature。
(完)

评论 0