分布式事务解决方案:最佳实践——一个异地奶爸深夜敲代码的血泪总结

半个架构师
2025-12-29 22:24
阅读 4039

上周五晚上11点47分,终于把两个娃哄睡了。

老大三岁半,老二刚满一岁。老婆在另一个城市工作,每周五晚上的高铁回来,周日晚上又得走。这周她临时加班,没赶上车,我一个人扛下了“双娃夜战”。洗澡、讲故事、喂奶、换尿布……一套流程下来,感觉比跑完半马还累。

但我知道,真正的“工作时间”才刚刚开始。

打开电脑,泡了杯速溶咖啡(别笑,家里奶粉都快堆成山了,哪还有闲钱买手冲),我继续啃那个卡了三天的分布式事务问题。项目上线在即,订单服务和库存服务跨微服务调用,事务一致性成了拦路虎。老板说:“下周必须上线”,而我连Saga模式和TCC的区别都还没彻底搞明白。

那一刻,我坐在儿童房的小板凳上(主卧被娃占领了),盯着屏幕上密密麻麻的代码,心里一阵发虚:月薪从15k涨到22k是好事,但技术债不会因为工资涨就自动消失。


为什么分布式事务让我彻夜难眠?

先说背景。我们系统用的是Spring Cloud + Dubbo + MySQL,典型的Java微服务架构。用户下单时,要同时扣减库存、生成订单、记录积分。三个服务,三个数据库,传统单体时代的@Transactional直接失效。

去年十月第一次遇到这个问题时,我天真地以为加个try-catch+手动回滚就行。结果上线第二天,库存超卖,客服电话被打爆。老板脸色铁青,我躲在工位上改bug到凌晨三点——那天正好是老婆产检的日子,我在视频里看到她一个人站在医院走廊,手里拿着B超单。

技术可以重来,但家人的信任一旦崩了,重建很难。

从那以后,我下定决心:必须系统性掌握分布式事务。不是为了KPI,而是为了夜里能安心睡觉,而不是被报警短信惊醒。


工具选型:踩坑后的清醒认知

市面上主流方案无非几种:2PC、TCC、Saga、本地消息表、Seata、RocketMQ事务消息……听起来高大上,但落地时全是细节魔鬼。

1. 别迷信“全自动”——Seata的甜蜜陷阱

我一开始狂推Seata,AT模式看起来太香了:无侵入、自动回滚、一行注解搞定。团队里几个新人也跟着喊“牛逼”。

但现实狠狠打了脸。

  • 性能瓶颈:全局锁导致高并发下吞度量暴跌,压测时TPS从800掉到120。
  • 脏读风险:AT模式依赖undo_log,但在极端网络分区下,可能读到中间状态。
  • 运维复杂:TC(Transaction Coordinator)要单独部署,还要考虑高可用。有次TC挂了,整个下单链路瘫痪两小时。

最后我们只在非核心场景用了Seata。工具再强,也得看业务容忍度。

2. TCC:看似优雅,实则“自虐”

TCC(Try-Confirm-Cancel)要求每个服务实现三个接口。理论上很完美,但开发成本爆炸。

举个例子:扣库存的try阶段要预占库存,confirm真正扣减,cancel释放。听起来合理,但:

  • 业务逻辑被强行拆成三份,代码可读性暴跌
  • 网络超时、重复调用、幂等性……每个环节都要额外处理
  • 测试用例翻倍,QA同事差点跟我翻脸

我们试了一个月,最终放弃。除非你是金融级系统,否则TCC的ROI(投入产出比)太低。

3. 本地消息表 + 异步补偿:打工人の务实之选

现在我们主力方案是:本地消息表 + RocketMQ事务消息 + 定时对账补偿

具体做法:

  1. 下单时,在同一个DB事务中插入订单 + 插入“待发送消息”记录
  2. 后台线程扫描消息表,发送MQ
  3. 消费端处理成功后,更新消息状态
  4. 定时任务每天跑一次对账,修复不一致数据

优点很明显:

  • 强依赖少:只用MySQL + MQ,都是现有技术栈
  • 最终一致性:业务能接受几秒延迟(用户也不在乎)
  • 可追溯:消息表就是天然日志,排查问题方便

缺点?当然有——要写补偿逻辑,要处理消息重复,要防死信队列堆积。但比起半夜被叫起来救火,这点工作量算什么?

成年人的世界,没有完美方案,只有权衡后的最优解。


Java 实践:代码里的血与泪

贴一段我们现在的核心伪代码(敏感信息已脱敏):

@Transactional
public void createOrder(OrderDTO dto) {
    // 1. 本地事务:创建订单 + 插入消息
    Order order = orderMapper.insert(dto);
    Message message = new Message();
    message.setBizId(order.getId());
    message.setTopic("ORDER_CREATED");
    message.setStatus(MessageStatus.PENDING);
    messageMapper.insert(message); // 同一个DB事务!

    // 2. 异步发送MQ(由独立线程池处理)
    mqSender.asyncSend(message);
}

消费端:

@RocketMQMessageListener(topic = "ORDER_CREATED", consumerGroup = "inventory-group")
public class InventoryConsumer implements RocketMQListener<MessageExt> {
    
    @Override
    public void onMessage(MessageExt msg) {
        try {
            // 幂等处理:先查是否已处理
            if (messageService.isProcessed(msg.getMsgId())) {
                return;
            }
            
            // 扣库存
            inventoryService.decreaseStock(...);
            
            // 标记消息已消费
            messageService.markAsProcessed(msg.getMsgId());
            
        } catch (Exception e) {
            // 记录失败,等待补偿任务重试
            messageService.markAsFailed(msg.getMsgId(), e.getMessage());
            throw new RuntimeException("消费失败,触发重试", e);
        }
    }
}

关键点:

  • 本地事务保证“订单+消息”原子性
  • 消费端必须幂等(MQ至少一次投递)
  • 失败必须可追踪,不能静默丢弃

这套方案上线三个月,0数据不一致。虽然不够“高大上”,但稳如老狗。


给 fellow 程序员的真心话

写到这里,已经是凌晨2点15分。老二突然哭醒,我赶紧去冲奶粉。看着他小口小口喝着,心里却在想:技术人最大的误区,就是追求“银弹”。

分布式事务没有银弹。Seata不是,TCC不是,甚至区块链也不是。真正的最佳实践,是理解业务边界,选择能驾驭的方案,然后用工程手段兜底。

如果你也在异地带娃、深夜coding,我想说:

  • 别怕承认“我不会”——我曾因不懂Saga被同事质疑能力,后来花两周啃完《Designing Data-Intensive Applications》
  • 工具只是杠杆,核心是思维——理解CAP、BASE、幂等性,比背框架源码更重要
  • 保持输出倒逼输入——这篇文章除了帮别人,更是帮我理清思路

写在最后:为了更好的明天

老婆昨天视频问我:“你最近是不是又瘦了?”
我说:“没事,就是晚上多学点东西。”
她沉默了几秒,说:“别太拼,我和孩子需要你活着,不是透支。”

那一刻,我鼻子一酸。

技术人的价值,不该用加班时长衡量。能准时下班陪孩子,能周末带老婆逛超市,能在系统稳定时安心睡觉——这才是真正的“高可用人生”。

分布式事务的终点,从来不是代码完美,而是生活平衡。

共勉。

评论 0

最热最新
暂无评论
半个架构师Lv.1
0
影响力
0
文章
0
粉丝