分布式事务解决方案:最佳实践

需求文档失踪
2025-12-21 18:50
阅读 1378

——一个光谷程序员相亲脱单后的深夜复盘

去年十月,我还在光谷软件园B2栋加班到凌晨一点。那天是周五,外面下着小雨,写字楼里只剩我和隔壁组的小李还在死磕一个“订单支付后库存没扣”的线上 bug。老婆(当时还是女朋友)发来微信:“又加班?说好今天陪我看《沙丘2》的……” 我回了个“对不起,系统崩了”,然后默默把泡面桶踢到桌底。

那会儿我刚跳槽到这家做 SaaS 服务的创业公司,月薪从15k涨到22k,房租3500,压力却翻了三倍。技术栈从 Java 转向 Go,团队要重构整个交易系统,而最让我头大的,就是分布式事务


一、事故现场:一场“看似成功”的支付

事情起因很简单:用户下单 → 支付成功 → 扣减库存 → 发优惠券。四个微服务,跨三个数据库(订单库、商品库、营销库)。我们最初用的是“先写订单,再调库存,最后发券”的串行调用,中间加了些重试和日志。

但上周五晚上八点,运营同事突然冲进办公室:“用户投诉!付了钱,库存没减,券也没发!”

我查日志发现:支付回调成功写入订单状态为“已支付”,但调用库存服务时网络超时(其实是对方 Redis 雪崩),后续流程直接中断。结果用户钱付了,货没锁住,券也没到账——典型的数据不一致

更糟的是,运营那边已经开始手动补券、人工锁库存,每单处理成本高达50元。老板在群里@我:“这问题再不解决,下个月KPI全扣。”

那一刻,我真的焦虑到想删库跑路。


二、技术选型:在理想与现实之间反复横跳

作为主力后端,我被拉进紧急会议。CTO 是个前阿里P8,开口就问:“你们考虑过分布式事务方案吗?”

我支支吾吾:“之前用过 Spring 的 @Transactional,但那是本地事务……跨服务的话,TCC?Saga?还是消息队列?”

他点点头:“Go 和 Java 混合架构下,选型更要谨慎。不能为了技术炫技,把系统搞成‘分布式地狱’。”

我们梳理了几个选项:

  1. 2PC(两阶段提交):理论上强一致,但性能差,且 Go 生态缺乏成熟实现。放弃。
  2. TCC(Try-Confirm-Cancel):需要每个服务提供三个接口,改造成本高。我们库存服务连幂等都没做好,根本扛不住。
  3. 基于消息队列的最终一致性:用 RocketMQ 或 Kafka 做事务消息,异步解耦。听起来靠谱,但得保证消息不丢、消费幂等。
  4. Saga 模式:长事务拆成多个本地事务+补偿逻辑。适合我们的业务链路,但补偿逻辑写起来像写“后悔药”,容易漏场景。

最终,我们决定采用 “本地消息表 + 幂等消费” 的折中方案——既不用强依赖 MQ 事务消息(公司用的还是 Kafka,不支持事务),又能控制改造范围。


三、落地实践:Go 写生产者,Java 写消费者

具体怎么做?

步骤1:在订单服务(Go)中,开启本地事务

// 伪代码
tx := db.Begin()
// 1. 插入订单
tx.Exec("INSERT INTO orders ...")
// 2. 插入本地消息表(待发送)
tx.Exec("INSERT INTO outbox (msg_id, topic, payload, status) VALUES (...)")
tx.Commit() // 本地事务保证订单和消息原子性

步骤2:后台轮询 outbox 表,发消息到 Kafka

用一个独立的 Go 协程定期扫表,把 status=pending 的消息发出去,成功后更新为 sent

步骤3:库存服务(Java)消费消息,执行扣库存

@KafkaListener(topics = "order-paid")
public void handleOrderPaid(String msg) {
    if (idempotentService.isProcessed(msgId)) return; // 幂等检查
    
    try {
        inventoryService.decrease(productId, quantity);
        couponService.issue(userId, couponId);
        idempotentService.markAsProcessed(msgId);
    } catch (Exception e) {
        // 失败可重试,最多3次
        throw new RuntimeException("retryable");
    }
}

关键点来了:所有消费者必须幂等。我们在 MySQL 里建了一张 processed_messages 表,用 msg_id 做唯一索引,插入即视为已处理。

上线前,我和 Java 组的老王对了三天接口,光是“消息格式要不要带 timestamp”就吵了两轮。他说:“你 Go 的 time.RFC3339 和我们 Java 的 Instant 格式不一样!” 我回:“那你转成 Unix 毫秒不就完了?” —— 程序员的浪漫,大概就是互相甩锅时还能保持微笑。


四、安全意识:别让“最终一致”变成“永远不一致”

很多人以为用了消息队列就万事大吉,但安全意识才是兜底的关键。

我们做了三件事:

  1. 监控告警:outbox 表积压超过100条就钉钉报警;消息消费失败超过3次进死信队列,人工介入。
  2. 对账机制:每天凌晨跑脚本,比对“已支付订单”和“已扣库存”数量,差异超过0.1%就触发核查。
  3. 运营兜底:给运营同学开了个内部管理后台,可以手动“重发消息”或“强制补偿”。虽然他们总点错,但至少不用半夜 call 我。

有次凌晨三点,Kafka 集群故障,消息积压两小时。我被电话吵醒,老婆迷迷糊糊问:“又崩了?” 我说:“嗯,不过这次有对账,不怕。” 她翻个身:“那你快去修,修完回来睡觉。” —— 相亲十几次才遇到一个不嫌我加班的,值了。


五、反思:技术没有银弹,只有权衡

现在回头看,分布式事务的本质不是技术问题,而是业务容忍度问题。

  • 如果你是银行转账,必须强一致,那就上 Seata + AT 模式,哪怕性能差。
  • 但如果是电商下单,用户能接受几秒延迟,那最终一致性足够,还省资源。

我们选择本地消息表,是因为:

  • 团队 Go/Java 混合,不想引入新中间件(如 Seata)
  • 业务允许秒级延迟
  • 运营能接受少量人工干预

而且,别迷信“全自动”。再完美的系统也会出问题,留一手人工通道,是对业务最基本的尊重。


六、写在最后:从代码到生活,都需要“事务思维”

有趣的是,解决分布式事务的过程,让我对生活也有了新理解。

以前相亲,总想着“一次到位”——找个完美对象,立刻结婚,一步到位。结果见了八个,不是聊不来,就是对方嫌我秃。后来明白:感情也是“最终一致性”。你付出关心,对方回馈信任,中间可能有延迟、有误会,甚至需要“补偿操作”(比如送花道歉),但只要方向对,终会达成一致。

现在我和老婆住在关山大道,周末她打游戏我写博客,偶尔一起吐槽公司需求。她说:“你最近不焦虑了?” 我说:“因为我知道,有些事,急不得。”

技术如此,人生亦如此。

分布式事务没有标准答案,但清晰的边界、可靠的幂等、及时的监控、以及必要的人工兜底,这四点,无论 Go 还是 Java,无论写代码还是过日子,都值得坚守。

共勉。

作者:武汉·光谷软件园某 SaaS 公司后端工程师,Go & Java 双修,已脱单。
当前状态:正在用 Saga 模式重构会员积分系统,顺便准备婚礼。

评论 0

最热最新
暂无评论
需求文档失踪Lv.1
0
影响力
0
文章
0
粉丝