大事务拆分

大事务拆分

[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_trxtrx_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);
}
posted @ 2026-05-07 16:57  xzlrf  阅读(34)  评论(0)    收藏  举报