分布式事务解决方案:最佳实践(一个996福报打工人的真实踩坑记录)

分支开太多了
2025-12-17 18:54
阅读 647

上周五晚上十点半,我还在公司改bug,窗外上海的霓虹灯早就亮了,而我的K8s Pod还在疯狂CrashLoopBackOff。原因?一个看似简单的“下单+扣库存”操作,在微服务拆分后变成了分布式事务的经典翻车现场。

作为一个Vim党、Python重度用户、偶尔被迫写SpringBoot的云原生老油条,我本以为自己对事务处理已经驾轻就熟——直到产品那边又拍脑袋搞了个“秒杀+优惠券叠加”的新需求。那一刻我知道,我的周末又没了。


为啥这事落到我头上?

先交代下背景:我在一家中型互联网公司做后端,团队用的是典型的微服务架构——订单服务、库存服务、账户服务、优惠券服务,各跑在自己的K8s Namespace里。数据库是MySQL集群,消息队列用的是RocketMQ(别问为啥不用Kafka,问就是历史包袱)。

产品经理上周三甩过来PRD,说双11要上线“限时秒杀+满减券+积分抵扣”三合一功能。听起来很酷,但技术上就是个分布式事务地狱套餐。更离谱的是,Deadline定在下周五——也就是说,我得在三天内搞定方案、两天内开发联调、一天上线压测。

领导拍拍我肩膀:“你不是熟悉K8s和云原生吗?这种高可用场景正好练手。”
我内心OS:练手?这是拿生产环境当沙盒啊!


踩坑第一步:天真地用了本地事务

一开始我想偷懒,直接在订单服务里调用库存服务的Feign接口,两边各自用@Transactional。结果?测试一跑,库存扣了,订单没建成功,用户白嫖了商品。运维大哥半夜call我:“线上库存负数了,快看看!”

那一刻我真的想砸键盘。但转念一想,这不就是分布式事务要解决的核心问题吗?数据一致性 vs 系统可用性,CAP定理永远绕不开。


方案选型:从2PC到最终一致性

我翻了翻内部Wiki,发现团队之前试过Seata的AT模式,但因为网络抖动频繁导致锁表,最后被DBA喷退了。现在主流方案无非三种:

方案 优点 缺点 是否适合我们
2PC/XA 强一致性 性能差、阻塞严重 ❌ 高并发场景直接GG
TCC 灵活、性能好 代码侵入强、补偿逻辑复杂 ⚠️ 要写Try/Confirm/Cancel三套逻辑,时间不够
基于消息队列的最终一致性 异步解耦、吞吐高 有延迟、需幂等 ✅ 最现实的选择

结合我们已有RocketMQ基础设施,以及“秒杀场景允许短暂不一致”的业务容忍度,我果断选了可靠消息最终一致性方案。


Python + SpringBoot 混合架构下的实现细节

我们的订单服务是SpringBoot(Java),库存服务却是Python(别问,问就是早期创业时的技术债)。这就带来一个问题:如何保证跨语言服务的消息可靠性?

关键思路:本地事务表 + 消息状态机

我在订单服务的MySQL里加了一张outbox_message表:

CREATE TABLE outbox_message (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  message_id VARCHAR(64) NOT NULL UNIQUE,
  topic VARCHAR(128) NOT NULL,
  payload JSON NOT NULL,
  status ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  retry_count INT DEFAULT 0
);

核心流程如下:

  1. 用户下单 → 开启本地事务
  2. 插入订单 + 插入outbox_message(状态为pending)
  3. 提交事务
  4. 后台线程扫描pending消息,发送到RocketMQ
  5. 发送成功 → 更新状态为sent
  6. 库存服务消费消息,执行扣库存(必须幂等!)

💡 开发心得:这个“本地事务表”模式虽然土,但在混合技术栈下极其稳定。比依赖MQ事务消息(如RocketMQ的half message)更可控,尤其当你的MQ版本老旧或文档缺失时。

SpringBoot侧关键代码(订单服务)

// OrderService.java
@Transactional
public void createOrder(OrderRequest request) {
    // 1. 创建订单
    Order order = new Order();
    order.setUserId(request.getUserId());
    order.setStatus("CREATED");
    orderRepo.save(order);

    // 2. 写入本地消息表
    OutboxMessage msg = new OutboxMessage();
    msg.setMessageId(UUID.randomUUID().toString());
    msg.setTopic("inventory-deduct");
    msg.setPayload(buildInventoryPayload(order, request));
    msg.setStatus("pending");
    outboxRepo.save(msg); // 和订单在同一个事务!
}

然后有个定时任务每5秒扫一次:

@Scheduled(fixedDelay = 5000)
public void sendPendingMessages() {
    List<OutboxMessage> pending = outboxRepo.findByStatus("pending");
    for (OutboxMessage msg : pending) {
        try {
            rocketMQTemplate.syncSend(msg.getTopic(), msg.getPayload());
            msg.setStatus("sent");
            outboxRepo.save(msg);
        } catch (Exception e) {
            msg.setRetryCount(msg.getRetryCount() + 1);
            if (msg.getRetryCount() > 5) {
                msg.setStatus("failed"); // 告警!人工介入
            }
            outboxRepo.save(msg);
        }
    }
}

Python侧(库存服务)——别忘了幂等!

# inventory_service.py
@app.route('/deduct', methods=['POST'])
def deduct_inventory():
    data = request.json
    order_id = data['order_id']
    
    # 幂等检查:用Redis记录已处理的order_id
    if redis_client.exists(f"deducted:{order_id}"):
        return {"code": 200, "msg": "already processed"}
    
    # 扣库存逻辑
    if not stock_db.deduct(data['sku_id'], data['quantity']):
        return {"code": 500, "msg": "insufficient stock"}
    
    # 标记已处理
    redis_client.setex(f"deducted:{order_id}", 3600, "1")
    return {"code": 200}

🔥 血泪教训:第一次上线没做幂等,MQ重复投递导致库存多扣。测试小哥提了10个bug,我差点被拉去站会批斗。


生产环境踩过的雷

  • 消息积压:高峰期MQ消费者跟不上,我加了动态扩缩容——K8s HPA根据rocketmq_consumer_lag指标自动伸缩Pod。
  • 死信队列没监控:有次网络抖动,失败消息进了DLQ但没人告警。后来加了Prometheus规则:rocketmq_dlq_size > 0 就钉钉报警。
  • 本地事务表膨胀:忘记清理已发送的消息,三个月后这张表占了50G。现在每天凌晨跑个job删7天前的sent记录。

效果如何?

上线后双11当天,系统扛住了10倍流量,订单成功率99.98%。虽然有0.02%的订单因库存超卖被异步补偿回滚(用户收到短信:“抱歉,商品售罄,已退款”),但产品居然说“用户感知不强,OK的”。

最爽的是,我终于能在周六睡到自然醒——虽然是因为周日晚上还要值班。


最后几句真心话

分布式事务没有银弹。TCC太重,Saga太复杂,2PC太慢。对我们这种资源有限、deadline压迫的中小团队,基于本地消息表的最终一致性是最务实的选择。

它不要求所有服务用同一种语言(Python/Java/Go随便混),不依赖特定中间件高级特性,甚至能在MySQL主从延迟的破环境下苟住。虽然代码多写几行,但换来的是半夜不用被叫醒修数据。

所以,别再迷信“完美方案”了。在996的福报里,能跑起来、能抗住、能快速修复的方案,就是好方案。

对了,如果你也在用Vim写代码,记得装个vim-spring-boot插件——虽然我大部分时间还是在kubectl logs -fvim ~/.bashrc之间反复横跳。

共勉,打工人。

评论 0

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