MySQL大事务在实际生产里有多坑,我相信踩过的人都有体会。明明是一条UPDATE或者DELETE,条件稍微没写准,或者数据量一上来,直接就把InnoDB的undo表空间撑爆,主从延迟瞬间拉满,连带着CPU和IO一起飙升。轻则业务接口超时报警,重则直接把数据库拖到不可用。这篇文章我就拿一个“化整为零,分批执行大事务”的demo脚本当例子,把拆批的逻辑、参数设定的依据、还有实际执行时容易忽略的细节一次讲清楚。
不管你是开发、DBA还是运维,只要你的业务里有批量更新数据、清理历史数据、给大表刷字段这类需求,这篇文章都值得花十分钟看完。脚本本身不复杂,核心思路也不高深,难的是把几个关键的边界条件想明白,我会直接给出能跑的代码,也会讲清楚每一处为什么这么写。
1. 大事务为什么会成为生产事故的源头
先不急着上代码,我们把大事务为什么危险这件事拆开聊透。只有理解了底层原理,后面的参数调整才有依据,否则你只是把一个“大炸弹”切成了几个“中炸弹”,问题并没有真正解决。
1.1 InnoDB的事务机制决定了“大”就是原罪
MySQL默认的存储引擎是InnoDB,它的事务隔离级别默认是REPEATABLE READ。为了保证事务的隔离性,InnoDB引入了MVCC(多版本并发控制)机制,简单说就是:一行数据被修改时,旧版本的数据不会立即被覆盖,而是会保留在undo log里,供其他还未提交的读事务通过版本链去读取。
这就是问题所在了。一个大事务要更新100万行,意味着这100万行的旧版本都要保留在undo log里,直到事务提交或者回滚后才能清理。如果事务持续几十分钟甚至几个小时,undo log会持续膨胀,大到一定程度,磁盘空间告警,purge线程清理速度跟不上,整库性能都会跟着下降。
这个场景可以类比成搬家:你把整个房子的东西一次性装车再运走,货车要很大,路上占用车道的时间也长,一不小心侧翻就是大事。分批搬则不同,每次只运一小车,路面影响小、风险可控、中途停下也不会造成太严重的后果。
1.2 大事务带来的四类连锁反应
除了undo膨胀,大事务还会在生产环境引发一系列连锁反应,这里总结成四类常见问题:
-
锁范围过大引发阻塞。大数据量的UPDATE或DELETE,InnoDB会对扫描到的行加锁。如果没能高效走索引,可能把大量行甚至整张表锁住,所有相关读写请求全部堵在锁等待上,业务侧的表现为数据库连接池被打满,接口大面积超时。
-
binlog与主从延迟。MySQL的二进制日志(binlog)记录的是事务级别的提交,一个大事务提交时,binlog会一次性写入极大的数据量,从库的SQL线程要在一个“事务”内把这些全部回放完,期间无法并行做其他操作,直接导致主从延迟从几秒飙升到几十分钟。
-
回滚代价不可控。事务越大,回滚的成本越高。一旦应用在执行到一半时超时或报错,MySQL不得不把已经做的修改全部逆向操作一次,回滚持续时间可能比正向执行还长,期间服务器负载依旧居高不下。
-
死锁概率上升。大事务持有大量行锁的时间长,两个大事务之间或者大事务与普通事务之间,极容易因为锁等待产生死锁。死锁发生后InnoDB会自动回滚其中一个事务,而回滚一个大事务的开销,前面已经说过了。
所以,无论是从资源消耗、锁竞争还是故障恢复角度,“大”都是原罪。这也是为什么我们必须想办法把一个庞大的事务拆解成多个独立的小事务来分批执行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分批执行的整体设计思路
分批发事务的核心思路并不复杂:把原来一个事务里要处理的N行数据,划分成若干个批次,每批只处理M行,每批使用独立的事务提交,批与批之间做短暂停顿,把压力峰值削平。
2.1 为什么不是直接改写SQL那么简单
有些人第一反应是:我加个LIMIT不就行了?比如UPDATE table SET status = 1 WHERE status = 0 LIMIT 1000;然后循环执行,这不就拆开了吗?
思路方向没错,但直接这么做会碰到几个很现实的问题。第一个问题是,UPDATE配合LIMIT在MySQL里的语义不是标准的,它不保证从表头开始取1000行更新,语义上具有一定的随意性,虽然实际执行中很多场景下是按主键顺序,但这是要看执行计划的,不是你能依赖的。第二个问题是,如果UPDATE条件对应的数据还在持续写入,循环执行LIMIT的写法会有重复扫描和重复处理的风险,很难准确定位到“还没处理过”的数据。第三个问题是,DELETE和UPDATE在条件与索引配合不当的情况下,每一次“扫一批”都可能全表扫描,批次数多了等于把一张大表全表扫了几十遍,IO开销反而更大,虽然事务不大,但整体资源消耗未必理想。
所以可靠的分批方案,必须以一个有序且稳定的字段作为切分依据,最典型的就是主键自增ID。每一批都通过主键范围定位,这样既能精确控制每次影响的行数,又能确保批量之间互不重叠、没有遗漏。
2.2 一个可靠的分批脚本应该具备哪些要素
一个能上生产环境的分批脚本,我认为至少要满足以下五点:
- 每一批的事务边界必须清晰,单批提交,绝不在一个事务里跨批次处理。
- 有明确的进度反馈,每一批结束都能输出当前处理到的位置和剩余情况,这样中途异常时能定位。
- 支持断点续跑,也就是当唯一键或主键为条件时,可以从上次失败的位置继续,而不是从头再来。
- 可控的批大小与批间隔,这两个参数要能被调用方灵活调整,以适应不同负载环境。
- 有安全的兜底策略,包括处理超时、参数校验、日志记录等,避免误操作把整张表刷掉后没有后悔药。
2.3 技术选型:存储过程、Shell脚本还是应用程序
具体用什么来编排这个分批逻辑,常见有三种选择:MySQL存储过程、Shell脚本循环调用SQL、或者用Python/Java这类外部程序连接数据库执行。
存储过程的优势在于不依赖外部环境,数据库客户端里能执行SQL就能跑,部署在服务器上调用也方便。缺点是语法相对古老,调试不如外部脚本灵活,变量作用域比较别扭。Shell脚本配合mysql命令行的方式简单直接,适合一次性维护任务,但参数拼接时要注意SQL注入和字符集问题,而且不好做复杂的数据加工校验。用外部程序写灵活度最高,能处理的异常场景最多,但对部署环境有额外要求,JVM或者Python环境总得有一个。
这里我给的demo脚本用的是存储过程的方式。为什么选它?因为这个脚本是一个可以放进“数据库工具类”场景里的通用方案,存储过程不仅复用性高,而且内部可以直接通过PREPARE语句动态拼接表名与条件,足够灵活,放到任何一台有MySQL客户端的机器上都能执行,不需要额外安装任何依赖。
2.4 为什么单批提交真实可行
这里需要特别解释一个容易被误解的点。有些文章会告诉你,大批量更新后需要显式开启事务并一次性COMMIT,这样速度最快,因为减少了提交次数,每批提交一次会拖慢速度。这种说法在“离线跑批,且不在乎对其他会话的影响”时是正确的。但我们现在讨论的换是生产环境的在线大表,优先目标根本就不是速度,而是稳定性和可控性。
单批提交的设置下,每处理1000行就COMMIT一次,虽然提交开销增加了,但换来了三个好处:锁范围从全表缩小到1000行左右;undo log不再持续膨胀,每次提交后都能及时回收旧版本;从库也能在每个小事务结束时及时回放,主从延迟可以维持在可控范围内。在绝大多数业务场景里,牺牲一点总时间换取稳定性,是完全划算的。
3. “化整为零”分批执行脚本的完整实现
下面进入正题,直接给出一个完整的、可落到生产环境的MySQL存储过程demo脚本。脚本的目的是把一张大表中满足条件的记录分批UPDATE成一个新值,适用于状态流转、逻辑删除标记、大数据量订正等场景。
3.1 脚本代码全文
sql复制-- 分批更新大事务demo
-- 适用场景:大表数据的批量UPDATE/DELETE,避免长事务
-- 核心思路:基于主键范围切片,每批一个独立事务,批间sleep削峰
-- 调用示例:
-- CALL batch_update_by_pk(
-- 'your_table',
-- 'status = 2',
-- 'status = 0',
-- 1000,
-- 0.5,
-- 100000,
-- 30,
-- @processed_rows,
-- @success
-- );
DELIMITER $$
DROP PROCEDURE IF EXISTS batch_update_by_pk$$
CREATE PROCEDURE batch_update_by_pk(
IN p_table_name VARCHAR(64), -- 目标表名
IN p_set_clause VARCHAR(500), -- SET子句,如 'status = 2'
IN p_where_clause VARCHAR(500), -- WHERE条件(不含主键范围条件),如 'status = 0'
IN p_batch_size INT, -- 每批处理行数
IN p_sleep_second DECIMAL(5,2), -- 批间暂停秒数
IN p_max_rows BIGINT, -- 最大处理行数,0表示不限制
IN p_timeout_sec INT, -- 单个批次的超时时间(秒),0表示不限制
OUT p_processed BIGINT, -- 实际处理行数
OUT p_success_flag INT -- 执行状态:1成功 0失败
)
BEGIN
DECLARE v_min_id BIGINT DEFAULT 0;
DECLARE v_max_id BIGINT DEFAULT 0;
DECLARE v_current_max BIGINT DEFAULT 0;
DECLARE v_affected BIGINT DEFAULT 0;
DECLARE v_total_affected BIGINT DEFAULT 0;
DECLARE v_continue INT DEFAULT 1;
DECLARE v_start_time DATETIME(3);
DECLARE v_sql TEXT DEFAULT '';
DECLARE v_range_sql TEXT DEFAULT '';
DECLARE v_exec_sql TEXT DEFAULT '';
DECLARE v_is_last_batch INT DEFAULT 0;
-- 输入参数基本校验
IF p_table_name IS NULL OR p_table_name = '' THEN
SET p_processed = 0;
SET p_success_flag = 0;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_table_name不能为空';
END IF;
IF p_set_clause IS NULL OR p_set_clause = '' THEN
SET p_processed = 0;
SET p_success_flag = 0;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_set_clause不能为空';
END IF;
-- 防御性校验:表名仅允许字母、数字、下划线,防止SQL注入
IF p_table_name REGEXP '[^a-zA-Z0-9_]' THEN
SET p_processed = 0;
SET p_success_flag = 0;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_table_name包含非法字符';
END IF;
-- 获取目标表主键ID的范围
-- 使用动态SQL先取min(id)作为游标起点
SET v_range_sql = CONCAT(
'SELECT IFNULL(MIN(id), 0), IFNULL(MAX(id), 0) INTO @v_min, @v_max FROM ',
p_table_name,
' WHERE ',
p_where_clause
);
-- 执行前先校验where条件是否写错,避免全表误更新
-- 如果where条件为空或恒真,直接拦截
IF p_where_clause IS NULL OR TRIM(p_where_clause) = '' THEN
SET p_processed = 0;
SET p_success_flag = 0;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_where_clause不能为空,禁止无条件全表更新';
END IF;
SET @v_range_sql = v_range_sql;
PREPARE range_stmt FROM @v_range_sql;
EXECUTE range_stmt;
DEALLOCATE PREPARE range_stmt;
SET v_min_id = @v_min;
SET v_max_id = @v_max;
SET v_current_max = v_min_id;
SET v_continue = 1;
SET v_total_affected = 0;
-- 如果min=0且max=0,说明表里没有满足条件的数据
IF v_max_id = 0 THEN
SET p_processed = 0;
SET p_success_flag = 1;
ELSE
-- 主循环
WHILE v_continue = 1 DO
-- 记录批次开始时间
SET v_start_time = NOW(3);
IF p_timeout_sec > 0 THEN
SET @max_exec_time = p_timeout_sec;
SET SESSION MAX_EXECUTION_TIME = @max_exec_time * 1000;
END IF;
-- 当前批次的结束主键 = min + batch_size - 1
SET v_current_max = v_min_id + p_batch_size - 1;
-- 如果当前结束主键已经大于等于整体max,说明是最后一批
IF v_current_max >= v_max_id THEN
SET v_current_max = v_max_id;
SET v_is_last_batch = 1;
END IF;
-- 构造真正的更新SQL
SET v_sql = CONCAT(
'UPDATE ', p_table_name,
' SET ', p_set_clause,
' WHERE ', p_where_clause,
' AND id BETWEEN ? AND ?'
);
SET @id_start = v_min_id;
SET @id_end = v_current_max;
SET @v_sql = v_sql;
PREPARE exec_stmt FROM @v_sql;
EXECUTE exec_stmt USING @id_start, @id_end;
SET v_affected = ROW_COUNT();
DEALLOCATE PREPARE exec_stmt;
SET v_total_affected = v_total_affected + v_affected;
-- 输出当前批次信息
SELECT CONCAT(
'[批次完成] 处理ID范围: [', v_min_id, ', ', v_current_max, ']',
', 本批影响行数: ', v_affected,
', 累计影响行数: ', v_total_affected
) AS batch_log;
-- 收尾判断
IF v_is_last_batch = 1 THEN
SET v_continue = 0;
ELSE
-- 批间暂停,给主库和从库一个喘息的机会
IF p_sleep_second > 0 THEN
DO SLEEP(p_sleep_second);
END IF;
SET v_min_id = v_current_max + 1;
END IF;
-- 如果设置了最大处理行数,且已达到,提前结束
IF p_max_rows > 0 AND v_total_affected >= p_max_rows THEN
SET v_continue = 0;
END IF;
-- 清空超时设置
SET SESSION MAX_EXECUTION_TIME = 0;
END WHILE;
END IF;
SET p_processed = v_total_affected;
SET p_success_flag = 1;
END$$
DELIMITER ;
3.2 脚本的核心逻辑拆解
这个存储过程的执行流程分为三个大步骤:初始化扫描、分批循环处理、结果信息返回。逐段解释关键逻辑。
初始化阶段最重要的是两条SQL。第一条是用来获取整个目标范围内主键的MIN值和MAX值,它决定了接下来循环要跑的范围。这里有个值得注意的小细节,MIN和MAX的计算本身会在WHERE条件命中的主键范围内做一次索引扫描,对于大表来说这是一次相对轻量的操作,因为主键索引天然有序,MySQL可以快速定位到边界。第二条是输入参数校验和表名字符合法性校验,这一步在后面会单独展开说明,但我想先把结论放前面:动态拼接SQL一定要先做参数校验,不要相信任何外部传入的字符串。
循环部分是整个过程的精华所在。每次循环都会基于一个简单但关键的公式计算“本批结束ID”:当前批次的结束主键等于起始主键加上批大小再减一。为什么要减一?因为主键范围是闭区间。假如起始ID是1,批大小是1000,那么本批要处理的是1到1000这1000行,如果直接拿1加上1000得到1001,包含的其实是1到1001,共1001行,多算一行。这个边界如果搞错了,要么每批多处理一行导致总批次数量与预期不符,要么更严重的是漏掉最后一批的某些行。
4. 实测过程与调优经验
脚本光能跑还不够,真实场景里会有一堆环境因素干扰执行效果。这里分享几组我在不同状态下的实测数据和调整经验,照着做能省掉不少排查时间。
4.1 测试环境与初始数据
我搭了一个相对接近生产的测试环境进行验证:MySQL 8.0.32,InnoDB引擎,双核四线程CPU,16GB内存,SSD磁盘。测试表是一张模拟订单流水的大表,总行数500万,数据体积约6GB,主键自增ID,附加一个普通索引idx_status(status, create_time)。表中status=0的“待处理”数据占了200万行,业务需求是要把这200万行统一改成status=2。
数据构造方式这里简单提一句。我用了递归CTE批量插入,加上RAND()函数生成随机的create_time,保证status字段上有足够的区分度。实际测下来,插500万行耗时约8分钟,这个准备阶段虽然费时,但很有必要,因为只有数据量足够大,主键批量切片的收益才能体现出来。
4.2 不同批大小对执行时间和系统负载的影响
为了实验效果,我把批大小分别设为500、1000、2000、5000,批间sleep统一设为0.2秒,观察总耗时和系统负载的变化。
| 批量大小 | 事务个数 | 总耗时 | MySQL CPU占用 | 是否有锁等待 |
|---|---|---|---|---|
| 500 | 4000 | 约23分钟 | 中等,峰值约60% | 无明显等待 |
| 1000 | 2000 | 约12分钟 | 中等,峰值约65% | 无明显等待 |
| 2000 | 1000 | 约8分钟 | 偏高,峰值约75% | 偶有轻微等待 |
| 5000 | 400 | 约6分钟 | 高,峰值约90% | 出现少量锁超时 |
批大小越小,系统负载越平稳,但总耗时明显拉长,因为提交次数和SQL解析次数都在增加。批大小越大,执行效率越高,但CPU峰值快速上抬,批大小到5000时已经能看到少量锁超时的警告。
从这张表可以清楚看出,批大小并不是越大越好。对于中等负载的生产环境,1000到2000行是一个比较稳妥的区间。如果业务低谷期执行且表上没有其他并发写入,可以适当放宽到5000;如果是白天业务高峰做在线变更,建议压到500到1000,配合适当的sleep时间,避免对业务造成可感知的影响。
4.3 Sleep时间到底应该怎么设
批间sleep这个参数,很多人会直接设为0,觉得分批都分好了,中间没必要等。但实际运行中,如果完全不休眠连续提交小事务,主库的提交线程和从库的回放线程照样会满负荷运转,主从延迟虽然比大事务好很多,但仍然可能被推高到一个不太好看的数字。
我的经验是,批大小在1000左右时,sleep设为0.1到0.3秒就能起到很好的削峰效果。这个时间不是用来“等数据库喘气”的,而是把每秒钟的事务提交频率限制在一个可控范围,比如说50个事务以内。执行完一批后主动停一下,还可以让监控系统有机会捕捉到负载变化,避免报警风暴。
如果你用的是pt-archiver这类工具,它内部也有类似的开销控制机制,本质上是同样的思路:每操作一定行数就休眠若干毫秒。数据库操作从来不是越快越好,稳稳跑完不影响业务才是最终目标。
4.4 实测中发现的额外重要结论
第一,更新操作如果没有利用到索引,哪怕是分批执行,效果也会大打折扣。我特意做过一个对照实验:同样是200万行status=0的数据,一条SQL直接更新,全表扫描耗时约260秒;用上面脚本按批1000执行,但WHERE条件没有走索引,每一批都要进行一次全表扫描,2000批次合计耗时接近55分钟,反而比一次性更新慢了一个数量级。所以,分批执行只能解决“事务过大、锁范围过大”的问题,解决不了“SQL本身走不了索引”的问题。
第二,批量更新对普通二级索引的维护也会产生额外开销。如果目标表上有多个二级索引,且更新涉及了索引字段,每批提交后InnoDB都要同步维护这些索引的变更,批处理时应该把这一部分因素计入耗时预算。
第三,如果你使用的是MySQL 8.0,可以关注一下innodb_undo_tablespaces和innodb_undo_log_truncate两个参数,它们影响undo文件的回收策略。批处理的小事务虽然在每次commit后都会释放undo资源,但如果没有开启truncate,undo表空间文件大小在经历过高峰后可能并不会自动收缩,只是内部可复用,磁盘占用看表面可能还是相对较高。这个问题不影响正确性,但在你监控磁盘使用率时可能会产生点点疑惑。
5. 常见问题与排查技巧
分批更新脚本在第一次生产使用前,建议先在一台测试实例上完整演练一遍。演练期间和后续实际使用中,以下问题最容易碰到,这里把排查思路一并列出来。
5.1 问题速查表
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 脚本执行报错“p_where_clause不能为空” | 调用时where条件传了空字符串 | 检查应用程序调用参数,确保传入了真实条件 |
| 执行到一半连接中断 | 客户端交互超时或网络抖动 | 用nohup方式执行或使用mysql客户端的wait_timeout参数调大会话超时 |
| 批处理非常慢,且CPU高 | UPDATE的WHERE条件没有利用索引,每批都在全表扫描 | EXPLAIN分析执行计划,给WHERE命中字段增加合适索引 |
| 更新行数与预期不符 | WHERE条件里还可能关联到其他过滤字段,或主键范围匹配到的数据里有不满足status条件的行 | 先执行SELECT COUNT(*)验证符合条件的数据量再跑批 |
| 事务仍有锁等待超时 | 批大小设置过大,或业务侧有并发大事务 | 降低批大小,适当增加sleep,或在业务低峰执行 |
| 主从延迟还是很高 | 单批提交后从库回放慢,或从库硬件较弱 | 进一步调小批大小,增加sleep,必要时升级从库配置 |
| 批间SLEEP不生效 | sleep参数被设为0或被误传为NULL | 在调用前打印参数确认,结合DO SLEEP语法检查 |
| SQL语法错误 | SET子句或WHERE子句拼写有误,或包含未转义的特殊字符 | 把脚本中动态拼接的SQL打印出来,手工执行验证后再跑全量 |
5.2 使用过程中的关键避坑指南
存储过程中的PREPARE和EXECUTE语句不支持直接在EXECUTE时使用存储过程变量,必须先赋值给用户变量(@var)再传入,这是我第一次改写动态SQL时踩过最久的坑。代码里已经处理好了,但如果你在自定义修改过程中不小心直接把变量格式写错,MySQL会直接报错,不会有任何回旋余地。
参数校验必须在逻辑执行之前完成,尤其是动态拼接SQL的场景。这个脚本里对表名做了正则校验,只允许字母、数字和下划线,这能挡掉绝大多数SQL注入风险。但对于SET子句和WHERE子句,只要你是数据库管理员自己调用,风险相对可控,但如果你要做成通用工具给开发同学用,建议加一层更严格的白名单,比如只允许包含特定字段名和操作符。
另外一个容易被忽略的细节是MAX_EXECUTION_TIME这个session级别变量。脚本里我在每批开始时设置超时时间,批结束后需要显式把它设回0,否则会在会话后续的其他操作中留下超时限制,可能导致后续的正常查询意外被掐断。这类session级的残留参数是生产环境中最让人头疼的隐蔽问题。
5.3 如何处理执行一半失败了的情况
如果脚本执行到一半因为网络原因或者MySQL重启中断了,不需要慌张,这个脚本的主键切片设计天然支持断点续跑。你只需要在调用时调整p_where_clause,把尚未处理的ID范围作为一个附加条件传进去即可。
举个例子。假设所有待更新数据的主键范围是1到2000000,执行到ID=1234567的时候进程断了。那么再次调用时,p_where_clause可以写成:
sql复制status = 0 AND id > 1234567
这样新的执行会从主键1234568开始继续处理,已经提交的批次不会被重复更新。如果你不放心,也可以在UPDATE的SET子句里加上status = 2这样的目标值判断,本质上效果一样,核心是让处理条件具备幂等性。
5.4 比存储过程更轻量的替代方案
用存储过程有一个硬性限制:需要在一个会话连接里连续执行,如果这个会话在执行中断开,那么整个流程也就断掉了。因此在一些自动化运维场景里,我更推荐的方案是写一个Shell脚本,循环调用mysql命令行,每轮传不同的主键范围参数。这样即使某一轮失败,下一轮换个参数重新启动就行,而且还能配合系统cron做定时调度和异常告警。逻辑是这样的:
bash复制#!/bin/bash
TABLE="orders"
BATCH_SIZE=1000
MIN_ID=0
MAX_ID=2000000
while [ $MIN_ID -lt $MAX_ID ]; do
CURRENT_MAX=$((MIN_ID + BATCH_SIZE - 1))
if [ $CURRENT_MAX -gt $MAX_ID ]; then
CURRENT_MAX=$MAX_ID
fi
mysql -uuser -ppassword dbname -e "
UPDATE $TABLE
SET status = 2
WHERE status = 0 AND id BETWEEN $MIN_ID AND $CURRENT_MAX;
"
echo "processed range: [$MIN_ID, $CURRENT_MAX]"
MIN_ID=$((CURRENT_MAX + 1))
sleep 0.2
done
Shell脚本版本的坏处在于每次更新都需要重新建立一次数据库连接,且SQL直接拼接变量,注入风险要靠自己控制。好处是断点续跑容易做、日志好处理、可以和告警系统直接对接。一般我自己的做法是:一次性离线任务用存储过程,需要纳入调度平台的周期性任务用Shell脚本或Python脚本。
6. 有没有不能拆分执行的场景
分批不是银弹,有一些事务从业务语义上就是无法拆分的,拆了反而会破坏数据一致性。这类场景虽然和本文的分批主题相反,但把边界说清楚能让你的技术方案更完备。
- 涉及多张表且要求同时成功或同时失败的强一致性操作。比如跨账户转账的扣款和入账,这种业务必须在一个事务里保证原子性,拆开后中间态暴露给其他事务会造成账目不一致。
- 需要保证某个时间点快照一致性的查询。如果你要基于某个时刻的整体数据做统计报表,拆成多批后每批提交时间不同,数据可能被其他事务改动,最终统计结果就不是同一快照下的结果。
- 对同一数据的并发修改场景。如果两张表或者两个服务同时按主键范围处理同一批数据,哪怕各自的事务很小,仍会因为行锁竞争而互相阻塞,在这种情况下应该先通过分布式锁或任务队列限制并发,再谈分批。
综上,分批执行大事务只适用于“对单行结果无状态要求、只在乎最终全部完成”的批量维护类操作,比如状态流转、数据订正、逻辑删除、历史归档。业务的强一致性保障,还是要靠应用层事务边界去设计,绝不能因为“分批执行也能跑完”就去拆不该拆的逻辑。
7. 实用的小技巧和后续扩展方向
最后补充一些这个脚本在实践中的衍生用法,能帮你少走很多弯路。
第一,如果需要批量删除历史数据,建议不要直接在UPDATE脚本上改条件,而是先用SELECT把要清理的主键范围确认好,再在DELETE语句里复用这个范围。DELETE同样可以用本文的框架,只需把SET子句去掉,换成DELETE FROM,但代价比UPDATE更高,因为InnoDB要额外维护删除标记和purge进程,每次清理的范围建议比更新操作的批大小再保守一些。
第二,如果要更新的是冷热分离后的归档表,可以把批大小调大一些,比如一次处理5000行甚至10000行,因为归档表通常没有高并发访问,可以适当牺牲负载平稳性换取执行速度。对于这类场景,性能目标和平稳目标需要重新做权衡,不要照搬在线表参数。
第三,如果主键不是自增ID而是UUID或者业务单号,脚本里的id BETWEEN ? AND ?就不适用了。这种场景建议额外增加一个自增序号列或者使用游标循环,每次FETCH固定数量。MySQL的游标效率相对较低,更稳妥的做法是维护一张临时表,把目标行的主键先批量灌进去,再基于临时表的自增ID做分页或者切片循环,处理完一批删一批。
第四,生产环境第一次跑这个脚本前,先把它放到测试环境,用Explain把几条关键SQL的执行计划打出来,确认id范围切片命中的是主键索引、WHERE字段命中辅助索引,再考虑上生产。这个排查步骤虽然花不了几分钟,但能避免线上执行到一半才发现SQL在拖全表,极大降低了变更风险。
我在实际使用这类脚本时,还有一个体验较深的地方:处理过程中输出频率不要太高,每执行一批打印一次日志,批次数很多时日志文件增长很快。可以把日志级别分两种,一种是每批都输出,适合批次数少于100的小任务;一种是只输出每100批汇总一行,适合几百万行的超大任务。这个细节看似不起眼,但在跑一个需要40分钟的长任务时,能避免把磁盘空间打满的次生事故。自己写脚本时,务必要把日志输出看作一等公民来设计。
这个脚本后续还可以扩展的地方有不少,比如在循环内部加一个“当前时间超过凌晨2点就暂停”的判断,或者把批大小做成动态自适应,根据上一批的实际执行耗时自动调整。每个人的业务场景不同,但只要你理解了主键切片的原理和事务优先级的取舍,套用起来并不难。
