分布式事务这玩意儿,不搞懂真的睡不着觉(尤其当你有两个娃还要改 Bug 的时候)

半杯咖啡写代码
2025-12-14 21:48
阅读 714

上周五晚上十一点半,我刚把两个小祖宗哄睡——一个三岁会翻墙,一个一岁会拆家。正打算打开 IntelliJ,突然手机「叮」一声:项目告警!订单创建成功但库存没扣!

操,又是分布式事务的问题。

我揉了揉眼睛,心里默默问候了一遍产品经理(不是真骂哈,我们 PM 其实人不错,就是需求写得像谜语)。这事说来话长:我们公司做的是一个电商 SaaS 平台,给中小商家提供“运营”工具,比如库存管理、订单同步、营销活动等。系统早几年是单体架构,Java 写的,跑得好好的。后来业务一火,用户量猛增,老板大手一挥:“微服务!上云原生!K8s 走起!”

于是,订单服务、库存服务、优惠券服务全拆开了,各跑各的 Pod,数据库也分库分表。结果?事务一致性直接崩了。


为什么分布式事务这么要命?

在单体应用里,一个 @Transactional 注解就搞定的事,在微服务世界里能让你掉光头发。比如下面这个经典场景:

  1. 用户下单 → 调用 订单服务 创建订单
  2. 同步调用 库存服务 扣减库存
  3. 如果库存不足,整个流程要回滚

听起来简单吧?但现实是:

  • 库存服务可能网络超时(别问,问就是北京晚高峰通勤时连 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

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