分布式事务解决方案:我在真实项目中的落地实践

凉薄少年
2025-06-16 23:26
阅读 900

开篇:分布式系统中绕不开的痛

开篇:分布式系统中绕不开的痛

作为一个后端开发工程师,这几年我主要在一家电商公司做订单中心相关的架构和开发工作。随着业务规模的增长和微服务架构的普及,我们不可避免地遇到了一个经典问题:跨服务的数据一致性保障——也就是分布式事务的问题。

最早的时候,我们的订单创建流程只是一个单体应用,调用商品库存、用户账户余额和服务商配送API都在同一个本地事务中完成。一切都很简单。但随着业务拆分,这些功能被逐渐拆成了独立的服务模块:

  • 订单服务
  • 商品服务(负责库存)
  • 用户服务(负责积分、余额)
  • 配送服务

每个服务都有自己的数据库,各自为政。这时候,一个订单创建动作,就变成了需要横跨多个服务、多个数据库的操作。

于是问题来了:当其中某个操作失败时,怎么保证数据的一致性?

这篇文章就是结合我自己参与并主导的一个项目,来讲讲我是如何一步步解决这个问题的,以及在这个过程中踩过的坑和总结出的经验。


问题描述:订单流程中的“不一致”

问题描述:订单流程中的“不一致”

我们当时遇到的核心问题是这样的:在创建订单的过程中,涉及到扣减库存、锁定用户余额、生成物流信息等多个步骤。如果某一步失败,比如库存不足或者余额不够,就需要整体回滚。否则就会出现数据不一致的情况。

举个具体场景:

  1. 用户下单购买商品A,数量是3。
  2. 订单服务发起请求:
    • 商品服务:扣减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 突然飙升。

最终我们做了两件事:

  1. 给 undo_log 加上定期清理定时任务;
  2. 引入 Kafka + BinLog 捕获机制做日志异步归档。

效果总结:从“不可控”到“稳定可控”

这套方案上线后,我们做了几轮压测和实际场景验证,结果如下:

指标 上线前 上线后
订单创建失败率 ~2%(主要是数据不一致) <0.1%
事务回滚准确率 不足70% >99.5%
日均事务数 不超过1万次 支撑到10万+次
系统响应延迟 P99 3秒左右 P99 1.2秒以内

更重要的是,我们在一次大型促销活动中,面对瞬时每秒数百单的流量冲击,系统表现得非常稳定。


经验分享:给正在走这条路的你几点建议

如果你也在考虑引入分布式事务方案,这里有几条我亲身体会的建议:

  1. 先做数据隔离和幂等设计
    在引入任何分布式事务框架之前,务必确保你的业务逻辑是幂等的。这样即使重试或回滚,也不会造成重复操作。

  2. 优先选择低侵入方案,逐步过渡
    Seata 的 AT 模式就是一个不错的选择。它不需要你写 Cancel 方法,也不影响原有代码结构。先让它跑起来,再根据业务需求升级到 Saga 或 TCC。

  3. 做好监控和回滚兜底机制
    分布式事务不可能完全无损,一定要配合消息队列、补偿任务等机制来做兜底方案。

  4. 注意事务边界控制,不要贪大求全
    不要一股脑把所有操作都放在一个事务中。能拆则拆,越小越易维护。必要时可以采用最终一致性+定时核对的方式来降低复杂度。

  5. 多做故障演练,别等到线上炸了才想起预案
    我们团队现在定期都会做事务断网测试、数据库锁表模拟等故障演练,提前发现问题并修复。


结尾:技术不是万能的,但合理的工程设计是

回顾这段旅程,我觉得最有价值的不是用了哪个框架,也不是解决了什么 bug,而是我们建立了一套合理的数据一致性控制机制。

技术终归是服务于业务的。分布式事务不是银弹,但它能在一定程度上为我们保驾护航。

希望这篇分享能帮你少踩几个坑,早点走上稳定之路。如果有相关问题,欢迎留言交流!


作者:一名爱折腾、爱实战的后端程序员,目前专注于高性能电商业务系统架构设计。如果你也有类似的分布式难题,我们可以一起聊聊!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝