分布式事务解决方案:一个老开发的真实踩坑经历
引言:分布式系统带来的“甜蜜烦恼”

作为一名在后端摸爬滚打多年的老兵,我经历过单体架构的简单粗暴,也见证了微服务架构的崛起和普及。如今再回到大型系统中,最大的挑战之一就是——分布式事务。
记得几年前,我在一家金融科技公司负责交易系统的重构,核心业务是用户的资金变动、积分兑换以及订单结算等。为了提升系统的可维护性和扩展性,我们采用了Spring Cloud + Dubbo的微服务架构,各个服务通过RPC调用互相协作完成一次完整的业务流程。
然而正是这种松耦合的设计,带来了不少头疼的问题,尤其是在数据一致性方面——比如用户下单的时候,库存服务扣减库存成功,但订单服务保存订单失败,或者支付服务处理完付款,积分服务却挂了……
这个时候,你就会明白什么叫:分布式事务不解决,系统就离出事不远了。
这篇文章就来聊聊我是如何一步步解决这个问题的,从最初的妥协方案到最终落地的可靠机制,过程充满挣扎与教训,希望能给正在这条路上的你一些启发。
项目背景:一场高并发下的数据一致性危机

我们的系统是典型的电商类金融平台,主要功能包括:
- 用户下单(订单服务)
- 扣减库存(库存服务)
- 支付接口调用(支付服务)
- 积分变更(积分服务)
这些服务部署在不同的节点上,使用的是 Spring Boot + Dubbo 构建的 RPC 调用链路。
一开始我们为了追求上线速度,采用了最简单的“伪本地事务”方式:每个服务内部加事务,外层靠try-catch重试兜底。例如:
// 简化后的伪代码示例
public void placeOrder(OrderDTO order) {
try {
orderService.createOrder(order);
inventoryService.decreaseStock(order.getSkuId(), order.getCount());
paymentService.chargeUser(order.getUserId(), order.getAmount());
pointService.addPoint(order.getUserId(), order.getPoints());
} catch (Exception e) {
// 出现异常,只能记录日志,人工介入补偿
}
}
这看似能处理大部分情况,但在某次促销活动中,问题集中爆发:有些订单创建失败但积分已经增加、库存被扣减但订单未生成……一时间运营团队焦头烂额,投诉电话不断。
我们这才意识到:必须引入一套完整的分布式事务机制,才能真正保证一致性。
挑战:不是没技术选型,而是太多选择让人迷茫

当时摆在我们面前的选择其实并不少,但每种都带着它的“副作用”:
2PC/XA方案(Seata)
- 优点:强一致性,支持回滚
- 缺点:性能差,锁资源,容易成为瓶颈;对数据库版本、驱动兼容要求高
TCC(Try-Confirm-Cancel)
- 优点:灵活控制资源锁定、适用于复杂业务场景
- 缺点:需要为每一个服务编写三个阶段的方法,业务逻辑变得复杂,实现成本高
SAGA模式
- 优点:无需全局锁,适合长事务
- 缺点:补偿逻辑复杂,一旦某个服务无法撤销操作就可能出错
本地事务表+消息队列(基于MQ异步补偿)
- 优点:解耦性强、吞吐量高
- 缺点:不能实时保证一致性,延迟补偿可能引起状态混乱
Event Sourcing / CQRS
- 不是我们当时的首选方案,复杂度太高,学习曲线陡峭
我们尝试过几种方式,最后结合自身业务特点,选择了基于 TCC 模式的自研框架 + 最终一致性校验机制。
解决思路:TCC 的实践之路

初识 TCC
TCC 的核心思想可以理解为三个步骤:
- Try 阶段:业务资源检查 & 锁定(如预扣库存)
- Confirm 阶段:执行真正的业务操作(如正式扣库存、生成订单)
- Cancel 阶段:释放已锁定的资源(如还原库存)
TCC 要求每一个服务都提供这三个方法,并且整个协调由“事务协调器”来掌控。
我们最初考虑用开源的 TCC 框架,比如 Hmily、ByteTCC 等,但由于业务较为复杂,定制化较高,最终决定基于 Spring AOP 自己写一套简化版的 TCC 流程引擎。
设计目标
- 实现基本的 Try-Confirm-Cancel 三段式流程
- 支持事务ID透传,方便日志追踪
- 事务日志持久化,用于后续补偿或核对
- 支持手动/自动补偿机制
整体结构
[Transaction Coordinator]
|
v
[Try 阶段] -> [各服务Try方法]
|
v
[Confirm or Cancel] -> 根据Try结果决定是否执行 Confirm 或 Cancel
|
v
[事务落库 & 补偿队列入队]
我们设计了一张事务日志表,用来跟踪每个事务的状态:
CREATE TABLE tcc_transaction_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
transaction_id VARCHAR(64) NOT NULL,
service_name VARCHAR(100),
status ENUM('TRYING', 'CONFIRMED', 'CANCELLED'),
create_time DATETIME,
update_time DATETIME
);
每当一个事务开始,我们就记录一条 TRYING 记录;执行完成后更新状态;如果某一步失败,则触发 Cancel 并记录取消动作。
示例:订单服务中的 Try 方法
@Transactional
public boolean tryCreateOrder(OrderDTO dto) {
Order order = new Order();
order.setUserId(dto.getUserId());
order.setStatus("CREATED");
orderMapper.insert(order);
// 注册事务事件日志
logService.recordTry(transactionId, "order-service", true);
return true;
}
其他服务类似,比如库存服务则是在 Try 阶段“冻结”库存,而不是直接扣除。
关键代码片段分享
这里给出几个关键部分的实现,供参考。
事务上下文管理器(简化版)
@Component
public class TccContextManager {
private ThreadLocal<String> context = new ThreadLocal<>();
public void set(String txId) {
context.set(txId);
}
public String get() {
return context.get();
}
public void clear() {
context.remove();
}
}
AOP拦截器控制事务流程
@Aspect
@Component
public class TccTransactionAspect {
@Autowired
private TransactionCoordinator coordinator;
@Around("@annotation(tcc)")
public Object handleTcc(ProceedingJoinPoint pjp, Tcc tcc) throws Throwable {
String txId = UUID.randomUUID().toString();
TccContextHolder.setTxId(txId);
try {
// 执行 Try 阶段
Object result = pjp.proceed();
// 执行 Confirm
if (!(boolean) result) throw new RuntimeException("Try failed");
coordinator.confirm(txId);
return result;
} catch (Throwable e) {
// 执行 Cancel
coordinator.cancel(txId);
throw e;
} finally {
TccContextHolder.clear();
}
}
}
事务协调器主流程
public class TransactionCoordinator {
public void confirm(String txId) {
List<TransactionLog> logs = logService.findByTxId(txId);
for (TransactionLog log : logs) {
ServiceInvoker.invokeConfirm(log.getServiceName(), txId);
}
}
public void cancel(String txId) {
List<TransactionLog> logs = logService.findByTxId(txId);
for (TransactionLog log : logs) {
ServiceInvoker.invokeCancel(log.getServiceName(), txId);
}
}
}
当然,这只是简化模型,真实项目中还需要考虑幂等、重试、补偿任务调度等问题。
踩过的那些坑:都是血泪教训
坑一:Cancel 失败怎么办?
我们在测试阶段发现,Cancel 方法有时候会抛异常,甚至服务不可用,导致事务状态处于一种“中间态”。为此,我们引入了一个定时补偿任务,每天凌晨跑一遍所有“悬而未决”的事务日志,发起 Cancel 补偿。
坑二:TCC 的幂等问题
由于网络原因,同一个 Confirm/Cancle 请求可能会重复到达。如果不做幂等判断,会导致库存多扣、积分多加等问题。因此我们在每个服务中都加了幂等校验:
@Override
public void confirmInventory(String txId) {
if (redisTemplate.hasKey("confirm:" + txId)) {
return; // 已经执行过
}
// 正式扣减库存
inventoryMapper.reduceStockByTxId(txId);
redisTemplate.opsForValue().set("confirm:" + txId, "done", 1, TimeUnit.DAYS);
}
坑三:跨服务事务 ID 传递
原本我们通过 ThreadLocal 保存事务 ID,但在 Dubbo 调用中线程切换会导致丢失,后来改用 RpcContext 来进行参数透传:
RpcContext.getContext().setAttachment("tx_id", txId);
并在消费者端拦截器中读取这个值并设置进 ThreadLocal。
坑四:性能瓶颈明显
尽管 TCC 性能比 2PC 强很多,但在高峰时期还是会拖慢整体响应。于是我们做了以下优化:
- Try 阶段尽量轻量级操作,不做复杂查询或写入
- 尽可能合并多个服务的调用为批量操作
- 最终一致性下放至 MQ 或定时任务处理
效果总结:终于稳住了
自从上线这套基于 TCC 的分布式事务机制后,系统稳定性大大提升,具体收益如下:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 交易失败率 | 1.2% | < 0.05% |
| 人工补单次数 | 每天几十次 | 接近零 |
| 数据一致性故障 | 每周数起 | 几乎没有 |
虽然开发成本上升了不少,但换来的是系统的健壮性和运维的省心,尤其是节假日大促时再也不怕流量洪峰了。
经验分享:过来人的忠告
如果你也在考虑引入分布式事务,请记住以下几点:
1. 不要盲目迷信理论框架
分布式事务没有银弹。不要一听别人说“XA 是强一致”,就一头扎进去。实际应用中,你需要结合业务场景来做权衡。比如金融转账更适合 TCC,而日志写入这类操作更适合最终一致性。
2. 先做好基础设施建设
- 统一事务ID传递机制
- 完善的日志追踪体系
- 事务补偿机制
- 幂等支持
否则你永远都无法定位到底哪一个环节出错了。
3. 把一致性分级对待
并不是所有业务都要求严格的一致性。比如:
- 下单、支付这些关键路径要用强一致性
- 日志记录、数据分析这些可以用异步最终一致性搞定
合理地将业务分层,能有效降低系统复杂度。
4. 写好 Cancel 是最难的部分
很多时候我们都能把 Confirm 写清楚,但 Cancel 反而成了最不确定的环节。建议在业务设计初期就要考虑“反向操作”的可行性。
5. 补偿机制要作为标配存在
无论是定时任务还是消息队列,一定要有一套兜底机制。就像我之前做的那样,每天扫一遍事务日志,及时修复脏数据。
6. 监控必不可少
我们后来接入了 Prometheus + Grafana,监控事务成功率、Cancel 次数、悬挂事务数量等指标,做到心中有数。
写在最后:分布式事务这条路,没人能绕过去
说实话,写这篇文章的时候我也在回忆当初那个焦头烂额的自己。那个时候真想放弃,觉得不如重回单体世界算了。但现在回想起来,正是那段时间的折腾,让我真正理解了什么叫“系统一致性”,什么叫“工程思维”。
分布式事务从来不是一件轻松的事,但它却是迈向复杂系统必经的一道坎。希望今天的分享能帮助你少走弯路,走得更远。
如果你在实践中遇到任何疑问,欢迎留言交流。毕竟这条路,我们都在走着。😊
本文作者:一名坚持码农本色的全栈工程师,专注高并发系统设计与落地实战。
个人博客:https://www.jianshu.com/u/yourprofile(如有)
GitHub:https://github.com/yourusername(如有)
技术群组:欢迎关注我的公众号【TechMaster】,一起探讨一线开发经验与行业趋势。

评论 0