在趣玩搭社交平台的支付场景中,一笔支付成功后需要同时完成"创建订单、扣减库存、增加积分"三个操作,分布在三个微服务中。本文记录为什么选 AT 模式、性能瓶颈如何突破、以及回滚失败时的"人肉兜底"方案。
一、问题背景
1.1 业务场景
趣玩搭的活动票务支付成功后,需要同时完成三件事:
支付回调到达
├── 订单服务:将订单状态从 PENDING 更新为 PAID
├── 库存服务:确认扣减库存(从"预扣"变为"实扣")
└── 积分服务:给用户增加活动积分(用于平台内的等级和权益)三个操作分布在三个微服务中,各自有独立的数据库。任何一个失败,其他两个都必须回滚,否则就会出现"付了钱但没有订单""扣了库存但没加积分"等不一致状态。
1.2 一致性要求分析
这个场景对一致性的要求非常高。它不是一个可以容忍"最终一致"的场景——用户支付成功后立刻就会查看订单状态、检查积分变化。如果用户看到"支付成功但订单还是待支付",投诉率会非常高。
同时这也是支付场景,涉及资金,不能有任何数据不一致的中间状态被用户感知到。
二、模式选型:为什么选 AT 而不是 TCC 或 Saga
2.1 三种模式的核心差异
在做选型之前,我先梳理了 Seata 三种主要模式的特点。
AT 模式(Auto Transaction)。 基于数据库本地事务 + 二阶段提交。第一阶段:各分支事务直接执行 SQL 并提交本地事务,同时 Seata 自动记录数据快照(undo_log)。第二阶段:如果全局事务成功,异步删除 undo_log;如果失败,根据 undo_log 自动生成反向 SQL 回滚数据。
对业务代码几乎零侵入——只需要在入口方法加一个 @GlobalTransactional 注解,不需要改业务逻辑。
TCC 模式(Try-Confirm-Cancel)。 需要业务自己实现三个接口:Try(资源预留)、Confirm(确认提交)、Cancel(取消回滚)。性能最好(没有全局锁),但侵入性极强——每个参与方都要写三套逻辑,代码量翻三倍。
Saga 模式。 每个参与方只需要实现"正向操作"和"补偿操作",通过状态机编排执行顺序。适合长事务(流程长达分钟甚至小时级),但对短事务来说编排配置的复杂度不值得。
2.2 选型决策
选择 AT 模式的三个理由:
第一,业务侵入性最低。 我们团队规模不大(4 个后端),选 TCC 意味着每个参与方的代码量翻三倍,开发和维护成本太高。AT 模式只需要在发起方加一个注解,其他服务完全不用改业务代码。
第二,事务时长短。 支付回调后的三个操作(改订单状态、确认库存、加积分)都是简单的数据库更新,单个操作耗时在 5-10ms,整个分布式事务在 50ms 以内完成。AT 模式的全局锁在这个时长下的开销是可以接受的。
第三,回滚场景少。 正常情况下三个操作都会成功(数据已经在前面的流程中做了充分校验),回滚只在极端异常(如某个服务宕机)时发生。TCC 模式的性能优势在高频回滚场景下才明显,我们的场景用 AT 足够。
2.3 为什么不选 TCC
面试中经常被追问这个问题,再展开说一下。
TCC 的 Try 阶段需要"资源预留"。以库存为例,Try 阶段要把库存从"可用"状态改为"冻结"状态,Confirm 阶段再把"冻结"改为"已扣减",Cancel 阶段把"冻结"改回"可用"。这意味着库存表要增加 frozen_stock 字段,所有读取库存的地方都要考虑冻结量。
积分服务更麻烦——积分的 Try 预留怎么做?预先给用户加积分然后标记为"冻结中"?这会导致用户在积分页面看到一个"冻结中"的积分,产生困惑。
TCC 的本质是把分布式事务的复杂性从框架转移到了业务代码中。 在我们这个场景里,这个转移不划算。
三、AT 模式落地实现
3.1 基础架构
┌─────────────┐
│ Seata Server│
│ (TC) │
└──────┬──────┘
│ 协调全局事务
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 订单服务 │ │ 库存服务 │ │ 积分服务 │
│ (TM + RM) │ │ (RM) │ │ (RM) │
│ order_db │ │ stock_db │ │ points_db │
│ undo_log │ │ undo_log │ │ undo_log │
└──────────────┘ └──────────────┘ └──────────────┘TM(Transaction Manager):事务发起方,由订单服务担当。RM(Resource Manager):事务参与方,三个服务都是 RM。TC(Transaction Coordinator):Seata Server,负责协调全局事务的提交或回滚。
每个服务的数据库中都需要创建 undo_log 表:
CREATE TABLE `undo_log` (
`branch_id` BIGINT NOT NULL COMMENT '分支事务ID',
`xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
`context` VARCHAR(128) NOT NULL COMMENT '上下文',
`rollback_info` LONGBLOB NOT NULL COMMENT '回滚数据(前后镜像)',
`log_status` INT NOT NULL COMMENT '状态',
`log_created` DATETIME NOT NULL,
`log_modified` DATETIME NOT NULL,
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB;3.2 核心代码
事务发起方(订单服务):
@Service
public class PaymentCallbackService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockFeignClient stockClient;
@Autowired
private PointsFeignClient pointsClient;
@GlobalTransactional(name = "payment-callback-tx", timeoutMills = 30000, rollbackFor = Exception.class)
public void handlePaymentSuccess(PaymentCallbackDTO callback) {
Long orderId = callback.getOrderId();
Long activityId = callback.getActivityId();
Long userId = callback.getUserId();
Integer amount = callback.getAmount();
// 1. 更新订单状态
int affected = orderMapper.updateStatus(orderId, OrderStatus.PENDING, OrderStatus.PAID);
if (affected == 0) {
throw new BusinessException("订单状态更新失败,可能已被处理");
}
// 2. 确认库存扣减(从预扣变为实扣)
Result<?> stockResult = stockClient.confirmDeduct(activityId, orderId);
if (!stockResult.isSuccess()) {
throw new BusinessException("库存确认失败: " + stockResult.getMessage());
}
// 3. 增加用户积分
Result<?> pointsResult = pointsClient.addPoints(userId, activityId, calculatePoints(amount));
if (!pointsResult.isSuccess()) {
throw new BusinessException("积分增加失败: " + pointsResult.getMessage());
}
log.info("支付回调处理成功 orderId={}, activityId={}, userId={}", orderId, activityId, userId);
}
private int calculatePoints(Integer payAmount) {
// 每消费1元获得10积分
return payAmount * 10;
}
}@GlobalTransactional 注解的工作原理:方法进入时,Seata TM 向 TC 注册一个全局事务(获得 XID);方法内的每个 Feign 调用都会通过拦截器将 XID 传递到下游服务;下游服务的本地事务执行前后,Seata RM 会自动记录数据快照到 undo_log;如果方法正常结束,TC 通知所有 RM 删除 undo_log(异步完成,不阻塞主流程);如果方法抛出异常,TC 通知所有 RM 根据 undo_log 生成反向 SQL 回滚数据。
分支参与方(库存服务):
@Service
public class StockServiceImpl implements StockService {
@Autowired
private StockMapper stockMapper;
@Transactional // 普通的本地事务注解即可,Seata会自动代理
@Override
public void confirmDeduct(Long activityId, Long orderId) {
// 将预扣库存标记为已确认
int affected = stockMapper.confirmDeduct(activityId, orderId);
if (affected == 0) {
throw new BusinessException("库存确认失败,预扣记录不存在");
}
}
}分支参与方不需要任何 Seata 特有的注解或代码改动,只需要正常的 @Transactional。Seata 通过数据源代理自动完成快照记录和 undo_log 写入。
3.3 Seata Server 部署
Seata Server(TC)使用 DB 模式部署(事务日志存储在 MySQL 中),保证 TC 自身的高可用:
# Seata Server 配置
store:
mode: db
db:
datasource: druid
db-type: mysql
url: jdbc:mysql://seata-db:3306/seata_server
user: seata
password: ****
min-conn: 10
max-conn: 30TC 部署了 2 个实例做主备,通过 Nacos 注册发现,客户端自动感知切换。
四、性能瓶颈与优化
4.1 第一个瓶颈:全局锁竞争
Situation: 上线初期,在压测中发现当并发量超过 500 TPS 时,支付回调接口的 RT 从 50ms 急剧上升到 800ms,大量请求在 Seata 的日志中出现 GlobalLock conflict 的警告。
根因分析: AT 模式的全局锁机制导致的。当一个全局事务修改了某行数据时,Seata 会对这行数据加一把"全局锁"(存储在 TC 的 lock_table 中)。其他全局事务如果要修改同一行数据,必须等前一个事务完成(提交或回滚)后才能获取锁。
在抢票场景中,同一个活动的库存记录(activity_stock 表的同一行)被大量并发事务争抢修改,全局锁成为了热点瓶颈。
解决方案: 将库存确认逻辑从"修改同一行"改为"按订单维度修改各自的预扣记录":
-- 改造前:所有事务争抢修改同一行
UPDATE activity_stock SET confirmed = confirmed + 1 WHERE activity_id = #{activityId};
-- 改造后:每个订单修改自己的预扣记录(不同行,不竞争全局锁)
UPDATE stock_deduct_log
SET status = 'CONFIRMED'
WHERE order_id = #{orderId} AND activity_id = #{activityId};预扣记录(stock_deduct_log)在 Redis Lua 脚本扣减成功后、发送 MQ 创建订单时就已经写入,每个订单对应独立的一行。这样全局锁从"热点行级锁"变成了"各自独立的行级锁",竞争完全消除。
效果: 支付回调接口在 1000 TPS 下 RT 稳定在 60ms 以内,GlobalLock conflict 降为零。
4.2 第二个瓶颈:TC 的数据库压力
Situation: 全局锁竞争解决后,压到 2000 TPS 时又遇到了新瓶颈——TC(Seata Server)的响应变慢,客户端注册分支事务和提交全局事务的耗时明显增加。
根因分析: TC 使用 DB 模式存储事务日志,每个全局事务的生命周期中,TC 会进行多次数据库操作(写入 global_table、branch_table、lock_table,以及事务完成后的删除操作)。2000 TPS 意味着 TC 每秒要进行上万次数据库读写。
解决方案:
第一,优化 TC 数据库的连接池和索引。将 TC 数据库的连接池从默认的 10 调到 30,并为 lock_table 增加了缺失的联合索引。
第二,将 TC 的存储模式在高峰期切换到 Redis 模式(Seata 1.5+ 支持)。Redis 作为 TC 的存储后端,比 MySQL 快一个数量级:
store:
mode: redis
redis:
host: seata-redis
port: 6379
database: 0
min-conn: 10
max-conn: 30效果: TC 响应时间从 15ms 降到 2ms,整体分布式事务的额外开销从 30ms 降到 10ms 以内。
4.3 非热点路径的优化思路
除了解决具体瓶颈,我还做了一个架构层面的优化:区分哪些操作必须在分布式事务内,哪些可以移出去。
原来的做法是把"修改订单状态 + 确认库存 + 增加积分 + 发送通知 + 记录日志"全部放在 @GlobalTransactional 里。但发送通知和记录日志跟数据一致性无关,它们失败了不需要回滚订单。
改造后:
@GlobalTransactional(name = "payment-callback-tx", timeoutMills = 30000)
public void handlePaymentSuccess(PaymentCallbackDTO callback) {
// ✅ 只有这三个操作需要分布式事务保护
orderMapper.updateStatus(callback.getOrderId(), OrderStatus.PENDING, OrderStatus.PAID);
stockClient.confirmDeduct(callback.getActivityId(), callback.getOrderId());
pointsClient.addPoints(callback.getUserId(), callback.getActivityId(), calculatePoints(callback.getAmount()));
}
// ❌ 通知和日志移到事务外面,用事件驱动
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterPaymentSuccess(PaymentSuccessEvent event) {
notifyService.sendPaymentSuccessNotification(event.getUserId());
auditLogService.recordPayment(event);
}事务内的操作越少,持有全局锁的时间越短,并发性能越好。
五、回滚失败的处理
5.1 回滚失败的场景
AT 模式的自动回滚依赖 undo_log 中的数据快照。回滚时,Seata 会用 undo_log 中的"前镜像"(before image)生成反向 SQL,将数据恢复到事务开始前的状态。
但有一种情况会导致回滚失败:脏写(Dirty Write)。
举例说明:全局事务 TX1 将订单状态从 PENDING 改为 PAID,undo_log 记录的前镜像是 PENDING。此时如果有另一个非 Seata 管理的操作(比如管理员手动在数据库中改了订单状态为 CANCELLED),当 TX1 需要回滚时,Seata 发现当前数据(CANCELLED)和 undo_log 中的后镜像(PAID)不匹配,无法安全回滚——如果强行用前镜像覆盖,管理员的修改就丢了。
5.2 我的回滚失败处理方案
第一层:预防脏写。 制定团队规范——所有对 Seata 管理的表的写操作,必须通过 Seata 管理的服务接口进行,禁止直接操作数据库(紧急情况除外,且必须在操作前确认没有进行中的全局事务)。
第二层:回滚异常告警 + 人工介入。 自定义 Seata 的 FailureHandler,在回滚失败时立刻触发告警:
@Component
public class CustomFailureHandler extends DefaultFailureHandler {
@Autowired
private AlertService alertService;
@Autowired
private CompensationService compensationService;
@Override
public void onRollbackFailure(GlobalTransaction tx, Throwable originalException) {
String xid = tx.getXid();
// 1. 告警:通知值班人员
alertService.sendCritical(
"Seata回滚失败告警",
String.format("全局事务 %s 回滚失败,需要人工介入。原始异常: %s",
xid, originalException.getMessage())
);
// 2. 记录到补偿表,等待人工处理
compensationService.recordRollbackFailure(xid, originalException);
// 3. 调用父类的默认处理(持续重试)
super.onRollbackFailure(tx, originalException);
log.error("全局事务 {} 回滚失败,已记录补偿任务并触发告警", xid, originalException);
}
}第三层:补偿任务表 + 管理后台。
CREATE TABLE `seata_compensation` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
`order_id` BIGINT COMMENT '关联订单ID',
`failure_reason` TEXT COMMENT '失败原因',
`status` VARCHAR(32) DEFAULT 'PENDING' COMMENT '处理状态',
`handled_by` VARCHAR(64) COMMENT '处理人',
`handled_at` DATETIME COMMENT '处理时间',
`handle_note` TEXT COMMENT '处理备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);运营管理后台上有一个"事务补偿"页面,展示所有回滚失败的事务。值班人员收到告警后,查看具体的 XID 和关联订单信息,手动检查各库数据状态,执行补偿操作(补扣库存、回退积分等),并在系统中记录处理结果。
5.3 实际发生过几次?
上线一年半以来,回滚失败总共发生过 2 次。一次是积分服务数据库短暂不可用导致分支事务注册超时,Seata 重试三次后回滚成功。另一次是一个新同事在排查问题时直接在数据库中修改了订单状态(违反了团队规范),导致 undo_log 的后镜像校验失败。
第二次事故后,我在数据库层面为 Seata 管理的核心表设置了触发器告警——非应用账号的写操作会触发告警通知 DBA。
六、什么时候不该用 Seata
AT 模式不是万能的,有几种场景我会避免使用 Seata:
跨公司的外部服务调用。 比如调用微信支付 API。你没有办法在微信的数据库里加 undo_log,Seata 管不到外部系统。这种场景用"本地消息表 + 补偿"的最终一致性方案更合适。
高频写入热点数据。 如果业务场景中大量事务争抢修改同一行数据,全局锁竞争会严重拖慢性能。这种场景要么改造数据模型消除热点(如本文中的方案),要么考虑 TCC 模式。
长事务流程。 如果一个业务流程需要跨越分钟甚至小时(比如审批流),全局事务持有锁时间过长,会阻塞其他事务。这种场景适合 Saga 模式。
七、经验总结
选型要从业务场景出发,不要从技术炫技出发。 AT 模式在理论上不如 TCC 性能好,但在我们的场景下(短事务、低回滚率、小团队),AT 模式的低侵入性带来的开发效率提升远大于 TCC 的性能优势。
AT 模式的性能瓶颈几乎总是出在全局锁上。 消除热点行竞争是最有效的优化手段,比调 Seata 参数有效得多。
分布式事务的范围越小越好。 只把真正需要强一致性的操作放进全局事务,其他操作用事件驱动异步完成。事务范围小 → 持锁时间短 → 并发能力强。
回滚失败是小概率事件,但处理方案必须提前设计好。 告警 + 补偿表 + 人工介入的三件套是工程上的最低保障。不要等到生产出了问题再临时想办法。
如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。