分布式事务解决方案:最佳实践(一个被 deadline 追着跑的 DevOps 的血泪总结)
“我们这个系统要支持跨服务转账,但又不能丢数据。”
—— 产品经理在周五下午五点发来的钉钉消息
大家好,我是老李,一名在某中型互联网公司苟了三年多的 DevOps 工程师。日常除了写 CI/CD 流水线、盯 Grafana 面板、半夜被 PagerDuty 叫醒之外,最近还被迫深入研究分布式事务——因为公司新上的“区块链+爬虫”数据聚合平台,要求强一致性,偏偏架构又是微服务拆得七零八落。
说实话,刚接到需求时我内心是拒绝的。毕竟,谁不知道“分布式事务”这五个字背后藏着多少坑?但为了跳槽简历上能多一行“精通分布式系统”,也为了不被老板在 OKR 会上公开处刑,我硬着头皮啃了两个月文档,终于把方案落地了。今天就来聊聊我们在生产环境踩过的雷、填过的坑,以及最终选型的性能最优解。
起因:一个“简单”的需求,引发一场雪崩
事情得从去年双11说起。我们团队接了个“创新项目”:用 Go 写的高并发爬虫集群,从全网抓取商品价格和评论数据;然后通过一套自研的轻量级区块链账本(别笑,是真的用 Tendermint 改的),对关键交易做不可篡改记录;最后这些数据要实时同步到用户积分系统和风控服务。
听起来很酷?问题来了:爬虫抓到一笔订单 → 区块链上链 → 积分系统加积分 → 风控系统打标签,这一整条链路涉及四个独立服务,每个都可能失败。如果中间任何一个环节挂了,数据不一致,用户投诉、财务对不上账、甚至合规风险……想想就头皮发麻。
更惨的是,产品要求 TPS ≥ 2000,P99 延迟 < 500ms。传统两阶段提交(2PC)?那玩意儿在我们测试环境一压测就卡成 PPT,连接池直接爆掉。Saga 模式?补偿逻辑写得我头秃,而且一旦补偿失败,还得人工介入——运维兄弟已经放话:“再让我半夜修数据,我就提桶跑路。”
技术选型:性能 vs 一致性,成年人不做选择?
我们团队开了三次技术评审会,吵得差点掀桌子。最后达成共识:不能为了强一致性牺牲太多性能,尤其是在爬虫这种高频写入场景下。
于是我们放弃了 2PC 和纯 Saga,转向 本地消息表 + 最终一致性 方案,并结合 Go 的高并发特性做了深度优化。
核心思路很简单:
- 业务操作和消息写入放在同一个本地 DB 事务里
- 后台异步任务轮询未发送的消息,投递到 Kafka
- 下游服务消费 Kafka 消息,执行本地逻辑
- 成功则标记消息已处理,失败则重试(带指数退避)
但!魔鬼在细节。比如:
- 消息表怎么设计才能避免锁竞争?
- Kafka 消息丢了怎么办?
- 爬虫服务突然 OOM 重启,会不会重复消费?
实战:Go + 本地消息表,如何扛住 2k TPS?
消息表结构设计(MySQL 8.0)
CREATE TABLE `outbox_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`service_name` VARCHAR(64) NOT NULL, -- 发送方服务名
`event_type` VARCHAR(64) NOT NULL, -- 事件类型,如 "order_created"
`payload` JSON NOT NULL, -- 序列化后的事件体
`status` TINYINT DEFAULT 0, -- 0: pending, 1: sent, 2: failed
`retry_count` INT DEFAULT 0,
`created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX idx_status_retry (status, retry_count),
INDEX idx_created (created_at)
) ENGINE=InnoDB;
关键点:
- 用
status + retry_count联合索引加速轮询 payload用 JSON 类型,避免频繁改表结构(感谢 MySQL 8.0)- 不用自增 ID 做分区,而是按
created_at范围查询,避免热点
Go 异步投递器(带背压控制)
// OutboxProcessor 负责轮询并投递消息
type OutboxProcessor struct {
db *sql.DB
kafka sarama.SyncProducer
limiter *rate.Limiter // 限制 Kafka QPS,防止压垮 broker
}
func (p *OutboxProcessor) PollAndSend(ctx context.Context) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
msgs, err := p.fetchPendingMessages()
if err != nil {
log.Errorf("fetch messages failed: %v", err)
continue
}
for _, msg := range msgs {
if !p.limiter.Allow() { // 背压控制!
time.Sleep(10 * time.Millisecond)
continue
}
if err := p.sendToKafka(msg); err != nil {
p.markAsFailed(msg.ID)
} else {
p.markAsSent(msg.ID)
}
}
}
}
}
💡 性能 Tips:我们把轮询间隔从 1s 降到 100ms,配合
SELECT ... FOR UPDATE SKIP LOCKED(MySQL 8.0 特性),避免多个 worker 争抢同一行,QPS 直接翻倍。
和区块链、爬虫的“危险关系”
你可能会问:区块链不是号称“天然解决信任问题”吗?为啥还要搞这套?
真相是:我们的区块链只存哈希摘要,不存完整业务数据。真正的一致性保障还得靠应用层。比如:
- 爬虫服务抓到订单后,先写本地 DB(含订单详情)
- 同时往
outbox_message插一条"order_hash_committed"事件 - 区块链服务消费该事件,只将订单哈希上链
- 如果上链失败,消息重试,直到成功
这样既利用了区块链的不可篡改性,又避免了把高频写入压到链上(否则 TPS 直接归零)。
至于爬虫?它们只是“数据生产者”,完全无感。唯一要注意的是:爬虫服务必须幂等。因为我们无法保证 Kafka 消息不重复(比如 ack 超时重试)。所以所有下游服务的接口都要做 request_id 去重。
性能对比:三种方案实测数据
我们在 16C32G 的机器上压测(模拟 2000 TPS 写入):
| 方案 | 平均延迟 | P99 延迟 | CPU 使用率 | 数据一致性 | 运维复杂度 |
|---|---|---|---|---|---|
| 2PC (Seata) | 820ms | 2100ms | 78% | 强一致 | ⭐⭐⭐⭐ |
| Saga (手动补偿) | 450ms | 980ms | 52% | 最终一致 | ⭐⭐⭐⭐⭐ |
| 本地消息表 + Kafka | 280ms | 480ms | 38% | 最终一致 | ⭐⭐ |
结论很明显:本地消息表方案在性能和可维护性上完胜。虽然牺牲了“强一致”,但在我们场景下,500ms 内达到最终一致完全可接受。
血泪教训 & 最佳实践
- 消息表一定要独立:别和业务表混在一起!否则 DDL 或慢查询会拖垮整个事务。
- Kafka 必须配 acks=all:宁可慢一点,也不能丢消息。我们曾因
acks=1在 broker 宕机时丢过一批数据,CTO 亲自打电话骂人。 - 重试要有上限 + 告警:超过 5 次失败就进死信队,触发企业微信告警。别指望自动恢复。
- 监控看三个指标:消息积压量、重试率、端到端延迟。Grafana 面板我已经贡献给公司内部 Wiki 了。
- 别信“理论上没问题”:上线前务必用 Chaos Engineering 注入网络分区、DB 主从切换等故障。
写在最后:为什么我要写这篇文章?
其实上周我已经拿到了新 offer,下个月就要去一家做 Web3 基础设施的 startup 了。临走前,团队小弟问我:“哥,这分布式事务到底有没有银弹?”
我笑了笑说:“没有。只有权衡。”
写这篇文章,一是给接我班的兄弟留个参考(少踩点坑,多陪陪女朋友),二是给自己三年 DevOps 生涯画个小结。毕竟,在这个天天喊“降本增效”的年代,能沉下心把一件事做到极致,已经很难得了。
如果你也在为分布式事务头疼,不妨试试本地消息表 + Kafka 这套组合拳。代码我已经脱敏上传到 GitHub(链接略),欢迎 star & 提 issue。
P.S. 产品经理们,请记住:“简单”两个字,是程序员听到最恐怖的词。下次提需求前,请先请我喝杯瑞幸 😏

评论 0