分布式事务解决方案:我在实习转正项目里踩过的坑与填坑姿势

王杰
2025-12-13 17:35
阅读 717

大家好,我是小林,普通一本 CS 专业大四狗,目前在某二线互联网公司后端组混日子,已经拿到秋招 offer 等着入职了。这学期基本没啥课,每天就窝在公司工位上写代码、改 bug、刷 LeetCode——没错,一边准备入职前的“技术保温”,一边顺便把简历上的“分布式系统经验”那行字再夯实点。

说到分布式事务,其实我原本以为这玩意儿是架构师才操心的事。结果去年双11前两周,我们组接了个新需求:用户投递简历后,要自动触发一个异步任务去抓取他 GitHub 上的公开项目信息(用爬虫),然后通过内部算法模型打个“工程能力分”存进数据库。听起来人畜无害对吧?

但问题来了:整个流程横跨三个服务——

  • 用户服务(MySQL):记录简历投递事件
  • 爬虫服务(Go 写的):调 GitHub API 拿数据
  • 算法服务(Python + TensorFlow Serving):跑模型打分

而且 PM 死活要求“要么全成功,要么全回滚”。我当场就懵了:这不就是典型的分布式事务场景吗?!更离谱的是,测试同学还特意问:“如果爬虫超时了,简历状态还能不能回退?”——好家伙,这题我会,但没实战过啊!


被逼上梁山:为什么本地事务不够用?

刚开始我天真的想法是:在用户服务里开个事务,先 insert 简历,再 HTTP 调爬虫服务,成功了再 commit。结果被组长一巴掌拍醒:“你当其他服务是你家后花园?网络抖一下你就把简历锁死半小时?”

确实,传统单体应用里 BEGIN; INSERT; UPDATE; COMMIT 那套在微服务时代直接失效。一旦跨服务、跨数据库,ACID 就只剩个 A(原子性)在风中凌乱。

我们线上环境用的是 MySQL 8.0 + Redis + Kafka,团队技术栈偏 Go(爬虫和核心业务都是 Go),所以方案必须兼容这些。经过和后端老哥们的头脑风暴(其实就是茶水间蹲坑时的牢骚大会),我们锁定了三种主流解法:

方案 原理 优点 缺点 适用场景
2PC(两阶段提交) 协调者统一询问+提交 强一致性 同步阻塞、性能差、协调者单点 金融等强一致场景
TCC(Try-Confirm-Cancel) 业务层实现补偿逻辑 灵活、性能较好 开发成本高 核心交易链路
Saga(事件驱动) 顺序执行+失败回滚 异步、扩展性好 最终一致性、需设计补偿 长流程、非关键业务

考虑到我们的场景:非金融级、允许短暂不一致、但必须保证最终状态正确,再加上爬虫本身就有重试机制,Saga 模式成了最优解。而且 Kafka 天然支持事件驱动,简直是天作之合。


实战:用 Saga + Kafka 搞定简历-爬虫-算法链路

架构设计

用户服务 → [Kafka Topic: resume_submitted] 
          ↓
爬虫服务(消费)→ 抓取 GitHub → [Kafka Topic: github_fetched]
                   ↓
算法服务(消费)→ 打分 → 更新用户表

关键点:每个步骤都必须幂等!因为 Kafka 可能重复投递消息(比如消费者挂了重启)。

第一步:用户服务发事件(Go)

// user_service.go
func SubmitResume(ctx context.Context, req *SubmitResumeReq) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 1. 插入简历记录(状态=processing)
    _, err = tx.ExecContext(ctx, 
        "INSERT INTO resumes (user_id, status, created_at) VALUES (?, 'processing', NOW())", 
        req.UserID)
    if err != nil {
        return err
    }

    // 2. 发送 Kafka 事件(注意:必须在 DB 事务内!)
    event := ResumeSubmittedEvent{UserID: req.UserID, ResumeID: newID}
    if err := kafkaClient.PublishInTx(ctx, tx, "resume_submitted", event); err != nil {
        return err // 这里会触发 rollback
    }

    return tx.Commit()
}

💡 坑点:早期我把 Kafka 发送放在事务外,结果 DB 提交成功但 Kafka 失败,导致漏消息!后来用了 go-mysql-kafka-transaction 这种库,确保 DB 和 Kafka 的原子性。

第二步:爬虫服务消费 + 补偿(Go)

爬虫服务收到 resume_submitted 后:

  • 成功:抓取 GitHub → 发 github_fetched 事件
  • 失败(比如 GitHub API 限流):resume_failed 事件
// crawler_service.go
func HandleResumeSubmitted(event ResumeSubmittedEvent) {
    githubData, err := fetchGitHubProfile(event.UserID)
    if err != nil {
        // 关键:失败也要发事件!
        kafkaClient.Publish("resume_failed", ResumeFailedEvent{
            ResumeID: event.ResumeID,
            Reason:   "github_api_error",
        })
        return
    }
    
    kafkaClient.Publish("github_fetched", GithubFetchedEvent{
        ResumeID: event.ResumeID,
        Data:     githubaData,
    })
}

第三步:用户服务监听失败事件,回滚状态

// user_service_compensate.go
func HandleResumeFailed(event ResumeFailedEvent) {
    // 直接更新状态为 failed,不走事务(因为原事务已结束)
    db.Exec("UPDATE resumes SET status = 'failed', error_msg = ? WHERE id = ?", 
        event.Reason, event.ResumeID)
}

🤯 灵魂拷问:如果 github_fetched 之后算法服务挂了怎么办?
:算法服务也要做同样逻辑——失败就发 scoring_failed,用户服务再监听它。每个环节都要有“逃生舱”


踩过的坑 & 生产经验

1. 幂等性不是说说而已

有次测试同学疯狂点“重新投递”,导致同个 resume_id 被处理十几次。我们紧急加了幂等表:

CREATE TABLE idempotent_keys (
    key VARCHAR(64) PRIMARY KEY,  -- e.g., "resume_submitted:123"
    created_at TIMESTAMP
);

每次处理前先 INSERT IGNORE,失败就跳过。虽然简单粗暴,但管用。

2. 补偿操作可能失败!

最恐怖的场景:爬虫成功了,但发 github_fetched 时 Kafka 挂了。这时候简历卡在 processing 状态,用户以为没成功。

我们的解法:

  • 定时扫描:每天凌晨跑脚本,把超过 1 小时还是 processing 的简历标记为 timeout
  • 人工干预接口:运维后台加了个“强制重试”按钮(别笑,救过命)

3. 别信“最终一致性”的鬼话

上线第一周,监控告警炸了:5% 的简历卡在中间状态。查日志发现是算法服务 OOM 了(模型加载太吃内存)。后来我们:

  • 给 Kafka 消费者加了 backoff retry(指数退避)
  • 算法服务拆成独立 Pod,资源隔离
  • 关键事件加 dead letter queue(DLQ),方便人工排查

性能 & 运维考量

延迟 vs 一致性

Saga 是异步的,所以端到端延迟从 200ms(同步)涨到 2s+。但 PM 居然接受了!理由是:“用户又不是实时看分,第二天看到就行”。果然,业务容忍度决定技术方案

监控必须到位

我们在 Grafana 上建了三个核心指标:

  • resume_processing_duration_seconds(P99 < 5s)
  • kafka_lag_by_topic(不能堆积超过 1000 条)
  • compensation_rate(补偿率 > 1% 就告警)

有次半夜报警,发现是 GitHub API 配额用完了,自动切备用账号搞定。没有监控的分布式系统等于裸奔


写在最后:这玩意儿真能写进简历吗?

说实话,做完这个项目我才算真正理解了“分布式事务不是银弹”。2PC 太重,TCC 成本太高,Saga 虽然灵活但得自己兜底。不过好处是——现在面试官问我“如何保证跨服务数据一致性”,我能掏出一整套故事+数据+代码,而不是背八股文。

上周五晚上,我又被拉去 review 一个新需求:用户删除简历时,要同时删掉算法生成的画像。我脱口而出:“用 Saga 啊!发个 resume_deleted 事件,下游自己清理。” —— 组长笑着点头:“行啊小子,可以出师了。”

虽然离真正的高并发、强一致场景还差得远(比如支付、库存),但至少下次写简历时,“熟悉分布式事务解决方案”这行字,我可以理直气壮地加上去了。

对了,最近在啃《Designing Data-Intensive Applications》,顺便学点 AI(毕竟算法服务老甩锅说“模型不准”)。要是哪天能把 LLM 用在事务补偿决策上……算了,先搞定入职再说吧 😅


附:关键配置参考

Kafka 消费者配置(Go Sarama):

consumer:
  retries: 3
  backoff_ms: 1000
  max_processing_time: 30s # 超时自动 nack
  enable_dlq: true         # 开启死信队列

MySQL 事务隔离级别:

-- 必须 READ COMMITTED!避免幻读导致重复消费
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

评论 0

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