大事务拆分
大事务拆分
[TOC]
在 MySQL 中,大事务通常指执行时间长、操作数据量多、持有锁(或生成 undo 日志)过多的事务。它可以表现为:一个事务修改了上百万行;一个事务执行了几分钟甚至几小时未提交;或一个事务里塞入了过多 SQL,比如在事务里调用远程接口、等待用户输入等。
大事务危害
- 锁竞争加剧:长时间持锁,阻塞其他会话,甚至引发雪崩
- 主从延迟放大:大事务提交后,从库回放 binlog 需要同样长的时间
- undo log 膨胀:长事务导致 undo 无法被 purge,undo 表空间持续增长,影响性能
- 回滚代价巨大:一旦失败,回滚时间可能接近甚至超过执行时间
- 内存/磁盘压力:大事务在内存中生成大量脏页,提交时刷盘更重
拆分方式
拆分大事务的核心思路:化整为零,小步快跑,及时提交。
1. 数据变更类(大批量 DELETE/UPDATE/INSERT)
a) 分批循环提交(最常用)
每次只处理一小批,提交后再处理下一批,避免一次性锁住太多行。
REPEAT
DELETE FROM large_table WHERE created_at < '2020-01-01' LIMIT 1000;
-- 或者 UPDATE ... LIMIT 1000
COMMIT;
-- 如果无行影响则退出循环
-- 可以适当 SLEEP(0.1) 降低负载
UNTIL ROW_COUNT() = 0 END REPEAT;
- 优点:简单,对业务影响最小
- 注意:需要循环执行脚本或存储过程,LIMIT 常和 ORDER BY 主键配合确保稳定
b) 按主键/唯一键范围拆分
针对有明显范围条件的操作,按主键区间拆分,批量提交。
-- 假设主键 id 自增, 每次处理 5000 行
SET @min_id = 0, @max_id = 5000;
WHILE EXISTS (SELECT 1 FROM orders WHERE id > @min_id) DO
DELETE FROM orders WHERE id > @min_id AND id <= @max_id;
COMMIT;
SET @min_id = @max_id;
SET @max_id = @max_id + 5000;
END WHILE;
- 优点:完全利用索引,速度可控,复制延迟平滑
- 适用:范围删除、归档类操作
c) 利用临时表/分阶段操作
先把要删除/更新的主键分批取出,放在临时表,再关联原表分批操作。
-- 1. 将符合条件的 id 分批写入临时表
INSERT INTO temp_ids SELECT id FROM source WHERE ... LIMIT 10000;
-- 2. 根据 temp_ids 分批删除
DELETE FROM source WHERE id IN (SELECT id FROM temp_ids LIMIT 1000);
COMMIT;
-- 循环直至 temp_ids 处理完
d) 工具辅助
- pt-archiver(Percona Toolkit):自动按低负载方式归档/删除数据,控制 chunk 大小和休眠时间,避免大事务
- gh-ost / pt-online-schema-change:在线修改表结构时,通过分段拷贝和增量同步避免大事务锁表
- 应用层的批量队列 Job:例如利用 Redis 或 MQ 将待处理记录拆成多个小任务,由消费端分批处理并提交
2. 业务流程类(避免一个事务串太多步骤)
a) 将非关键操作移出事务
- 发邮件、推送通知、写日志、调用外部接口等绝不应该放在事务内,放在提交之后执行,或改为异步
- 原则:事务中仅包含对数据库的关键写操作,其余一律后置
b) 最终一致性 / Saga 模式
如果业务流程天然较长(如订单创建 -> 扣库存 -> 扣款 -> 送积分),不要试图包在一个本地大事务里。拆分为多个小事务,每个有自己的本地事务,通过补偿或重试实现最终一致。
- 示例:
create_order()事务1 ->reserve_inventory()事务2 ->payment()事务3 ... 失败时执行对应的补偿事务
c) 异步解耦
将部分操作通过消息队列后置处理,比如"下单后增加积分"可作为一条消息,由消费者在独立事务中完成,不为积分处理拖慢下单事务。
如何预防
应用层开发规范
- 强制及时提交/回滚:避免
autocommit=0后忘了 commit;在 finally 块中处理回滚 - 显式开启事务后务必控制粒度:一个事务里操作的行数上限(如 1000 行),超过则分段
- 禁止事务内等待:不让事务内包含 RPC 调用、消息队列发送、用户交互、长时间计算
- 对大批量 DML 走审核:没有
LIMIT、没走索引的 UPDATE/DELETE 不准执行,必须改成分批处理 - 使用连接池时注意事务泄露:归还连接前保证事务已结束
- ORM 框架检查:Hibernate/MyBatis 等的配置,防止隐性大事务(如懒加载全部集合)
- 设置 SQL 超时:应用侧调用时加上 JDBC 的
queryTimeout,不让 SQL 无限执行
能在项目里识别并优化大事务
识别大事务的三个信号:
1. 事务执行时间超过 1 秒(监控 information_schema.innodb_trx 的 trx_started)
2. undo log 持续增长(SHOW ENGINE INNODB STATUS 看 History list length)
3. 锁等待频繁(performance_schema.data_lock_waits 查看等待链)
项目中的优化示例:
// 反例:大事务(远程调用 + 循环操作都在事务里)
@Transactional
public void createOrder(OrderDTO dto) {
// 1. 调用远程库存服务(可能耗时很长)
stockClient.deduct(dto.getGoodsId(), dto.getCount());
// 2. 循环插入订单明细(100 条)
for (OrderItem item : dto.getItems()) {
orderItemMapper.insert(item);
}
// 3. 发送 MQ 消息(远程调用)
mqTemplate.convertAndSend("order.create", dto);
}
// 正例:拆分后
public void createOrder(OrderDTO dto) {
// 1. 核心事务:只包含 DB 操作
Long orderId;
try {
orderId = transactionTemplate.execute(status -> {
// 只保留必要的 DB 写操作
Order order = buildOrder(dto);
orderMapper.insert(order);
orderItemMapper.batchInsert(dto.getItems());
// 预扣库存改为本地 Redis 操作
redisTemplate.opsForValue().decrement("stock:" + dto.getGoodsId(), dto.getCount());
return order.getId();
});
} catch (Exception e) {
// 补偿:恢复 Redis 库存
redisTemplate.opsForValue().increment("stock:" + dto.getGoodsId(), dto.getCount());
throw e;
}
// 2. 事务提交后,异步发送 MQ
Order finalOrder = orderMapper.selectById(orderId);
mqTemplate.convertAndSend("order.create", finalOrder);
}

浙公网安备 33010602011771号