Home
avatar
bg

CatOi

Seata 分布式事务在支付链路的落地:从 AT 模式原理到 99.2% 支付成功率

在搜索动力负责电商微服务平台期间,我做的最核心的一块就是支付链路的数据一致性:账户服务扣余额、商品服务冻结库存、订单服务落库,三个服务三个数据源,任何一步失败都必须整体回滚。这篇文章把 Seata AT 模式的原理、我们在生产里踩过的坑、以及调优到 99.2% 支付成功率的完整过程整理出来。

一、为什么本地事务救不了支付链路

单体时代一个 @Transactional 就能解决的问题,拆成微服务之后变成了三个独立的数据源事务。以我们最典型的一笔下单为例:

// 订单服务(伪代码)
@GlobalTransactional
public OrderResult createOrder(OrderRequest req) {
    // 1. 远程调用账户服务:冻结余额
    accountClient.freeze(req.getUserId(), req.getAmount());
    // 2. 远程调用商品服务:冻结库存
    stockClient.freeze(req.getSkuId(), req.getQuantity());
    // 3. 本地落库:订单主表 + 流水表
    orderMapper.insert(order);
    orderItemMapper.batchInsert(order.getItems());
    // 4. 远程调用支付网关发起扣款
    payClient.deduct(req.getPayToken());
}

四个步骤分属三个数据源。如果第 2 步库存不足抛异常,第 1 步已经冻结的余额必须退回去;如果第 4 步支付超时,前三步全部要回滚。任何本地事务注解都管不到别的服务的数据库,这就是分布式事务要解决的问题。

分布式事务的方案很多:2PC/XA(强一致但性能差)、TCC(性能好但侵入性强)、基于消息的最终一致(适合异步场景)、Saga(长事务)。支付这种强一致 + 高频 + 链路短的场景,我们最终选了 Seata 的 AT 模式——它是 2PC 的改良版,对业务代码几乎零侵入。

二、AT 模式的源码级原理

AT(Auto Transaction)模式的核心思想一句话:一阶段把业务数据和回滚日志在同一个本地事务里提交,二阶段异步删日志或按日志补偿回滚。

2.1 一阶段:代理数据源做了什么

Seata 不会让你的 SQL 直接落到数据库。它通过 DataSourceProxy 包装你的数据源,所有 SQL 先经过解析:

业务 SQL(如 update account set balance = balance - 100 where id = 1)
    ↓
① 查询前镜像(beforeImage):select * from account where id = 1
    ↓
② 执行业务 SQL
    ↓
③ 查询后镜像(afterImage):再查一次改后的数据
    ↓
④ 把前后镜像 + 业务 SQL 组装成 undo_log,
   与业务 SQL 在【同一个本地事务】里提交
    ↓
⑤ 向 TC(事务协调器)注册分支事务、申请全局锁

关键点在④:业务数据和 undo_log 是一个本地事务。这意味着一阶段提交后,数据变更就已经对本地可见了(所以 AT 不是隔离的,后面讲全局锁时再回到这一点)。

2.2 二阶段:提交与回滚

  • 全局事务成功:TC 通知各分支异步删除 undo_log(批量删除,很快)。
  • 全局事务回滚:分支根据 undo_log 里的前后镜像生成反向 SQL 补偿:
-- 反向 SQL 不是简单"恢复前镜像",而是带校验的补偿更新
update account set balance = {beforeImage.balance}
where id = 1 and balance = {afterImage.balance}

注意 where 里的校验条件:只有当前值还等于后镜像时才回滚。如果这条数据在全局事务期间被别的事务改过(说明发生了脏写),补偿会失败并告警——这是 AT 模式放弃隔离性换性能的代价,也是下面全局锁存在的原因。

2.3 全局锁:防脏写的核心机制

AT 模式一阶段就提交了本地事务,两个全局事务同时改同一行怎么办?Seata 用全局锁解决:分支事务在提交前必须拿到 TC 上这条数据行的全局锁,拿不到就重试(默认 30 次 × 10ms),超时则本地事务回滚。

正因为有全局锁,undo_log 校验失败(脏写)在正常业务里几乎不会发生——除非有人绕过 Seata 直接改库。所以我们生产规范里有一条硬性要求:参与全局事务的表,所有写入口必须走 Seata 数据源。

三、OpenFeign 链路上的 XID 传播

AT 模式要求全局事务的上下文(XID)跨服务传播。Seata 对 OpenFeign 做了自动集成,但有几个容易忽略的点:

# 1. 每个参与方的配置
seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: vh_pay_tx_group
  service:
    vgroup-mapping:
      vh_pay_tx_group: default
  data-source-proxy-mode: AT

坑 1:自定义 Feign 拦截器会丢 XID。我们项目里有统一给请求加鉴权头的 Feign 拦截器,重建了 request template 但没透传 Seata 的 TX_XID header,导致下游服务感知不到全局事务、各自为政。排查方式很简单:在下游分支的 undo_log 里看 xid 字段是不是同一个,不是就是传播断了。

坑 2:异步线程要手动传播。@GlobalTransactional 的上下文存在 ThreadLocal 里,@Async 或线程池里的远程调用拿不到 XID,需要用 RootContext.bind(xid) 手动绑定:

String xid = RootContext.getXID(); // 主线程先取出
executor.submit(() -> {
    RootContext.bind(xid);        // 子线程绑定
    try {
        stockClient.freeze(...);
    } finally {
        RootContext.unbind();
    }
});

四、生产级的三大经典问题:空回滚、幂等、悬挂

这三个问题 AT 模式本身不解决,必须业务层防护。我们踩过全部三个。

4.1 空回滚

TC 启动回滚时,如果分支的事务还没执行到(比如分支调用超时但实际还在路上),回滚会收到一个”没有 undo_log 的回滚请求”。不处理就会回滚失败、全局事务卡死。解法是事务防御表:

create table transaction_defense (
    xid varchar(128) primary key,
    branch_id bigint not null,
    action varchar(32) not null,   -- try / cancel
    state tinyint not null
);

Cancel 分支先查这个 xid 有没有 Try 记录:没有就插入一条 cancel 记录并直接返回成功(空回滚);Try 分支执行前查到已有 cancel 记录,则拒绝执行。

4.2 幂等

网络重试会让同一个分支请求到达两次。防御表天然解决:Try 执行前检查 state,已成功就幂等返回。

4.3 悬挂

Cancel 先执行了(空回滚记录落库),Try 请求才姗姗来迟——如果没有防护,Try 会执行成功,但全局事务已经回滚,这笔钱就”悬”在那里没人管了。解法就是 4.1 里的规则反过来用:Try 执行前检查是否存在 cancel 记录,存在则拒绝执行。

三个问题一张表总结:

问题场景解法
空回滚回滚先于分支到达无 Try 记录时 Cancel 空返回
幂等分支请求重试到达防御表状态检查
悬挂Cancel 先执行,Try 后到Try 发现 cancel 记录则拒绝

五、把成功率从 98% 推到 99.2% 的调优清单

上线后我们通过链路追踪把失败订单逐笔归因,主要的优化项按收益排序:

① 全局事务超时时间放开到 60s。默认 30s 在支付网关偶发慢响应时会误杀正常事务,超时回滚后的用户重试反而放大了失败率。改成 60s 后误杀归零。

② undo_log 表按月分区 + 清理任务。回滚率低的业务里 undo_log 绝大部分走”二阶段异步删除”,但异常堆积会让这张表膨胀拖慢一阶段提交。分区 + 每日清理后,一阶段 P99 从 45ms 降到 18ms。

③ 全局锁重试次数提到 50 次、间隔 15ms。秒杀场景热点商品行是全局锁竞争重灾区,默认 30 次 × 10ms 在峰值时不够,锁竞争超时直接转为业务失败。调整后热点 SKU 的锁冲突失败下降了一个数量级。

④ 分支事务按风险分级。支付网关扣款这种外部依赖不放全局事务里(它的幂等由支付对账兜底),只把确定性强的数据库操作纳入 AT 范围——分布式事务的范围越小,成功率越高,这是一条被反复验证的经验。

⑤ TC 集群独立部署 + db 模式存储。TC 用 file 模式只适合 demo,生产必须 db/redis 模式多节点,TC 挂了全局事务全部悬死。

六、结语:AT 不是银弹,边界感才是

回头看,Seata AT 模式给我们带来的最大价值不是”自动回滚”本身,而是让业务代码保持了普通本地事务的写法,团队心智成本极低。但它有明确的能力边界:全局锁不解决隔离性、不解决外部系统一致性、不解决业务幂等。把确定性强的数据库操作圈进 AT、把外部依赖用对账和幂等兜底、把防御逻辑做成统一组件——这些边界感才是 99.2% 这个数字背后的东西。

剩下的 0.8% 里,绝大多数是用户主动取消和风控拦截,真正的事务一致性故障经过对账系统的兜底已经降到可忽略的水平。分布式事务做到最后,拼的不是框架选型,而是对每一条失败路径的归因和收敛。

Seata 分布式事务 SpringCloud 微服务 支付