分布式事务解决方案:从翻车现场到稳如老狗的最佳实践

编译通过了吗
2025-12-18 11:33
阅读 567

上周五晚上 10 点,我坐在成都春熙路附近一家还没打烊的咖啡馆里,盯着监控面板上那条不断告警的曲线,一边啃着冷掉的冒菜,一边在心里默默问候某个产品经理祖宗十八代——对,就是我自己。

没错,我是个“前·产品经理”,现在在一家中型电商公司做后端开发。当初转岗的理由也很简单:天天画原型、写 PRD、被研发怼需求不闭环,最后终于忍无可忍,自己撸起袖子下场写代码。结果?产品没做好,代码倒是越写越秃了。

但说真的,从 PM 转技术最大的好处是:我比大多数程序员更懂“业务为什么非得这么搞”。比如今天要聊的分布式事务问题,就源于去年双 11 前的一个“史诗级需求”。


起因:一个看似人畜无害的“库存+订单”联动需求

事情是这样的:我们有个新业务线要做“秒杀 + 预售”混合模式。用户下单时,系统要同时:

  1. 扣减商品库存(调用库存服务)
  2. 创建订单(调用订单服务)

乍一看,两个接口串起来就行。但现实狠狠扇了我一巴掌——网络不可靠、服务会超时、数据库可能挂。上线第一天压测,10% 的请求出现了“扣了库存但没生成订单”的情况,直接导致超卖。运维小哥当场裂开,测试妹子追着我问:“你不是产品经理出身吗?怎么连数据一致性都不考虑?”

那一刻,我深刻理解了什么叫“自己挖的坑,含泪也要填完”。


分布式事务?别被名字吓住,本质就是“要么全成功,要么全回滚”

先说结论:没有银弹,只有权衡。分布式事务的核心矛盾在于:CAP 定理告诉我们,强一致性(C)和高可用(A)不能兼得。而业务又要求“不能丢单、不能超卖”,那怎么办?

我翻遍了 Seata、TCC、Saga、消息队列最终一致性……甚至把 RocketMQ 的事务消息源码都拉下来 debug 过(感谢 Apache 开源精神)。最后结合我们团队的技术栈(Spring Cloud + MySQL + K8s 部署),选了一条性价比最高、运维成本最低的路:基于可靠消息的最终一致性方案

💡 为什么没选 Seata AT 模式?
因为我们库存表加了复杂的行锁逻辑,Seata 的全局锁容易引发死锁;而且团队没人敢在大促前上新中间件,怕背锅。


实战:用 RocketMQ + 本地事务表搞定一致性

架构设计

核心思路就一句话:先发消息,再执行本地操作,失败就重试。但细节决定成败。

我们做了三层保障:

  1. 本地事务表:下单前,先把“待发送的消息”写入 message_outbox 表,和订单创建在一个 DB 事务里。
  2. 定时补偿任务:后台 Job 每 30 秒扫一次未发送成功的消息,重新投递。
  3. 消费者幂等:库存服务收到消息后,先查是否已处理过该订单 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());
        }
    }
}

踩过的坑 & 生产环境血泪经验

  1. 消息 ID 必须全局唯一
    我们一开始用订单 ID 当消息 ID,结果订单取消后重试,消息 ID 重复,MQ 直接丢弃。后来改用 UUID + 业务 ID 双保险。

  2. 补偿任务别太频繁
    刚上线时补偿任务每 5 秒扫一次,DB CPU 直接飙到 90%。调到 30 秒后稳如老狗。

  3. 死信队列一定要配
    有次库存服务升级,消费逻辑变更,旧消息一直失败。幸好配了 DLQ(Dead Letter Queue),没让消息无限重试拖垮系统。

  4. 监控比代码更重要
    我们在 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 发送成功但本地事务提交失败怎么办?”
  • “补偿任务如何避免重复消费?”

立马哑火。

我的建议

  1. 别死记方案名称,重点讲清楚你在什么场景下用了什么方案,为什么选它,遇到什么坑
  2. 手绘架构图比背八股文有用 100 倍。面试官想看的是你的权衡能力
  3. 如果你像我一样是转行选手,突出你的业务理解力——比如:“作为前 PM,我知道超卖对 GMV 的影响比系统延迟更致命,所以优先保一致性”。

写在最后:技术没有标准答案,只有合适与否

现在回头看那个双 11,虽然加班到凌晨是常态,但看到系统扛住 5 倍流量、0 超卖事故,还是挺爽的。成都的生活节奏慢,但技术人的战场从不平静。

分布式事务这东西,说到底不是炫技,而是在业务约束、团队能力和系统稳定性之间找平衡点。如果你也在为类似问题头疼,不妨试试“本地事务表 + 可靠消息”这套组合拳——它可能不够 fancy,但足够稳。

对了,上周那个冒菜店老板已经认识我了,每次见我都笑:“程序员又来 debug 人生了?”

我笑着点头,心想:人生哪有 bug,都是 feature

(完)

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝