分布式事务解决方案:从踩坑到落地的最佳实践
分布式系统发展到今天,已经成为大型后端架构的标配。而“分布式事务”这个问题,也几乎是所有中大型互联网公司都会面临的难题。我自己从业五六年,经历过多个高并发、多服务的项目,其中有一段和分布式事务斗争的日子让我至今记忆犹新。
我想借这篇文章,把我在实际项目中遇到的挑战、用过的方案、踩过的坑,以及最后落地方案的经验,都拿出来跟大家聊聊。希望能给正在做相关架构选型或正被这类问题困扰的朋友们一点参考。
一、项目背景:一次电商系统的重构之旅

2022年初,我所在的团队负责一个电商平台的后台服务重构。原来的系统是单体结构,所有交易流程、库存管理、订单履约逻辑都集中在一个 Java Web 应用里。随着业务发展,这种架构已经无法支撑高并发的需求,于是我们决定采用微服务架构进行拆分。
拆出来的几个核心服务分别是:
- 订单服务(Order Service)
- 支付服务(Payment Service)
- 库存服务(Inventory Service)
原本在单体系统中通过本地数据库事务就能搞定的流程——比如“下单时扣库存、冻结余额”,现在却变得复杂了。这三个服务各自独立部署、使用不同的数据库,任何一步失败都需要保证整个操作回滚。
这就是典型的跨服务、跨数据库的事务场景,也就是我们常说的分布式事务问题。
二、问题描述:事务失效与数据一致性挑战

上线初期,我们采用了最简单的做法:即每个服务调用自己的数据库事务,不考虑全局一致性。
举个例子:用户下单时,先调用订单服务创建订单,再调用库存服务减少库存。如果这两个服务之间没有统一协调,一旦库存服务调用失败,订单已经写入数据库,就出现了“已下单但未扣库存”的问题。
更糟糕的情况出现在支付回调环节。假设订单已经完成支付,但支付服务通知订单服务更新状态时发生网络故障,导致订单状态未同步。这时前端显示已付款,但后端还没确认——这是典型的数据不一致问题。
我们开始频繁收到用户的投诉:“钱收了货没发”、“订单状态异常”等等。线上日志中也出现大量状态不一致的日志记录,运维同事天天半夜爬起来手动修复数据,压力山大。
显然,不能再靠人工来兜底了,必须引入一套可靠的分布式事务机制。
三、解决方案选型:为什么选择了Seata?

我们调研了目前市面上常见的几种分布式事务方案,包括:
- 两阶段提交(2PC):强一致性,但性能差,而且存在单点故障风险。
- TCC(Try-Confirm-Cancel):实现灵活、对性能影响小,但开发成本高,需要自己处理补偿逻辑。
- Saga模式:异步长事务,适合某些长时间流程,但失败恢复复杂。
- 消息队列 + 最终一致性:依赖MQ的事务消息,实现最终一致性,但实时性低。
- Seata框架(基于AT模式):支持自动化的分布式事务管理,兼容性强,性能尚可,社区活跃。
我们最终选择了 Seata 的 AT 模式作为主要技术方案,原因如下:
- 侵入性小:只需要引入 starter 和配置即可启用。
- 兼容性好:支持主流 ORM 和数据库。
- 自动化回滚:不需要自己编写 Cancel 方法,Seata 自动记录 undo_log 来回滚事务。
- 性能可接受:相比 TCC 或 2PC,对性能影响较小。
Seata 的 AT 模式原理简单来说就是借助全局事务 ID(XID),通过拦截 SQL 并记录 undo 日志,实现跨服务的数据一致性。
四、代码实践:如何集成Seata并保障事务一致性?

我们以“下单 + 扣库存 + 支付”这个流程为例来看看如何实现。
1. 引入 Seata Starter
<!-- pom.xml 中添加 -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.6.1</version>
</dependency>
2. 配置 Seata 客户端
seata:
enabled: true
application-id: order-service
tx-service-group: my_test_tx_group
service:
vgroup-mapping:
my_test_tx_group: default
grouplist:
default: 127.0.0.1:8091
config:
type: file
registry:
type: file

3. 在订单服务中使用全局事务注解
@GlobalTransactional
public void placeOrder(OrderDTO orderDTO) {
// 创建订单
orderRepository.save(order);
// 调用库存服务扣减库存
inventoryClient.decreaseStock(orderDTO.getProductId(), orderDTO.getCount());
// 调用支付服务预授权
paymentClient.preauthorize(order.getUserId(), order.getAmount());
}
只要加上 @GlobalTransactional 注解,Seata 就会自动为这个方法开启一个全局事务,并将 XID 透传到下游服务中。
下游服务也需要引入 Seata Client,这样他们就能感知到全局事务的存在。
五、踩坑经验:那些年我们一起走过的弯路
虽然 Seata 方案看起来很完美,但在生产环境落地过程中,我们也遇到了不少坑。
坑1:数据库连接池不够用了
由于 Seata 是通过拦截 SQL 实现的,它会在每次执行 SQL 时插入一些额外的事务控制信息。这会导致每个数据库操作持有连接的时间变长,特别是在并发量高的时候,连接池经常被打满。
解决办法:
- 调整连接池大小,建议使用 HikariCP,并设置合理的 maxPoolSize。
- 设置合适的事务超时时间,默认是 60s,可根据业务调整。
spring:
datasource:
hikari:
maximum-pool-size: 50
坑2:undo_log 表膨胀严重
Seata 会在每个事务库中自动生成一张 undo_log 表,用来记录事务前后的数据变化。如果事务频繁或者表结构较大,这张表会快速膨胀,占用大量磁盘空间。
解决办法:
- 定期清理
undo_log表中的历史数据。 - 可配合定时任务删除超过一定时间的历史 undo 数据(例如超过 3 天)。
DELETE FROM undo_log WHERE log_created <= NOW() - INTERVAL 3 DAY;
坑3:服务间调用链路丢失 XID
我们在一开始使用 Feign 调用时发现,下游服务有时拿不到正确的 XID,导致事务断裂。后来排查发现,是因为 Feign 默认没有将请求头中的 XID 传递过去。
解决办法:
实现一个 FeignInterceptor 把 XID 加到请求头上:
@Component
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String xid = RootContext.getXID();
if (StringUtils.isNotBlank(xid)) {
template.header("XID", xid);
}
}
}
同时,下游服务也要有对应的 Filter 来注入上下文:
@Configuration
public class SeataConfig {
@Bean
public FilterRegistrationBean<WebServerFilter> seataFilter() {
FilterRegistrationBean<WebServerFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new WebServerFilter());
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
static class WebServerFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String xid = httpRequest.getHeader("XID");
if (StringUtils.isNotBlank(xid)) {
RootContext.bind(xid);
}
try {
chain.doFilter(request, response);
} finally {
RootContext.unbind();
}
}
}
}
六、效果总结:上线后系统稳定性大大提升
自从全面引入 Seata 后,我们的分布式事务问题基本得到了缓解。具体体现在:
- 数据一致性得到保障:几乎不再出现“下单成功但未扣库存”这类问题。
- 运维工作量显著下降:以前每晚都要人盯着数据是否一致,现在可以安心睡觉了。
- 排查效率提高:Seata 提供了丰富的日志追踪能力,能快速定位事务卡在哪里。
当然,代价是稍微牺牲了一点性能。但由于我们优化了连接池和事务粒度,整体延迟控制在可接受范围内。
七、我的经验分享:几点建议送给各位同行
结合这几年的经历,我想给大家几点实用性的建议:
1. 不要盲目追求“强一致性”
很多时候,“最终一致性” + 重试补偿机制比强一致性更容易实现且性能更好。尤其是一些非关键路径上的事务,比如日志记录、积分更新等,完全可以接受短暂的不一致。
2. 控制事务边界和粒度
不要在一个事务里包含太多逻辑,否则很容易造成事务过长、锁等待、甚至死锁。建议按照业务划分清晰的事务边界,必要时可拆分为多个小事务配合补偿机制。
3. 重视监控和报警体系
分布式事务一旦出问题,往往很难定位。建议搭建完善的日志聚合平台(如 ELK)和事务追踪系统(如 SkyWalking),并且设定关键指标的监控报警,比如事务失败率、平均耗时、阻塞线程数等。
4. Seata 的 AT 模式不是万能的
它非常适合那种数据库变更为主的场景,但如果涉及到第三方系统或非关系型数据库,就可能不太适用了。这时候要考虑引入其他机制(如 TCC + 人工补偿)来做补充。
5. 生产环境务必做好压测和灰度发布
我们在真实压测之前,低估了 Seata 对资源的消耗。结果在线上跑了一波大促,差点因为连接池打满拖垮整个服务。后来改完配置后才稳住。
所以建议上线前:
- 使用 JMeter / LoadRunner 进行全链路压测;
- 灰度发布,先让一部分流量走 Seata;
- 实时监控,随时准备降级。
写在最后:技术是手段,理解业务才是根本
回顾这段经历,其实最大的收获不是学会了怎么用 Seata,而是明白了:所有的技术方案都是为了解决实际业务问题,脱离业务谈技术永远是空中楼阁。
分布式事务只是一个工具,背后反映的是我们对系统复杂性的理解和把控。当你真正理解了一个订单背后的完整生命周期、各个模块之间的依赖关系之后,才能做出更合理的技术选型和设计决策。
希望这篇实战分享能够对你有所帮助。如果你也在做类似的改造,欢迎留言交流,一起探讨更多落地细节。

评论 0