分布式事务解决方案:我在真实项目中的落地实践
开篇:分布式系统中绕不开的痛

作为一个后端开发工程师,这几年我主要在一家电商公司做订单中心相关的架构和开发工作。随着业务规模的增长和微服务架构的普及,我们不可避免地遇到了一个经典问题:跨服务的数据一致性保障——也就是分布式事务的问题。
最早的时候,我们的订单创建流程只是一个单体应用,调用商品库存、用户账户余额和服务商配送API都在同一个本地事务中完成。一切都很简单。但随着业务拆分,这些功能被逐渐拆成了独立的服务模块:
- 订单服务
- 商品服务(负责库存)
- 用户服务(负责积分、余额)
- 配送服务
每个服务都有自己的数据库,各自为政。这时候,一个订单创建动作,就变成了需要横跨多个服务、多个数据库的操作。
于是问题来了:当其中某个操作失败时,怎么保证数据的一致性?
这篇文章就是结合我自己参与并主导的一个项目,来讲讲我是如何一步步解决这个问题的,以及在这个过程中踩过的坑和总结出的经验。
问题描述:订单流程中的“不一致”

我们当时遇到的核心问题是这样的:在创建订单的过程中,涉及到扣减库存、锁定用户余额、生成物流信息等多个步骤。如果某一步失败,比如库存不足或者余额不够,就需要整体回滚。否则就会出现数据不一致的情况。
举个具体场景:
- 用户下单购买商品A,数量是3。
- 订单服务发起请求:
- 商品服务:扣减3件库存 ✅
- 用户服务:扣除50元余额 ❌失败(比如网络异常)
此时,库存已经减少,但用户的余额没有扣除,这显然不合理。
那怎么处理这种跨服务的“事务”?最开始我们尝试了一些临时方案,比如通过消息队列补偿机制来“事后兜底”,但效果并不理想,尤其是在一些高并发的促销场景下,经常出现数据状态对不上的情况。
于是我们决定必须引入一套更规范、可靠的分布式事务解决方案。
解决方案选型与设计思路

第一阶段:调研主流方案
我们在技术团队内进行了一轮内部讨论,对比了市面上常见的几种方案:
| 方案 | 特点 | 适用场景 |
|---|---|---|
| 二阶段提交(2PC) | 强一致性,依赖协调者,容易阻塞 | 对一致性要求极高,可接受一定性能损失 |
| TCC(Try-Confirm-Cancel) | 柔性事务,需要手动实现三个接口 | 要求较高,适合金融类交易场景 |
| SAGA模式 | 异常回滚机制,适合长周期操作 | 流程清晰、支持异步操作 |
| Seata框架(AT模式) | 基于数据库 undo log 实现,对业务透明 | 中等一致性要求,适合互联网场景 |
最终我们选择了 Seata + Saga 组合模式 的方式,原因如下:
- 我们的系统是基于 Spring Cloud Alibaba 构建的,而 Seata 是该生态的官方推荐组件。
- AT模式对业务代码侵入较小,能快速上线验证。
- 后期考虑接入 TCC 或 Saga 来应对某些核心链路。
技术架构设计
我们整个订单流程涉及四个服务,统一部署在一个 Kubernetes 集群中,各个服务之间通过 OpenFeign + Nacos 进行通信,同时使用 RocketMQ 做事件驱动通知。
我们将订单中心作为全局事务入口,并使用 Seata 的 GlobalTransactional 注解来管理整个下单流程。
大致流程如下:
@GlobalTransactional
public void createOrder(CreateOrderRequest request) {
// 调用商品服务扣库存
productClient.decreaseStock(request.getProductId(), request.getProductCount());
// 调用用户服务扣余额
userClient.deductBalance(request.getUserId(), request.getTotalPrice());
// 创建订单主表
orderRepository.save(order);
}
只要在任意一个远程调用抛出异常,Seata 就会自动触发回滚逻辑,依次撤销之前成功的远程操作(例如回补库存或退还余额)。
当然,实际开发中并不是照搬示例这么简单,后面会详细说我们遇到的一些坑。
代码实践:关键片段展示

下面是我们订单服务中一个典型的事务边界定义,这里只贴出部分伪代码用于说明:
主方法标注 @GlobalTransactional
@Transactional
@GlobalTransactional(timeoutMills = 60000, name = "create_order_tx")
public String createOrder(Long userId, Long productId, int count) throws Exception {
Product product = productClient.getProduct(productId);
BigDecimal totalPrice = product.getPrice().multiply(BigDecimal.valueOf(count));
// 扣库存
boolean stockResult = productClient.deductStock(productId, count);
if (!stockResult) {
throw new RuntimeException("库存不足");
}
// 扣余额
boolean balanceResult = userClient.deductBalance(userId, totalPrice);
if (!balanceResult) {
throw new RuntimeException("余额不足");
}
// 创建订单实体
Order order = buildOrderEntity(userId, productId, count, totalPrice);
orderMapper.insert(order);
return order.getOrderNo();
}
商品服务的 Feign 客户端声明事务传播
@FeignClient(name = "product-service", configuration = FeignConfig.class)
public interface ProductClient {
@PostMapping("/stock/deduct")
@Transactional(propagation = Propagation.REQUIRES_NEW)
boolean deductStock(@RequestParam("productId") Long productId,
@RequestParam("count") Integer count);
}
⚠️ 注意:Feign 默认不传递事务上下文,所以要在 FeignClient 上配置拦截器,将 XID(事务ID)透传到下游服务中。
数据库层面配置 undo_log 表
Seata AT 模式会在每个数据库中自动生成一个 undo_log 表,用来记录本地事务执行前后快照,用于后续回滚。
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
这个表非常关键,是 AT 模式的核心所在,一旦丢失或者损坏,可能导致数据无法正确回滚。
踩坑经验:那些只有自己摔过才知道的事
坑一:Feign 调用默认不携带 XID
刚开始我们直接用 Feign 做远程调用,发现事务根本没生效。后来查日志才发现,XID 没有传递过去。这会导致各个服务都不知道这是一个分布式事务操作。
解决方案:
写了一个 Feign 拦截器,把当前线程中的 XID 放进 HTTP 请求头里:
@Component
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String xid = RootContext.getXID();
if (StringUtils.isNotBlank(xid)) {
template.header("XID", xid);
}
}
}
然后在每个服务的拦截器中取出这个 header 并设置回 RootContext 中。
坑二:事务超时导致死锁
我们曾经有一次在大促期间大量订单卡住,排查下来发现是事务等待太久,Seata 在做全局提交/回滚时卡住了。
后来加了全局事务的 timeout 配置,并设置 fallback 回退策略:
seata:
client:
async-committing-retry-limit: 3
lock:
retry-internal: 10
retry-times: 30
此外,也优化了本地 SQL,尽量避免长事务,比如不要一次性更新太多数据,而是分批次处理。
坑三:事务日志文件过大,影响性能
随着数据量上涨,我们发现事务日志增长很快,undo_log 表体积庞大,MySQL 磁盘 IO 突然飙升。
最终我们做了两件事:
- 给 undo_log 加上定期清理定时任务;
- 引入 Kafka + BinLog 捕获机制做日志异步归档。
效果总结:从“不可控”到“稳定可控”
这套方案上线后,我们做了几轮压测和实际场景验证,结果如下:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 订单创建失败率 | ~2%(主要是数据不一致) | <0.1% |
| 事务回滚准确率 | 不足70% | >99.5% |
| 日均事务数 | 不超过1万次 | 支撑到10万+次 |
| 系统响应延迟 | P99 3秒左右 | P99 1.2秒以内 |
更重要的是,我们在一次大型促销活动中,面对瞬时每秒数百单的流量冲击,系统表现得非常稳定。
经验分享:给正在走这条路的你几点建议
如果你也在考虑引入分布式事务方案,这里有几条我亲身体会的建议:
先做数据隔离和幂等设计
在引入任何分布式事务框架之前,务必确保你的业务逻辑是幂等的。这样即使重试或回滚,也不会造成重复操作。优先选择低侵入方案,逐步过渡
Seata 的 AT 模式就是一个不错的选择。它不需要你写 Cancel 方法,也不影响原有代码结构。先让它跑起来,再根据业务需求升级到 Saga 或 TCC。做好监控和回滚兜底机制
分布式事务不可能完全无损,一定要配合消息队列、补偿任务等机制来做兜底方案。注意事务边界控制,不要贪大求全
不要一股脑把所有操作都放在一个事务中。能拆则拆,越小越易维护。必要时可以采用最终一致性+定时核对的方式来降低复杂度。多做故障演练,别等到线上炸了才想起预案
我们团队现在定期都会做事务断网测试、数据库锁表模拟等故障演练,提前发现问题并修复。
结尾:技术不是万能的,但合理的工程设计是
回顾这段旅程,我觉得最有价值的不是用了哪个框架,也不是解决了什么 bug,而是我们建立了一套合理的数据一致性控制机制。
技术终归是服务于业务的。分布式事务不是银弹,但它能在一定程度上为我们保驾护航。
希望这篇分享能帮你少踩几个坑,早点走上稳定之路。如果有相关问题,欢迎留言交流!
作者:一名爱折腾、爱实战的后端程序员,目前专注于高性能电商业务系统架构设计。如果你也有类似的分布式难题,我们可以一起聊聊!

评论 0