分布式事务不是你想做就能做
上周五晚上十一点,我合上 MacBook,把泡面桶扔进已经堆成小山的垃圾桶里,心里默默发誓:再也不接这种“运营临时加需求、前端甩锅后端、老板要明天上线”的项目了。但转头一看钉钉消息——产品经理又在群里@我:“这个分布式事务能不能今晚搞定?明天运营要搞大促。”
我叹了口气。四年外包生涯,从杭州滨江到未来科技城,给阿里生态打过杂,也帮网易系的小团队救过火。见过太多人把“分布式”当银弹,以为加个事务注解就万事大吉。结果呢?不是数据对不上,就是系统雪崩。
今天这篇,不讲理论八股,就说说我在实战中踩过的坑、趟过的雷,以及怎么用 Go 搞定一套靠谱的分布式事务方案,顺便让前端和运营少来烦我。
一场由“优惠券发放”引发的血案
事情是这样的:某电商客户要做一个“分享得券”活动。用户 A 分享链接,B 点击后两人各得一张券。听起来简单?但背后涉及三个服务:
- 用户服务(记录邀请关系)
- 营销服务(生成并发放优惠券)
- 账务服务(记录券的财务流水)
问题来了:如果 B 点击后,用户服务写成功了,但营销服务挂了,那 A 白忙活,运营那边投诉电话直接打爆客服。更惨的是,如果营销服务发了券,但账务没记,财务对不上账——这可是能上审计报告的大事。
产品经理拍脑袋说:“你们后端加个事务不就完了?” 我差点一口老血喷在 VSCode 上。你倒是说得轻巧,跨服务、跨数据库,哪来的本地事务?
这时候,分布式事务方案就得上场了。但选哪个?2PC?TCC?Saga?还是干脆用消息队列兜底?
别被理论忽悠了,先看业务容忍度
很多人一上来就谈 XA 协议、Seata、DTF,但真正的架构决策,往往取决于业务能容忍什么。
- 强一致性要求高(比如支付、账务)→ 考虑 TCC 或 2PC
- 最终一致性可接受(比如发券、积分)→ Saga + 补偿 or 消息队列
- 性能压倒一切(比如秒杀)→ 异步 + 对账兜底
我们这个场景,运营说:“只要最后两个人都拿到券就行,中间慢点没关系。” 好,那我们就走 最终一致性 路线。
但注意!“最终一致”不等于“随便搞”。你得保证:
- 不会重复发券(幂等)
- 失败能重试(可靠)
- 出问题能追溯(可观测)
于是,我决定用 基于可靠消息的最终一致性方案,核心组件:Kafka + 本地事务表 + 幂等消费。
为什么选 Go?因为快、稳、不矫情
虽然公司主力是 Java,但这个新模块我坚持用 Go 写。原因很简单:
- 启动快,资源省(外包项目服务器预算抠得要死)
- 并发模型天然适合处理异步任务
- 静态编译,部署就是扔个二进制文件,运维不骂我
而且,Go 的 database/sql 包配合 pgx 驱动,写本地事务表简直丝滑。
核心思路:两阶段提交的“穷人版”
- 第一阶段:在本地数据库开启事务,同时写业务数据 + 写消息到“待发送消息表”
- 提交本地事务
- 异步轮询消息表,把消息发到 Kafka
- 消费者收到消息,执行下游操作(发券),成功则标记消息为已消费
关键点:消息的生产和业务操作在一个本地事务里,保证原子性。
// 伪代码:分享成功后发券
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,是静默失败——消息丢了没人知道,券没发用户没投诉,月底对账才发现差了十万张。
所以我们加了三道保险:
- 消息表监控:每5分钟检查
outbox表里超过10分钟还没发送的消息,告警 - 消费者延迟监控:Kafka Lag 超过阈值就钉钉报警
- 每日对账脚本:凌晨跑批,比对“应发券数” 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 实现简单。虽然理论上可能有极短时间不一致,但业务能接受。
血泪教训:这些坑千万别踩
别信“自动补偿”
有些框架号称自动回滚,但网络超时、服务宕机时,补偿逻辑可能根本没触发。手动写补偿函数,比依赖框架靠谱。Kafka 不是万能的
曾经以为 Kafka 保证不丢消息,结果发现生产者没开acks=all,broker 挂了一台,消息真丢了。配置必须严格:acks: all retries: 3 enable.idempotence: true别让运营直接改数据库
有次运营为了“紧急修复”,直接 UPDATE 了券状态,结果破坏了幂等逻辑,导致重复发券。后来我们加了 DB 只读账号给运营,写操作必须走 API。测试要模拟网络分区
用tc命令模拟丢包、延迟,看看系统会不会脑裂。别等双11才暴露问题。
结语:外包狗的生存哲学
干了四年外包,我早就不信“完美架构”了。客户需求变比翻书快,老板要的是“能跑就行”,测试只关心“点按钮有没有报错”。
但作为有点追求的程序员,我还是想在泥潭里种朵花。分布式事务不是炫技,而是用最小成本守住数据底线。
这套基于消息表的方案,上线三个月,发了上千万张券,0 数据不一致。运营终于不来半夜打电话了,前端也不乱重试了,连产品经理都说:“你们后端最近挺稳啊。”
其实哪有什么稳,不过是把坑都提前踩了一遍罢了。
如果你也在杭州,正在被分布式事务折磨,欢迎来约杯咖啡。我知道西湖边有家店,他们的拿铁不加糖——就像我的代码,拒绝甜腻,只求可靠。
(完)

评论 0