分布式事务不是你想做就能做

Grid排版师
2026-01-03 02:48
阅读 1440

上周五晚上十一点,我合上 MacBook,把泡面桶扔进已经堆成小山的垃圾桶里,心里默默发誓:再也不接这种“运营临时加需求、前端甩锅后端、老板要明天上线”的项目了。但转头一看钉钉消息——产品经理又在群里@我:“这个分布式事务能不能今晚搞定?明天运营要搞大促。”

我叹了口气。四年外包生涯,从杭州滨江到未来科技城,给阿里生态打过杂,也帮网易系的小团队救过火。见过太多人把“分布式”当银弹,以为加个事务注解就万事大吉。结果呢?不是数据对不上,就是系统雪崩。

今天这篇,不讲理论八股,就说说我在实战中踩过的坑、趟过的雷,以及怎么用 Go 搞定一套靠谱的分布式事务方案,顺便让前端和运营少来烦我。


一场由“优惠券发放”引发的血案

事情是这样的:某电商客户要做一个“分享得券”活动。用户 A 分享链接,B 点击后两人各得一张券。听起来简单?但背后涉及三个服务:

  1. 用户服务(记录邀请关系)
  2. 营销服务(生成并发放优惠券)
  3. 账务服务(记录券的财务流水)

问题来了:如果 B 点击后,用户服务写成功了,但营销服务挂了,那 A 白忙活,运营那边投诉电话直接打爆客服。更惨的是,如果营销服务发了券,但账务没记,财务对不上账——这可是能上审计报告的大事。

产品经理拍脑袋说:“你们后端加个事务不就完了?” 我差点一口老血喷在 VSCode 上。你倒是说得轻巧,跨服务、跨数据库,哪来的本地事务?

这时候,分布式事务方案就得上场了。但选哪个?2PC?TCC?Saga?还是干脆用消息队列兜底?


别被理论忽悠了,先看业务容忍度

很多人一上来就谈 XA 协议、Seata、DTF,但真正的架构决策,往往取决于业务能容忍什么

  • 强一致性要求高(比如支付、账务)→ 考虑 TCC 或 2PC
  • 最终一致性可接受(比如发券、积分)→ Saga + 补偿 or 消息队列
  • 性能压倒一切(比如秒杀)→ 异步 + 对账兜底

我们这个场景,运营说:“只要最后两个人都拿到券就行,中间慢点没关系。” 好,那我们就走 最终一致性 路线。

但注意!“最终一致”不等于“随便搞”。你得保证:

  • 不会重复发券(幂等)
  • 失败能重试(可靠)
  • 出问题能追溯(可观测)

于是,我决定用 基于可靠消息的最终一致性方案,核心组件:Kafka + 本地事务表 + 幂等消费。


为什么选 Go?因为快、稳、不矫情

虽然公司主力是 Java,但这个新模块我坚持用 Go 写。原因很简单:

  • 启动快,资源省(外包项目服务器预算抠得要死)
  • 并发模型天然适合处理异步任务
  • 静态编译,部署就是扔个二进制文件,运维不骂我

而且,Go 的 database/sql 包配合 pgx 驱动,写本地事务表简直丝滑。

核心思路:两阶段提交的“穷人版”

  1. 第一阶段:在本地数据库开启事务,同时写业务数据 + 写消息到“待发送消息表”
  2. 提交本地事务
  3. 异步轮询消息表,把消息发到 Kafka
  4. 消费者收到消息,执行下游操作(发券),成功则标记消息为已消费

关键点:消息的生产和业务操作在一个本地事务里,保证原子性。

// 伪代码:分享成功后发券
func HandleShare(ctx context.Context, userID, invitedID string) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 1. 记录邀请关系
    _, err = tx.ExecContext(ctx, 
        "INSERT INTO invites (user_id, invited_id) VALUES ($1, $2)", 
        userID, invitedID)
    if err != nil {
        return err
    }

    // 2. 插入两条待发券消息(A 和 B 各一条)
    msgID1 := uuid.New().String()
    msgID2 := uuid.New().String()
    _, err = tx.ExecContext(ctx, `
        INSERT INTO outbox (msg_id, topic, payload, status) 
        VALUES ($1, 'coupon.issue', $2, 'pending'),
               ($3, 'coupon.issue', $4, 'pending')`,
        msgID1, buildCouponPayload(userID),
        msgID2, buildCouponPayload(invitedID))
    if err != nil {
        return err
    }

    return tx.Commit() // 业务+消息,一起提交!
}

注:outbox 表就是传说中的“事务消息表”,名字取自 Martin Fowler 的 Transactional Outbox 模式


前端别再乱重试了!

说到幂等,不得不吐槽前端同学。有一次测试环境,前端调用分享接口,超时就自动重试三次。结果我这边没做幂等,三个人收到了九张券……运营差点提刀来砍我。

所以,所有可能被重试的接口,必须幂等

我们的做法:

  • 每次请求带唯一 request_id
  • 服务端用 Redis 记录 request_id -> result
  • 重复请求直接返回缓存结果
func IssueCoupon(ctx context.Context, req IssueRequest) (*IssueResponse, error) {
    // 检查是否已处理
    key := "coupon:issued:" + req.RequestID
    if exists, _ := redis.Get(ctx, key).Result(); exists != "" {
        return &IssueResponse{CouponID: exists}, nil
    }

    // 发券逻辑...
    couponID, err := createCoupon(req.UserID)
    if err != nil {
        return nil, err
    }

    // 缓存结果,TTL 24h
    redis.Set(ctx, key, couponID, 24*time.Hour)
    return &IssueResponse{CouponID: couponID}, nil
}

前端现在知道:后端不是无限续杯的奶茶店,乱重试是要出人命的。


运维视角:如何不让半夜被叫醒?

分布式系统最怕什么?不是 bug,是静默失败——消息丢了没人知道,券没发用户没投诉,月底对账才发现差了十万张。

所以我们加了三道保险:

  1. 消息表监控:每5分钟检查 outbox 表里超过10分钟还没发送的消息,告警
  2. 消费者延迟监控:Kafka Lag 超过阈值就钉钉报警
  3. 每日对账脚本:凌晨跑批,比对“应发券数” vs “实发券数”,差异邮件通知
# 示例:检查积压消息
SELECT COUNT(*) FROM outbox 
WHERE status = 'pending' 
  AND created_at < NOW() - INTERVAL '10 minutes';

另外,日志必须带 trace_id!从 Nginx 到 Go 服务再到 Kafka,全链路透传。出了问题,5 分钟定位到具体环节,而不是在群里互相甩锅。


方案对比:没有银弹,只有权衡

方案 一致性 性能 复杂度 适用场景
2PC (XA) 强一致 支付、转账
TCC 强一致 极高 金融核心
Saga 最终一致 订单、营销
消息表 最终一致 通用场景

我们选消息表,就是因为复杂度低、运维友好、Go 实现简单。虽然理论上可能有极短时间不一致,但业务能接受。


血泪教训:这些坑千万别踩

  1. 别信“自动补偿”
    有些框架号称自动回滚,但网络超时、服务宕机时,补偿逻辑可能根本没触发。手动写补偿函数,比依赖框架靠谱。

  2. Kafka 不是万能的
    曾经以为 Kafka 保证不丢消息,结果发现生产者没开 acks=all,broker 挂了一台,消息真丢了。配置必须严格:

    acks: all
    retries: 3
    enable.idempotence: true
    
  3. 别让运营直接改数据库
    有次运营为了“紧急修复”,直接 UPDATE 了券状态,结果破坏了幂等逻辑,导致重复发券。后来我们加了 DB 只读账号给运营,写操作必须走 API。

  4. 测试要模拟网络分区
    tc 命令模拟丢包、延迟,看看系统会不会脑裂。别等双11才暴露问题。


结语:外包狗的生存哲学

干了四年外包,我早就不信“完美架构”了。客户需求变比翻书快,老板要的是“能跑就行”,测试只关心“点按钮有没有报错”。

但作为有点追求的程序员,我还是想在泥潭里种朵花。分布式事务不是炫技,而是用最小成本守住数据底线

这套基于消息表的方案,上线三个月,发了上千万张券,0 数据不一致。运营终于不来半夜打电话了,前端也不乱重试了,连产品经理都说:“你们后端最近挺稳啊。”

其实哪有什么稳,不过是把坑都提前踩了一遍罢了。

如果你也在杭州,正在被分布式事务折磨,欢迎来约杯咖啡。我知道西湖边有家店,他们的拿铁不加糖——就像我的代码,拒绝甜腻,只求可靠。

(完)

评论 0

最热最新
暂无评论
Grid排版师Lv.1
0
影响力
0
文章
0
粉丝