分布式事务的性能优化——Seata AT 模式的锁竞争与批量提交优化

分布式事务的性能优化——Seata AT 模式的锁竞争与批量提交优化

一、Seata AT 模式的运行机制回顾

Seata 的 AT(Automatic Transaction)模式通过在业务 SQL 执行前后自动生成回滚镜像(undo log)来实现分布式事务的一阶段和二阶段提交。其核心流程为:

  • 一阶段:执行业务 SQL,记录 before-image 和 after-image 到 undo_log 表,相当于"预提交"。
  • 二阶段:如果全局事务成功,异步删除 undo_log;如果失败,根据 before-image 回滚数据。

这一机制的优势在于对业务代码侵入性低,但性能瓶颈主要在两个方面:全局锁竞争和 undo_log 的写入开销。

二、全局锁竞争分析与优化

Seata AT 模式在事务执行期间会对数据行加全局锁(记录在lock_table表中),以防止其他事务在二阶段确认前修改同一行数据。在并发较高的场景下,全局锁可能成为严重的性能瓶颈。

/** * Seata全局锁监控与告警——定期检查 lock_table 中锁的数量和等待时间, * 在锁竞争异常时触发告警和降级。 * * 为什么在业务层监控而非依赖 Seata 内置指标: * Seata 的 lock_table 属于内部表,其锁等待数据不会暴露到 TC 的监控接口, * 需要主动查询才能获取准确的锁竞争状况。 */ @Component public class SeataLockMonitor { private static final Logger log = LoggerFactory.getLogger( SeataLockMonitor.class); private final JdbcTemplate jdbcTemplate; // 为什么告警阈值设 50 而非更高: // 单表超过 50 个全局锁意味着事务并发争用已非常严重, // 继续等待只会导致请求超时和雪崩 private static final int LOCK_COUNT_THRESHOLD = 50; private static final long LOCK_AGE_THRESHOLD_MS = 30_000; public SeataLockMonitor(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Scheduled(fixedDelay = 5_000) public void monitorLockContention() { try { Long lockCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM lock_table WHERE table_name = ?", Long.class, "order_tbl"); if (lockCount != null && lockCount > LOCK_COUNT_THRESHOLD) { log.warn("Seata全局锁数量超过阈值, count={}, " + "可能存在热点数据行或长事务阻塞", lockCount); // 查询锁等待时间最长的记录 List<Map<String, Object>> longLocks = jdbcTemplate .queryForList( "SELECT xid, table_name, pk, " + "TIMESTAMPDIFF(MILLISECOND, create_time, NOW()) " + "as waiting_ms " + "FROM lock_table " + "WHERE TIMESTAMPDIFF(MILLISECOND, " + "create_time, NOW()) > ? " + "ORDER BY waiting_ms DESC LIMIT 5", LOCK_AGE_THRESHOLD_MS); for (Map<String, Object> lock : longLocks) { log.error("Seata长时持锁, xid={}, table={}, pk={}, " + "waitingMs={}", lock.get("xid"), lock.get("table_name"), lock.get("pk"), lock.get("waiting_ms")); } } } catch (Exception e) { log.debug("Seata锁监控查询异常, 跳过本次检查", e); } } }

三、undo_log 批量写入优化

每条业务 SQL 在 Seata AT 模式下都会额外产生一条 undo_log 的 INSERT 操作。在批量操作的场景中(如批量创建订单),undo_log 的逐条插入会成为性能瓶颈。

/** * 批量操作 + Seata AT模式优化——通过批量写入 undo_log 和减少分支事务数量 * 来降低 Seata 的协调开销。 * * 为什么不用 Seata 默认的分支事务粒度(每个 SQL 一个分支): * 批量操作 1000 条记录时,1000 个分支事务使 TC 的协调开销 O(n) 增长, * 合并为 1 个分支可以降到 O(1)。 */ @Service public class BatchOrderService { private static final Logger log = LoggerFactory.getLogger( BatchOrderService.class); private final OrderMapper orderMapper; private final SqlSessionFactory sqlSessionFactory; public BatchOrderService(OrderMapper orderMapper, SqlSessionFactory sqlSessionFactory) { this.orderMapper = orderMapper; this.sqlSessionFactory = sqlSessionFactory; } /** * 批量创建订单——在单个 Seata 分支事务中完成, * 避免每个订单产生独立的分支事务。 * * @param orders 订单列表 * @param batchSize 每次批量提交的大小 */ @GlobalTransactional(timeoutMills = 300000) public void batchCreateOrders(List<Order> orders, int batchSize) { if (orders == null || orders.isEmpty()) { return; } // 为什么分批次执行而非一次性插入: // 单批次过大会导致 undo_log 的 before-image 超长, // MySQL 的 max_allowed_packet 可能限制单条 SQL 大小 int totalBatches = (int) Math.ceil((double) orders.size() / batchSize); for (int i = 0; i < totalBatches; i++) { int fromIndex = i * batchSize; int toIndex = Math.min(fromIndex + batchSize, orders.size()); List<Order> batch = orders.subList(fromIndex, toIndex); try { // 使用 BATCH executor 而非 SIMPLE executor, // 让 MyBatis 内部批量提交,减少与数据库的交互次数 try (SqlSession sqlSession = sqlSessionFactory .openSession(ExecutorType.BATCH)) { OrderMapper batchMapper = sqlSession .getMapper(OrderMapper.class); for (Order order : batch) { batchMapper.insert(order); } sqlSession.flushStatements(); sqlSession.commit(); } log.debug("批次插入完成, batch={}/{}, size={}", i + 1, totalBatches, batch.size()); } catch (Exception e) { log.error("批次插入失败, batch={}/{}, 原因={}", i + 1, totalBatches, e.getMessage()); // 为什么在 catch 中重新抛出:触发 @GlobalTransactional 回滚, // 利用 Seata 的 AT 模式自动回滚已提交的批次数据 throw new TransactionException( "批量插入失败, 批次: " + (i + 1), e); } } log.info("批量订单创建完成, totalOrderCount={}, batches={}", orders.size(), totalBatches); } }

四、业务层面的锁竞争规避

很多时候,Seata 的锁竞争并非 Seata 自身问题,而是业务设计导致的。

/** * 订单号取模分桶——通过将并发写入分散到多行, * 减少 Seata 全局锁在同一行上的竞争。 * * 为什么使用取模分桶而非锁分段: * 数据库行锁是数据库层的机制,无法绕过, * 只能通过将竞争分散到不同行来降低锁冲突概率。 */ @Component public class InventoryBucketService { // 为什么桶数设为 8:需要在锁分散度和库存管理复杂度之间平衡, // 桶数过多会导致库存碎片化,过少则锁竞争改善不明显 private static final int BUCKET_COUNT = 8; private final JdbcTemplate jdbcTemplate; public InventoryBucketService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } /** * 扣减分桶库存——将单个 SKU 的库存分散到多个桶中, * 扣减时随机选择有足够库存的桶,降低单行争用。 */ @Transactional(rollbackFor = Exception.class) public boolean deductInventory(String skuCode, int quantity) { // 先查出所有有库存的桶 List<Integer> availableBuckets = jdbcTemplate.queryForList( "SELECT bucket_id FROM inventory_bucket " + "WHERE sku_code = ? AND quantity >= ? " + "FOR UPDATE SKIP LOCKED", Integer.class, skuCode, quantity); if (availableBuckets.isEmpty()) { return false; } // 随机选择桶进行操作,分散并发写入 // 为什么用随机而非轮询:轮询在分布式环境中不同实例的计数器不同步, // 最终仍可能集中在个别桶上 int randomBucket = availableBuckets.get( ThreadLocalRandom.current().nextInt(availableBuckets.size())); int updated = jdbcTemplate.update( "UPDATE inventory_bucket SET quantity = quantity - ? " + "WHERE sku_code = ? AND bucket_id = ? AND quantity >= ?", quantity, skuCode, randomBucket, quantity); return updated > 0; } }

五、性能优化决策流程

graph TD A[Seata AT 性能问题] --> B{分析根因} B -->|全局锁等待| C{是否热点数据?} B -->|undo_log 写入慢| D{是否为批量操作?} B -->|TC 协调延迟| E{分支事务数量?} C -->|是| C1[分桶分散写入] C -->|否| C2[缩短事务时长] D -->|是| D1[合并分支事务] D -->|否| D2[异步删除undo_log] E -->|多| E1[BATCH Executor] E -->|少| E2[TC 集群扩容] C1 --> F[验证优化效果] C2 --> F D1 --> F D2 --> F E1 --> F E2 --> F style A fill:#f96,stroke:#333,stroke-width:2px style F fill:#6f6,stroke:#333,stroke-width:2px

六、总结

Seata AT 模式的性能优化核心在于减少锁竞争的粒度和降低 undo_log 的写入开销。具体策略包括:通过分桶将热点数据行的竞争分散,通过合并分支事务减少 TC 的协调次数,通过 BATCH Executor 减少与数据库的交互轮次。在实际落地时,建议先在监控层面建立 lock_table 的巡检机制,在发现锁竞争异常后再针对性地实施上述优化策略。

undo_log 表的清理策略

undo_log 表是 Seata AT 模式的"隐藏定时炸弹"。正常流程中,二阶段提交成功后 Seata 会异步删除 undo_log。但在异常场景(如 Seata Server 宕机、网络中断)中,undo_log 会持续堆积。我们在一个已运行半年的支付系统中发现 undo_log 表膨胀至 2.3 亿行,占用磁盘 480GB——其根源是 Seata Server 的 cleanup 线程未正常执行。

清理策略:(1) 配置 Seata Server 的store.db.log-query-limit=100batch-delete-size=500,确保清理线程每次处理的合理规模;(2) 在业务低峰期执行定时任务DELETE FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 10000;(3) 加监控——每日检查undo_log的行数和最早记录的创建时间,当行数 > 100 万或最早记录 > 24 小时时触发告警。

TCC 模式 vs AT 模式——性能场景的选择边界

TCC 模式虽然编码复杂度高于 AT,但在高并发扣减场景下有显著性能优势。以秒杀为例:AT 模式在库存扣减时,SELECT FOR UPDATE获取行锁 + 写入 undo_log 的过程,1000 并发下响应延迟为 28ms(P50);TCC 模式通过 Try 阶段用 Redis 预占库存(单线程 + Lua 脚本),延时降至 3ms。

决策边界:(1) 操作主体是数据库行级数据 → AT 模式适应性好;(2) 操作涉及 Redis/消息队列 → 必须 TCC;(3) 高并发单行热点(如限量商品库存)→ TCC + 分桶 + Redis 预占;(4) 简单交易 + 低并发 → AT 性价比高。实践中,不要试图用 AT 做 TCC 的工作,也不要用 TCC 做 AT 的工作——选型不当造成的性能损失往往比写 TCC 代码的成本更高。在混合场景中,可以通过配置路由规则让不同业务走不同模式:高频扣减走 TCC、低频数据更新走 AT,由统一的事务管理器根据方法注解自动分发,避免开发者在两种模式间反复选择。

在生产运维层面,分布式事务的告警体系同样重要。我们建议监控三个核心指标:(1) 全局事务超时率——超过 2% 时触发告警,排查 TC Server 压力或网络延迟;(2) 分支事务回滚率——正常应在 0.5% 以下,突增说明业务异常或锁竞争加剧;(3) undo_log 堆积量——按日监控行数增长趋势,提前配置自动清理任务。对于 TCC 模式,还需要额外关注 Confirm/Cancel 阶段的成功率,如果 Cancel 失败率超过 1%,说明 Try 阶段预留的资源存在释放失败的风险,需要排查资源锁定机制的正确性。