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

事务别乱提交
2025-12-16 10:54
阅读 721

上周五晚上 10 点半,我瘫在深圳南山科技园的工位上,盯着屏幕上疯狂刷屏的 Deadlock found when trying to get lock 报错,心里一万头草泥马奔腾而过。这已经是我本周第三次被线上告警叫醒了。说好的早睡早起、健康生活呢?果然,程序员的 flag 都是用来打脸的。

我是京东某核心交易系统的后端开发,五年老油条了,经历过好几届 618 和双 11 的“洗礼”。我们系统每天处理数千万级订单,涉及库存、优惠券、积分、物流等多个微服务。去年双 11 前夕,业务方突然要求上线一个“跨服务组合优惠”功能——买 A 商品送 B 积分,同时扣减 C 优惠券。听起来人畜无害,但背后是三个独立数据库的写操作。产品经理轻飘飘一句“保证一致性就行”,差点让我当场表演一个原地去世。

问题来了:分布式事务到底怎么搞?

单体时代,一个 BEGIN ... COMMIT 就搞定的事,现在拆成七八个服务,每个服务还可能用不同数据库(MySQL、Mongo、TiDB 乱炖),传统的本地事务直接歇菜。这时候你就得面对分布式事务这个“大魔王”。

市面上常见的方案有这么几种:

  • 2PC/3PC:理论很美,性能很烂,锁持有时间长,在高并发场景下简直就是自虐。
  • TCC(Try-Confirm-Cancel):灵活,但代码侵入性强,每个业务都要手写 Try/Confirm/Cancel 三套逻辑,维护成本爆炸。
  • Saga 模式:通过补偿事务回滚,适合长流程,但不保证隔离性,中间状态可能被其他事务看到,容易产生脏读。
  • 基于消息队列的最终一致性:异步解耦,性能好,但实现复杂,要处理消息重复、丢失等问题。
  • Seata / DTM 等开源框架:封装了上述模式,开箱即用,但学习曲线陡峭,且对 Go 生态的支持参差不齐。

我们团队评估了一圈,最终决定采用 基于可靠消息的最终一致性 + 补偿机制,并用 Go 实现了一套轻量级的事务协调器。为什么选它?简单:性能扛得住、代码改动小、运维能睡着觉

踩坑实录:别信文档,信日志!

一开始我们天真地以为用 Kafka 发个消息就完事了。结果上线第一天,测试同学就发现:用户支付成功了,但积分没到账。查日志发现,消息压根没发出去——因为发消息和本地事务不是原子的!

典型的“先写 DB 再发消息”陷阱。如果 DB 写成功了,但发消息时网络抖动失败,数据就丢了。反过来,“先发消息再写 DB”更危险:消息发了,DB 写失败,下游服务白忙活。

解决办法?本地消息表(Local Message Table)。思路很简单:

  1. 在业务 DB 中建一张 outbox
  2. 业务操作和插入 outbox 记录放在同一个本地事务里
  3. 后台有个轮询任务,不断扫描 outbox,把消息发到 MQ
  4. 发送成功后标记为已发送,避免重复

这样,只要本地事务成功,消息就一定不会丢。虽然多了张表,但换来的是强可靠性

// 示例:下单时同时插入订单和消息记录
func CreateOrder(ctx context.Context, order Order) error {
    return db.WithTx(ctx, func(tx *sql.Tx) error {
        // 1. 创建订单
        if err := tx.ExecContext(ctx, "INSERT INTO orders ...", ...); err != nil {
            return err
        }
        // 2. 插入本地消息表
        msg := OutboxMessage{
            ID:          uuid.New(),
            Topic:       "order.created",
            Payload:     json.Marshal(order),
            Status:      "pending",
            CreatedAt:   time.Now(),
        }
        _, err := tx.ExecContext(ctx, 
            "INSERT INTO outbox (id, topic, payload, status, created_at) VALUES (?, ?, ?, ?, ?)",
            msg.ID, msg.Topic, msg.Payload, msg.Status, msg.CreatedAt)
        return err
    })
}

后台轮询服务用 Go 写,goroutine + channel 轻松搞定。我们每秒扫 500 条,延迟控制在 200ms 内,双 11 期间稳如老狗。

// 消息投递 Worker
func (s *Sender) Start() {
    ticker := time.NewTicker(100 * time.Millisecond)
    for range ticker.C {
        msgs, _ := s.repo.FetchPendingMessages(500)
        for _, msg := range msgs {
            go s.deliver(msg) // 并发投递
        }
    }
}

func (s *Sender) deliver(msg OutboxMessage) {
    if err := s.mq.Publish(msg.Topic, msg.Payload); err != nil {
        // 重试 or 告警
        log.Errorf("Failed to send message %s: %v", msg.ID, err)
        return
    }
    s.repo.MarkAsSent(msg.ID) // 更新状态
}

吐槽时间:运维同学看到我们又加了个轮询服务,差点拿咖啡泼我。但我说:“哥,这比半夜被 PagerDuty 叫醒强吧?” 他默默点了点头。

补偿机制:Plan B 不是摆设

最终一致性最大的问题是:中间状态不可见。比如用户支付后,积分还没到账,他刷新页面看到“积分未增加”,立马投诉客服。

所以我们加了一层 补偿 Job:定时扫描“异常状态”的订单(比如支付成功但积分未到账),主动触发补偿逻辑。

这里用到了一个简单的 状态机算法

订单状态: [CREATED] -> [PAID] -> [POINTS_ADDED] -> [COUPON_DEDUCTED]

如果发现某个订单卡在 PAID 超过 5 分钟,就调用积分服务的 AddPointsCompensate(orderID) 接口。补偿接口必须是 幂等 的!我们约定所有补偿接口都带 request_id,服务端用 Redis 记录已处理的 ID。

func AddPointsCompensate(ctx context.Context, req CompensateRequest) error {
    if s.redis.SetNX(ctx, "compensate:"+req.RequestID, "1", 24*time.Hour).Val() == false {
        return nil // 已处理,直接返回
    }
    return s.pointsService.Add(req.UserID, req.Points)
}

别小看这个 SetNX,去年双 11 就靠它挡住了因重试导致的积分重复发放事故。当时测试同学跑来问:“你们是不是给用户发了 10 倍积分?” 我看了一眼监控,淡定回他:“没有,幂等保命。”

性能与可维护性:Go 的优势在哪?

我们全栈 Go(除了 DB),选择 Go 实现事务协调器有几个原因:

  1. 并发模型天然契合:goroutine 轻量,处理高并发消息投递毫无压力。
  2. 编译型语言,部署简单:一个二进制文件扔到容器里就跑,运维同学狂喜。
  3. 清晰的错误处理:虽然 if err != nil 写到手抽筋,但至少不会像 Java 那样堆栈溢出看半天。

特别提一下 代码可读性。我们团队有个不成文规定:任何超过 50 行的函数必须拆。所以你看上面的 CreateOrder,逻辑清晰,注释到位,新来的实习生都能看懂。反观隔壁组用某 JVM 语言写的 TCC,Confirm 逻辑嵌套七层,作者自己都看不懂了,每次改需求都像拆炸弹。

效果如何?数据说话

上线三个月,经历了 618 大促,效果如下:

指标 优化前 优化后
事务成功率 98.2% 99.99%
平均延迟 1.2s 320ms
运维告警次数/周 15+ <2

最爽的是,今年双 11 当天,我居然能在晚上 9 点准时下班!虽然回家路上还在刷手机看监控,但至少不用守在公司等凌晨的流量洪峰了。

一点心得:别追求完美,追求可用

很多新人一上来就想搞 Seata、搞 XA,觉得“学术正确”就是最好的。但在真实的互联网业务中,简单、稳定、可运维才是王道。我们这套方案虽然“土”,但扛住了真实流量,代码也容易理解。领导问起来,我也能三句话讲清楚原理。

另外,分布式事务不是银弹。能用业务设计规避的,尽量规避。比如把强一致性需求转化为“最终一致 + 用户提示”,用户体验反而更好。毕竟,用户真的在乎那 200ms 的延迟吗?他们只在乎“钱没丢、东西能到”。

最后,分享一句我们架构师的名言:“所有分布式问题,都是因为你想得太复杂。

好了,今天就水到这里。我去泡杯枸杞茶,准备迎接明天早 8 点的站会——产品经理说又要加个“跨店满减叠加积分翻倍”功能……救命!

(完)


P.S. 如果你也在深圳搞 Go 后端,欢迎约咖啡聊聊分布式那些事儿。不过别找我要代码,我们 GitLab 权限管得比腾讯还严 😅

评论 0

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