分布式事务怎么搞?我在快手踩过的坑和填坑工具箱
去年双11前两周,我们组负责的订单履约系统突然在压测时炸了——用户付了钱,库存扣了,但优惠券没核销。运维兄弟半夜拉群@我:“老张,又双叒叕数据不一致了!”那一刻我真想把电脑砸了,毕竟这已经是本月第三次因为分布式事务翻车了。
我是快手的老兵,在后端架构岗上干了快六年,从0到1搭过用户中心、支付网关、实时推荐引擎。这两年在履约组带团队,天天跟分布式事务“搏斗”。说实话,刚接手这个系统时,我对分布式事务的理解还停留在“两阶段提交”这种教科书概念上,直到被线上事故教育得体无完肤。
今天这篇文章,就把我这两年在Java生态里摸爬滚打总结出来的实战经验分享出来。不讲虚的理论,只聊能跑在生产环境里的工具和最佳实践。
为啥分布式事务这么难搞?
先说背景。我们的履约流程涉及至少四个服务:订单服务(写MySQL)、库存服务(写Redis+MySQL)、优惠券服务(写MongoDB)、风控服务(异步调用)。每个服务都是独立部署、独立数据库,典型的微服务架构。产品经理一句话:“用户下单必须原子成功”,结果我们后端就得保证跨库、跨服务的数据一致性。
早期我们尝试过最朴素的方案:本地事务 + 重试补偿。比如下单时先扣库存,再发MQ通知优惠券核销。但如果MQ挂了或者优惠券服务超时,就得靠定时任务扫表补偿。结果呢?测试同学提了200+个边界Case,光是“部分成功+重试+并发”组合就让我们加班到凌晨三点。
后来我们意识到:这不是代码问题,是架构问题。于是开始系统性地评估市面上的分布式事务解决方案。
主流方案横向对比:别被论文忽悠了
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TCC (Try-Confirm-Cancel) | 强一致性,性能好 | 侵入性强,需写三套逻辑 | 支付、转账等强一致场景 |
| Saga | 易理解,支持长事务 | 最终一致性,回滚复杂 | 订单履约、审批流等 |
| Seata AT模式 | 无侵入,自动回滚 | 性能损耗大,锁粒度粗 | 快速上线、中小规模系统 |
| 消息事务(RocketMQ事务消息) | 解耦好,吞ut量高 | 依赖MQ可靠性,最终一致 | 日志、通知类弱一致场景 |
我们在内部做了POC(Proof of Concept),结论很现实:
- TCC 虽然完美,但改造成本太高。优惠券团队直接拒绝:“我们三个前端两个后端,哪有精力写Cancel接口?”
- Seata AT 看似香,但在高并发下(>5K TPS)数据库行锁竞争严重,压测时MySQL CPU飙到90%
- 消息事务 在非核心链路表现不错,但履约这种“钱相关”的场景,产品经理死活不同意“最终一致”
最后我们选了 Saga + 补偿机制 + 幂等设计 的混合方案。为什么?因为业务可接受最终一致,且我们有完善的监控告警体系兜底。
实战:用Java实现一个靠谱的Saga事务
核心思想很简单:正向操作 + 异步补偿。但魔鬼在细节。
第一步:定义事务步骤(Step)
// 履约Saga的步骤定义
public enum FulfillmentStep {
RESERVE_INVENTORY, // 预占库存
DEDUCT_COUPON, // 扣减优惠券
CREATE_DELIVERY_ORDER, // 创建物流单
COMPLETE; // 完成
}
第二步:每个步骤实现补偿逻辑
@Component
public class InventorySagaStep implements SagaStep {
@Override
public boolean execute(OrderContext ctx) {
// 调用库存服务预占
return inventoryClient.reserve(ctx.getOrderId(), ctx.getSkuId(), ctx.getCount());
}
@Override
public boolean compensate(OrderContext ctx) {
// 补偿:释放预占库存
return inventoryClient.release(ctx.getOrderId(), ctx.getSkuId(), ctx.getCount());
}
}
第三步:事务编排器(关键!)
@Service
public class SagaOrchestrator {
// 使用状态机驱动事务
public void startFulfillment(OrderContext ctx) {
ctx.setCurrentStep(FulfillmentStep.RESERVE_INVENTORY);
try {
for (FulfillmentStep step : STEP_ORDER) {
if (!executeStep(step, ctx)) {
// 执行失败,立即触发补偿
compensateUntil(step, ctx);
throw new SagaExecutionException("Step failed: " + step);
}
ctx.setCurrentStep(nextStep(step));
}
} catch (Exception e) {
log.error("Saga failed for order: {}", ctx.getOrderId(), e);
// 异步记录失败事务,供人工干预
sagaFailureQueue.offer(ctx);
}
}
private void compensateUntil(FulfillmentStep failedStep, OrderContext ctx) {
// 从当前步骤倒序补偿
List<FulfillmentStep> toCompensate = getStepsBefore(failedStep);
Collections.reverse(toCompensate);
toCompensate.forEach(step -> compensateStep(step, ctx));
}
}
第四步:幂等性保障(血泪教训!)
所有服务接口必须加幂等校验!我们用Redis + 唯一请求ID实现:
@PostMapping("/deduct-coupon")
public ResponseEntity<?> deductCoupon(@RequestHeader("X-Request-ID") String requestId) {
if (redisTemplate.hasKey("idempotent:" + requestId)) {
return ResponseEntity.ok("Already processed");
}
// 执行业务逻辑
couponService.deduct(...);
// 标记已处理(设置过期时间)
redisTemplate.setex("idempotent:" + requestId, 3600, "1");
return ResponseEntity.ok("Success");
}
生产环境避坑指南
补偿不是万能的
曾经有个Bug:补偿接口被限流,导致库存一直没释放。后来我们给补偿通道单独配了高优先级线程池,并加了补偿失败告警。监控必须到位
我们自研了一个Saga事务看板,实时展示:- 进行中事务数
- 补偿触发率
- 平均完成时长
上周发现补偿率突增,排查发现是优惠券服务慢查询导致超时。
不要迷信“自动”
Seata的AT模式号称“零侵入”,但在我们订单表有20+字段、频繁更新的场景下,undo_log表膨胀到TB级,DBA差点提刀来砍我。测试要模拟网络分区
用Chaos Mesh注入网络延迟后,才发现补偿逻辑没处理“服务完全不可达”的情况。现在CI流程强制跑混沌测试。
工具链推荐(亲测可用)
- 事务追踪:SkyWalking + 自定义Saga插件(能看到整个事务链路)
- 补偿重试:XXL-JOB(比Spring Retry更可控)
- 幂等组件:自己封装的
IdempotentTemplate,支持注解式使用 - 压测工具:GoReplay回放线上流量,比JMeter更真实
最后几句大实话
分布式事务没有银弹。我在快手这几年深刻体会到:架构选择本质是成本权衡。如果你的业务允许最终一致(比如发券、积分),优先考虑消息队列;如果是资金交易,老老实实用TCC。
另外,别指望一个框架解决所有问题。Seata、Hmily这些开源项目确实降低了门槛,但生产环境的稳定性,最终还是靠完善的监控 + 人工兜底 + 团队协作。上周五晚上,我们还在群里讨论一个补偿死循环的Case,最后发现是测试同学构造的异常数据没清理干净——所以啊,工具再强,也得配上靠谱的流程。
希望这篇带血泪的实战经验能帮你少走弯路。如果你们也在搞分布式事务,欢迎留言交流(或者吐槽你们的产品经理)!

评论 0