分布式事务的那些坑,我是怎么踩过来的
我是在一家中型电商公司做后端开发的,前年我们团队在做一个“秒杀+订单拆单”的重构项目时,第一次真正面对了分布式事务这一块硬骨头。以前虽然也看过一些理论知识,比如 CAP 定理、XA 协议、TCC 模型、Saga 模式等等,但真正到了生产环境里去落地,才发现课本和现实之间的差距有多大。
这篇文章就想分享一下当时那个项目的过程:为什么我们需要分布式事务?遇到了哪些坑?是怎么解决的?以及后来的一些运维经验。希望你能从中获得一些启发,少走点弯路。
项目背景

我们的系统最初是基于一个单体架构搭建的,随着业务增长和用户体量上升,慢慢地我们将库存、订单、支付这些模块都进行了微服务拆分,逐渐形成了以下结构:
┌────────┐ ┌─────────┐ ┌──────────┐
│ 用户中心 │<----->│ 订单服务 │<----->│ 库存服务 │
└────────┘ └─────────┘ └──────────┘
↑
↓
┌────────────┐
│ 支付服务 │
└────────────┘
这次项目的重点是优化下单流程,支持跨区域多仓发货(也就是订单需要按商品拆单发往不同仓库),而库存服务也是按照仓库来独立部署的。
问题来了——在一个订单创建过程中,如果涉及多个仓库的商品库存减少,且其中一个库存扣减失败,整个下单应该回滚,否则就会造成超卖或者库存不一致的问题。
这时候我们就必须考虑如何在多个微服务之间保证一致性。于是,分布式事务正式摆在了面前。
面对的挑战

刚开始我们尝试用数据库层面的 XA 两阶段提交,结果发现性能奇差无比,尤其在秒杀场景下几乎不可用。接着又试了消息队列配合本地事务表的方式,但在异常处理和补偿机制上特别容易出错。
具体来说,我们面临几个关键挑战:
- 网络波动带来的不确定性:一次调用可能超时也可能部分成功,必须设计重试机制。
- 数据最终一致性:不能完全依赖强一致性,需要引入异步补偿机制。
- 幂等性与重复操作问题:由于重试机制的存在,同一个操作可能会被多次执行。
- 日志追踪困难:一次下单涉及多个服务,出了问题根本不知道哪一环挂掉了。
- 性能瓶颈:高并发场景下,任何中间环节的阻塞都会影响整体吞吐量。
我们选择的方案:TCC + Saga 的混合模式
综合各种因素,我们最后采用了 TCC(Try-Confirm-Cancel)模型作为主方案,同时结合 Saga 模式处理一些不适合 TCC 的场景。
TCC 模型简介(以库存为例)
- Try 阶段:预冻结库存,检查库存是否足够,预留资源但不变更实际库存;
- Confirm 阶段:确认扣除库存,释放资源;
- Cancel 阶段:取消操作,释放预冻结资源。
示例代码逻辑(伪代码):
// Try 阶段
public boolean tryReduceStock(Long productId, int quantity) {
// 检查是否有足够的可用库存
if (stockRepo.available(productId) < quantity) {
return false;
}
// 冻结部分库存
stockRepo.freeze(productId, quantity);
return true;
}
// Confirm 阶段
public void confirmReduceStock(Long productId, int quantity) {
// 扣除已冻结库存
stockRepo.deductFreeze(productId, quantity);
}
// Cancel 阶段
public void cancelReduceStock(Long productId, int quantity) {
// 释放冻结库存
stockRepo.releaseFreeze(productId, quantity);
}
通过这种方式,我们在服务之间实现了比较可靠的事务控制机制。
Saga 模式辅助处理非资源类事务
对于某些不涉及核心资源的操作,比如订单状态变更记录、发送短信或通知,我们采用了 Saga 模式进行异步补偿。
比如下单完成后向用户发送一条通知短信,若发送失败,则不会直接抛异常阻止整个流程,而是记录待发送任务,在后台定时补偿执行。
技术实现细节
为了支撑整套 TCC 流程,我们做了几层基础建设:
1. 事务协调器(Transaction Coordinator)
我们并没有自己从头写一个复杂的协调器,而是借助了一个内部轻量级框架 tcc-core,这个框架主要负责:
- 维护事务生命周期(开始、提交、回滚)
- 管理参与服务的注册与回调
- 日志追踪与异常捕获
大致调用过程如下:
OrderService 创建事务 --> 调用各子服务Try方法 --> Try全成功提交事务
↓
否则触发Cancel
2. 异常处理和补偿机制
针对各种失败场景,我们设定了不同的补偿策略:
- Try阶段失败:直接回滚所有已经完成的 Try 操作
- Confirm阶段失败:自动标记事务失败,交由定时任务处理
- Cancel阶段失败:异步补偿,避免卡住主线程
我们还开发了一个简单的补偿调度器,周期性拉取失败事务并尝试重试。
3. 幂等性保障
每个 Try / Confirm / Cancel 请求都带上唯一 transaction ID 和 operation ID,并在各服务中加缓存判断是否已经执行过。
例如:
if (redis.exists("op:" + opId)) {
log.warn("操作已执行,跳过");
return;
}
redis.setex("op:" + opId, 86400, "done");
这样防止因为网络重传等原因导致重复执行。
开发中踩到的坑
坑一:Cancel 回调没有及时执行,导致库存冻结未释放
我们曾经遇到一个问题:某个库存服务因为网络抖动未能接收到 Cancel 消息,结果大量库存被冻结无法使用。
解决方案:
- 引入一个库存冻结时间戳字段
- 添加定时任务每天凌晨清空所有超过 2 小时未处理的冻结项
-- 增加冻结时间字段
ALTER TABLE inventory_frozen ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
-- 清理脚本(伪SQL)
DELETE FROM inventory_frozen WHERE created_at < NOW() - INTERVAL '2 HOURS';
这招虽然粗糙,但在当时救急非常有效。
坑二:Cancel 方法本身失败导致死循环
有时候 Cancel 自身也会报错(如数据库连接异常、锁等待超时等),我们最初的处理方式是不断重试,结果造成了内存爆掉。
改进方案:
- 增加重试次数限制
- 对失败的 Cancel 存储到 DB 中供后续人工干预
- 使用 Redis 记录重试次数,防止无限重试
坑三:跨地域网络延迟引发超时连锁反应
我们部署的服务在不同城市机房,有一次北京调广州的服务时,出现了大面积 TCC Try 超时。
对策:
- 调低远程调用超时阈值,快速失败而不是长时间阻塞
- 接口增加熔断机制(Hystrix)
- 异地部署改为同城双活 + 跨城备份
上线后的效果与收益
上线后,我们观察了两周的数据:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 下单成功率 | 91.3% | 98.7% |
| 超卖率 | 0.42% | 0.03% |
| 日均处理订单 | 12w | 15w |
| 平均响应时间 | 230ms | 180ms |
最重要的是,我们终于可以放心应对大促期间的订单流量冲击了。
而且这套机制也为后期的退款、售后、多商户入驻等功能提供了良好的扩展基础。
一些经验和建议
如果你也在处理类似的分布式事务问题,这里是我总结的一些小建议:
1. 不要一开始就把目标定为“完美一致性”
在很多业务场景中,“最终一致性”才是更现实的选择。除非像金融转账这种绝对要求,否则没必要追求强一致性。
2. 强烈推荐加入事务日志
每一笔事务的执行过程都必须记录下来,包括参与服务、状态变化、时间戳,这对排查问题是救命稻草。
你可以简单理解为给你的事务加个“行车记录仪”。
3. 接口设计要面向失败而设计
不要想着所有请求都能成功,而要想着失败的时候怎么办。接口要具备幂等性、可重试性、错误码区分明确。
4. 监控 + 告警 = 心安
我们后来在 Grafana 上接入了事务状态变化的监控指标,设置关键节点告警,一旦出现大量 Cancel 或 Confirm 失败能第一时间感知。
5. 不要轻易自造轮子
很多人一上来就想自己写事务框架,其实现在开源生态已经很成熟了,比如 Seata、Atomikos、DTX 等,都可以参考甚至直接集成。当然前提是你得了解它们的原理。
结语
分布式事务不是银弹,也不是无解之题。它本质上是对业务复杂性和系统可靠性的妥协与平衡。
在这次项目中,我们走了不少弯路,但也积累了很多宝贵的经验。最重要的是,我深刻理解了一句话:
“在工程实践中,没有最好的方案,只有最合适的方案。”
希望你也能找到属于你们系统的那条路。
如果你有类似的经验,欢迎一起交流~

评论 0