分布式事务踩坑三年,我才明白这些最佳实践

#徐芳
2026-05-30 21:43
阅读 975

去年双11前两周,我们组临时接到一个需求:把用户下单、库存扣减、积分发放三个服务串成一个原子操作。听起来是不是很熟悉?没错,就是典型的分布式事务场景。作为从测试转开发刚满三年的“半路出家”选手,当时我内心是崩溃的——毕竟之前做测试时,看到“事务回滚”四个字就头大,现在居然要亲手实现它?

坐标上海,每天挤地铁到公司不到10分钟,本以为能躺平写点CRUD,结果被分布式事务狠狠教育了一番。今天这篇博客,就是想把我这几个月踩过的坑、熬过的夜、喝掉的咖啡(以及差点砸掉的键盘)总结出来,给同样在微服务泥潭里挣扎的兄弟们一点参考。


事情起因其实挺戏剧化。产品经理拿着一张画得像小学生涂鸦的流程图跑来:“这个订单链路必须保证一致性,不然用户投诉起来,运维大哥又要半夜call我们了。” 我瞄了一眼,好家伙,涉及三个独立数据库,两个第三方服务,还有一个用Go写的老旧库存系统——这不就是分布式事务教科书里的经典反面案例吗?

最开始我们天真地用了“两阶段提交”(2PC),结果上线第一天就炸了:网络抖动导致协调者超时,整个订单链路卡住,用户页面一直转圈。运维群里瞬间刷屏:“@后端,赶紧看下!” 那天晚上我一边改代码一边啃泡面,心里默念:早知道当初做测试多好,至少不用背锅。

后来痛定思痛,决定换方案。调研一圈下来,最终选型了Saga模式 + 补偿机制,搭配本地消息表(Local Message Table)来保证最终一致性。为什么没选TCC?因为我们的库存服务是外包团队用Go写的,根本没法让他们配合写Try/Confirm/Cancel接口——沟通成本太高,deadline又近,只能自己造轮子。


爬虫项目教会我的“异步补偿”思维

有意思的是,真正让我理解Saga模式的,居然是一个业余项目:我用Rust写了个小爬虫,抓取电商价格数据做比价分析。因为目标网站有反爬,经常请求失败,我就设计了一套“重试+补偿”机制:如果某次抓取失败,就记录失败任务,后续定时重跑。

这套逻辑迁移到分布式事务上简直神契合!比如用户下单成功但积分发放失败,我们就把“补偿任务”写入本地消息表,由后台Job定期扫描并重试积分服务。一旦成功,标记任务完成;若多次失败,则告警人工介入。

关键代码结构如下(Go语言示例):

// 本地消息表结构
type TransactionMessage struct {
	ID          string    `gorm:"primaryKey"`
	BizType     string    // 业务类型: order_create, points_award...
	BizID       string    // 关联业务ID
	Status      string    // pending, success, failed
	Payload     string    // JSON格式的补偿参数
	RetryCount  int
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

// 下单主流程伪代码
func CreateOrder(ctx context.Context, req OrderRequest) error {
	tx := db.Begin()
	defer tx.Rollback()

	// 1. 创建订单
	order := createOrderInDB(tx, req)
	
	// 2. 扣减库存(调用Go库存服务)
	if err := callInventoryService(order.ID, req.Items); err != nil {
		return fmt.Errorf("库存扣减失败: %w", err)
	}

	// 3. 记录积分发放任务(即使失败也不阻塞主流程)
	msg := TransactionMessage{
		BizType: "points_award",
		BizID:   order.ID,
		Payload: marshalPointsPayload(req.UserID, calcPoints(req)),
		Status:  "pending",
	}
	tx.Create(&msg)

	return tx.Commit().Error
}

这里有个细节:库存扣减是同步强依赖,积分发放是异步弱依赖。为什么这么设计?因为库存不准会导致超卖,是P0级事故;而积分延迟发,用户顶多骂两句,属于可容忍范围。这种“关键路径 vs 非关键路径”的区分,是我在测试时期就养成的习惯——当年写自动化用例时,就知道哪些断言必须严格校验,哪些可以宽松处理。


Claude Code 和 Prompt 工程救我狗命

说到工具链,最近疯狂安利团队用 Claude Code(对,就是那个支持长上下文的AI编程助手)。有次我写补偿Job的幂等逻辑死活不对,反复出现重复发积分的问题。情急之下,我把整个文件贴给Claude,加上一句Prompt:

“这是一个Go编写的补偿任务处理器,请确保同一BizID+BizType的组合只会成功执行一次,即使被多次调度。当前代码存在并发竞争条件,请用数据库乐观锁修复。”

不到10秒,它直接给出了带version字段的更新语句:

// 使用乐观锁防止重复执行
result := db.Model(&msg).
	Where("id = ? AND status = ?", msg.ID, "pending").
	UpdateColumns(map[string]interface{}{
		"status": "processing",
		"version": gorm.Expr("version + 1"),
	})
if result.RowsAffected == 0 {
	log.Info("任务已被其他实例处理,跳过")
	return nil
}

那一刻我真想给Anthropic寄锦旗。不得不说,Prompt工程在这类场景特别有用——你越清楚问题边界,AI给出的方案越精准。当然,生产代码我还是会仔细review,毕竟AI偶尔也会“一本正经地胡说八道”。


生产环境血泪教训

上线后也不是一帆风顺。有一次因为消息表没加索引,补偿Job全表扫描导致数据库CPU飙到90%,运维大哥直接冲到我们工位:“再这样我就把你们服务下线了!” 后来赶紧补上复合索引:

CREATE INDEX idx_msg_status_biz ON transaction_messages (status, biz_type, created_at);

另外,监控也得跟上。我们在Prometheus里加了几个关键指标:

指标名 说明 告警阈值
saga_pending_tasks 待处理补偿任务数 > 100 持续5分钟
saga_retry_rate 任务重试率 > 5%
saga_manual_intervention 人工介入次数 单日 > 5

有了这些,终于能在问题扩大前及时干预。上周五晚上,系统自动检测到一批积分任务卡住,值班同学收到告警后远程登录,发现是第三方积分服务限流了——手动调整重试间隔后,半小时内自动恢复。不用再半夜被电话吵醒,幸福感拉满。


写在最后:测试出身反而成了优势?

回头看,从测试转开发的经历,其实在处理分布式事务时意外地帮了大忙。因为我更习惯从失败场景反推设计:网络超时怎么办?服务宕机怎么办?数据不一致怎么兜底?这种“悲观思维”让我们的补偿机制覆盖了更多边缘情况。

至于技术选型,我的建议很朴素:别盲目追新,先看团队能力和业务容忍度。如果你的系统像我们一样,有老旧Go服务、有外包组件、还有随时改需求的产品经理,那Saga + 本地消息表可能是性价比最高的方案。至于Seata、DTF这些框架,等团队有精力深度定制时再考虑也不迟。

哦对了,最近在用Rust重写部分补偿Job,内存安全和零成本抽象确实香。不过那是另一个故事了……(下次博客见?)

总之,分布式事务没有银弹,只有适合当下场景的“够用就好”。搞定它那天,我请全组喝了奶茶——毕竟,活着上线,就是胜利。

评论 0

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