分布式事务解决方案:最佳实践(一个被 deadline 追着跑的 DevOps 的血泪总结)

Vue快乐水
2025-12-16 18:37
阅读 420

“我们这个系统要支持跨服务转账,但又不能丢数据。”
—— 产品经理在周五下午五点发来的钉钉消息

大家好,我是老李,一名在某中型互联网公司苟了三年多的 DevOps 工程师。日常除了写 CI/CD 流水线、盯 Grafana 面板、半夜被 PagerDuty 叫醒之外,最近还被迫深入研究分布式事务——因为公司新上的“区块链+爬虫”数据聚合平台,要求强一致性,偏偏架构又是微服务拆得七零八落。

说实话,刚接到需求时我内心是拒绝的。毕竟,谁不知道“分布式事务”这五个字背后藏着多少坑?但为了跳槽简历上能多一行“精通分布式系统”,也为了不被老板在 OKR 会上公开处刑,我硬着头皮啃了两个月文档,终于把方案落地了。今天就来聊聊我们在生产环境踩过的雷、填过的坑,以及最终选型的性能最优解


起因:一个“简单”的需求,引发一场雪崩

事情得从去年双11说起。我们团队接了个“创新项目”:用 Go 写的高并发爬虫集群,从全网抓取商品价格和评论数据;然后通过一套自研的轻量级区块链账本(别笑,是真的用 Tendermint 改的),对关键交易做不可篡改记录;最后这些数据要实时同步到用户积分系统和风控服务。

听起来很酷?问题来了:爬虫抓到一笔订单 → 区块链上链 → 积分系统加积分 → 风控系统打标签,这一整条链路涉及四个独立服务,每个都可能失败。如果中间任何一个环节挂了,数据不一致,用户投诉、财务对不上账、甚至合规风险……想想就头皮发麻。

更惨的是,产品要求 TPS ≥ 2000,P99 延迟 < 500ms。传统两阶段提交(2PC)?那玩意儿在我们测试环境一压测就卡成 PPT,连接池直接爆掉。Saga 模式?补偿逻辑写得我头秃,而且一旦补偿失败,还得人工介入——运维兄弟已经放话:“再让我半夜修数据,我就提桶跑路。”


技术选型:性能 vs 一致性,成年人不做选择?

我们团队开了三次技术评审会,吵得差点掀桌子。最后达成共识:不能为了强一致性牺牲太多性能,尤其是在爬虫这种高频写入场景下。

于是我们放弃了 2PC 和纯 Saga,转向 本地消息表 + 最终一致性 方案,并结合 Go 的高并发特性做了深度优化。

核心思路很简单:

  1. 业务操作和消息写入放在同一个本地 DB 事务里
  2. 后台异步任务轮询未发送的消息,投递到 Kafka
  3. 下游服务消费 Kafka 消息,执行本地逻辑
  4. 成功则标记消息已处理,失败则重试(带指数退避)

但!魔鬼在细节。比如:

  • 消息表怎么设计才能避免锁竞争?
  • 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 内达到最终一致完全可接受。


血泪教训 & 最佳实践

  1. 消息表一定要独立:别和业务表混在一起!否则 DDL 或慢查询会拖垮整个事务。
  2. Kafka 必须配 acks=all:宁可慢一点,也不能丢消息。我们曾因 acks=1 在 broker 宕机时丢过一批数据,CTO 亲自打电话骂人。
  3. 重试要有上限 + 告警:超过 5 次失败就进死信队,触发企业微信告警。别指望自动恢复。
  4. 监控看三个指标:消息积压量、重试率、端到端延迟。Grafana 面板我已经贡献给公司内部 Wiki 了。
  5. 别信“理论上没问题”:上线前务必用 Chaos Engineering 注入网络分区、DB 主从切换等故障。

写在最后:为什么我要写这篇文章?

其实上周我已经拿到了新 offer,下个月就要去一家做 Web3 基础设施的 startup 了。临走前,团队小弟问我:“哥,这分布式事务到底有没有银弹?”

我笑了笑说:“没有。只有权衡。”

写这篇文章,一是给接我班的兄弟留个参考(少踩点坑,多陪陪女朋友),二是给自己三年 DevOps 生涯画个小结。毕竟,在这个天天喊“降本增效”的年代,能沉下心把一件事做到极致,已经很难得了。

如果你也在为分布式事务头疼,不妨试试本地消息表 + Kafka 这套组合拳。代码我已经脱敏上传到 GitHub(链接略),欢迎 star & 提 issue。

P.S. 产品经理们,请记住:“简单”两个字,是程序员听到最恐怖的词。下次提需求前,请先请我喝杯瑞幸 😏

评论 0

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