批量更新这种事,最怕一把梭。早几年我自己在后台跑批时就吃过亏:一条 UPDATE 直接更新了几十万行,结果表被锁住,前端查询全卡住,主库 binlog 疯涨,从库延迟更是飙到几百秒,最后只能硬着头皮等它滚完。后来学乖了,凡是耗时长、涉及行数大的更新,一律改成化整为零、分批执行。这篇就分享一个我在 MySQL 里处理大事务时常用的简单 demo 脚本,核心思想是每次只更新一小批,提交后再处理下一批,循环到没有剩余数据为止。适合刚接触大事务处理、或者在 MySQL 里写过跑批但还没踩过锁坑的开发和运维同学参考。
1. 为什么不能一把梭:大事务的连锁反应
1.1 一条 UPDATE 几十万行时,MySQL 内部发生了什么
先说一个很容易被忽略的点:MySQL 里执行一条 UPDATE,就算只涉及一张表,在 InnoDB 引擎下也是一个完整事务。事务里的行锁不是一次拿完的,而是随着扫描和更新逐步获取。当某个条件命中了非常多的行时,行锁数量就可能达到几十万甚至上百万。行锁要占用内存,锁信息还要进到事务系统里统一管理,这些都会让主库负载快速上升。
更麻烦的是锁等待。如果在执行大事务期间,有其他业务也改了这组数据,新事务只能排队等锁。尤其是常见的 status 字段更新场景,很多查询都会走同一批索引和记录,一旦大事务占着锁不放,整个表的更新并发能力基本就瘫痪了。前端表现就是接口耗时暴增,慢查询一片一片出现。另一个我不太容易察觉、但实际很致命的问题是 binlog 和回滚段:一个大事务会累积大量 redo log、undo log 和 binlog,提交时要做两阶段提交,即使中途失败,回滚也要把之前的修改全部撤销,这个回滚成本同样会拖垮 IO。从库那边则因为同步 binlog 时也要应用同一个事务,等于在主库和从库各烧一次资源。主库跑了十分钟的大事务,从库可能花更长。
1.2 “化整为零”的本质:把事务粒度控制在安全范围
化整为零并不是简单地把一条 SQL 写成多条 SQL,而是要控制事务的粒度。核心思路是这样的:每次从待处理数据中取固定数量的行,执行 UPDATE 或用 DELETE,然后立刻提交事务,等一小会再取下一批。因为每个小事务的行数有限,锁范围就小,日志量小,commit 也快,即使某一批失败,前一批已经正常提交,不会把自己拖入整体回滚的深渊。
分批跑批有几个隐含的原则值得记住。第一,批大小必须有上限,不能用“一次处理余下全部”这种思维;第二,每一批之间要能明确看到边界,最好是每批独立提交;第三,整个处理逻辑要能重复执行而不产生脏数据,也就是幂等。如果脚本跑到一半断了,重启后已经完成的批次不会重复处理,还没处理的部分可以被下一轮继续捞起来。要做到幂等,核心是控制好筛选条件,让“已经被处理过”的数据不会再被选中。平时常用做法是给表增加一个处理标记字段,或者在更新时把状态值从旧值改成新值,这样下一次循环时旧状态已经不存在了。
1.3 适合拆批的典型场景
我平时会优先考虑拆批的场景,基本都是“不需要整体原子性”的批量维护操作。最典型的几类包括:给历史订单打归档标记、把很久之前的数据软删除、清洗冗余字段、批量迁移状态,以及清理过期日志或流水。这类操作允许中间过程出现“一部分已经更新、一部分还没更新”的状态,中间状态不会影响业务,只要最终结果是把所有符合条件的数据都处理完就行。
但也不是所有更新都适合拆批。如果业务要求多张表之间的变更必须同时生效,比如账户扣款和余额流水必须同事务提交,那就不能只图单表效率拆成小事务,否则中间出现异常时会出现部分成功部分失败的脏逻辑。所以写脚本前先问自己一个问题:这个操作可以接受中间状态吗?如果答案是可以,那拆批就是安全且有价值的优化方向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. demo 场景与脚本形态怎么选
2.1 先约定一个明确的示例表
为了让你照着跑的时候不迷路,我定义一张非常简单的订单表,库名用 demo_db,表名用 order_info。它大概长这样:
sql复制CREATE TABLE demo_db.order_info (
id BIGINT NOT NULL AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
archive_time DATETIME NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_status (status)
) ENGINE=InnoDB;
字段含义很简单:status 表示订单当前状态,我把过期订单设置为 expired;archive_time 表示归档时间,为空说明还没进入归档流程。业务需求是把所有已经过期但未归档的订单,逐批改成 archived,同时写入归档时间。这个操作天然满足幂等条件:只要 archive_time 被写入,该行就不会再被后续批次选中。
2.2 用存储过程实现还是用外部 Shell 脚本实现
实现分批执行可以走两条路线,一条是写 MySQL 存储过程,另一条是写外部脚本,比如 Shell 脚本。存储过程的好处是封装在数据库内部,后续可以用 MySQL Event Scheduler 定时调度,适合固定周期的重复维护;缺点是 debug 稍微麻烦,而且初学者容易把 autocommit 和 COMMIT 的关系搞混。外部 Shell 脚本的好处是逻辑透明,每跑一批都能输出进度,方便手工观察,也方便接到 cron 或 Jenkins 任务里。如果中途出现异常,直接在 shell 层打断就行,已提交批次不会回滚,机制非常清晰。
对于这篇的 demo,我推荐先用 Shell 脚本。原因是跑批类脚本的调试过程本身很重要,你需要在终端直观看到“每批影响了多少行、累计多少行、什么时候结束”。等彻底理解了这个过程后,再往存储过程里迁移就不难。脚本的另一个好处是即使你不熟悉 MySQL 存储结构,也能通过 mysql 命令行逐步执行,排错成本低很多。
2.3 先记住一个前提:单表 UPDATE 支持 ORDER BY 和 LIMIT
很多初学者以为 MySQL 只能是 SELECT 才支持 LIMIT,其实 MySQL 的单表 UPDATE 语法也支持 ORDER BY 和 LIMIT。例如下面这条 SQL,就是按 id 升序选择符合条件的 5000 行并更新它们,最多只会动 5000 条:
sql复制UPDATE demo_db.order_info
SET status = 'archived', archive_time = NOW()
WHERE status = 'expired' AND archive_time IS NULL
ORDER BY id
LIMIT 5000;
这是这个 demo 脚本能够成立的根基。没有这个能力,就需要用子查询把主键集合筛出来再回表更新,代码会绕很多。有了 UPDATE ... LIMIT,外部脚本的逻辑就可以做得非常短小。如果你用的是多表 UPDATE ... JOIN,那确实不支持 ORDER BY 和 LIMIT,后面的章节里我会单独讲替代方案。
3. shell 脚本完整实现与逐行拆解
3.1 先准备一点可观测的数据
测试之前,先给 order_info 表插一点过期数据。如果你已经有真实数据,可以跳过这一步。为了方便演示,我用了一个最基础的造数方式:
sql复制USE demo_db;
SET @cnt = 0;
INSERT INTO order_info(order_no, user_id, amount, status, archive_time, created_at)
SELECT CONCAT('order_', @cnt := @cnt + 1),
10000 + (@cnt % 1000),
ROUND(RAND() * 1000, 2),
'expired',
NULL,
NOW() - INTERVAL (@cnt % 365) DAY
FROM information_schema.tables AS a
CROSS JOIN information_schema.tables AS b
LIMIT 200000;
这段语句会根据 information_schema.tables 的组合产生 20 万行测试数据,初始 status 都是 expired,archive_time 是 NULL。正式环境不要这么干,但在本地 MySQL 里验证脚本逻辑足够了。执行完成后可以快速确认一下:
sql复制SELECT COUNT(*) FROM demo_db.order_info WHERE status = 'expired' AND archive_time IS NULL;
我本地跑完是 20 万行。接下来就进入主脚本环节。
3.2 分批执行主脚本
这份脚本我没有加太多花哨功能,只保留最核心的循环、进度输出和正常退出判断。建议把连接参数放到 ~/.my.cnf 里,而不是直接写在命令行里,避免密码通过进程列表泄露。脚本内容如下:
bash复制#!/bin/bash
# MySQL 分批执行大事务 demo
# 作用:把 order_info 表中 status='expired' 且 archive_time IS NULL 的数据分批归档
DB_NAME="demo_db"
BATCH_SIZE=5000
SLEEP_TIME=1
# 假设本机已经配置好 mysql 客户端,并且 ~/.my.cnf 里有可用连接参数
MYSQL_CMD="mysql --defaults-file=${HOME}/.my.cnf -N --batch -e"
total=0
while true; do
SQL="UPDATE ${DB_NAME}.order_info
SET status = 'archived', archive_time = NOW()
WHERE status = 'expired' AND archive_time IS NULL
ORDER BY id
LIMIT ${BATCH_SIZE};
SELECT ROW_COUNT();"
affected=$(${MYSQL_CMD} "${SQL}")
# 如果命令执行报错,stderr 会打印错误,stdout 可能没有数字返回
if ! echo "${affected}" | grep -qE '^[0-9]+$'; then
echo "执行出错,输出内容为:${affected}"
break
fi
# ROW_COUNT() 返回 0 说明已经没有符合条件的数据
if [ "${affected}" -eq 0 ]; then
echo "没有新的待处理数据,退出循环"
break
fi
total=$((total + affected))
echo "本批更新 ${affected} 行,累计 ${total} 行"
sleep "${SLEEP_TIME}"
done
echo "批量更新结束,总处理行数:${total}"
运行前记得给脚本加执行权限:
bash复制chmod +x batch_update_demo.sh
./batch_update_demo.sh
脚本的关键点是每条 SQL 里面同时执行了 UPDATE 和 SELECT ROW_COUNT(),而且这两个操作是在同一个 mysql 会话里先后完成的,所以 ROW_COUNT() 返回的就是本次 UPDATE 真正影响的行数。如果这个 SQL 执行报错,mysql 会把错误输出到 stderr,而脚本捕获的 stdout 可能为空,接下来就会被 grep -qE 拦住,不会进入死循环。
3.3 核心 SQL 为什么不会把同一行反复更新
回到 SQL 本身,每次循环执行的语句都是:
sql复制UPDATE demo_db.order_info
SET status = 'archived', archive_time = NOW()
WHERE status = 'expired' AND archive_time IS NULL
ORDER BY id
LIMIT 5000;
因为 UPDATE 先把 status 改成了 archived,又把 archive_time 写成了当前时间,那么这批数据在下一轮循环里就不再满足 status='expired' AND archive_time IS NULL 了,所以永远不会被重新捞出来。ORDER BY id 则保证了处理顺序按主键推进,不会出现随机乱跳。实际生产环境里,如果一次跑批卡了一部分,下次重新执行脚本,已经处理的会被跳过,还没有处理的会继续从最小符合条件的 id 开始。这比游标逐行处理要高效得多,也比一次性 UPDATE 要安全得多。
细心的人可能会问:MySQL 的单表 UPDATE 既然支持 LIMIT,那直接用一条 UPDATE ... LIMIT 处理所有数据为什么不行?问题的关键是 LIMIT 5000 只限制当前这 5000 行。这 5000 行更新完之后,MySQL 不会自动把“还有多少行没更新”这件事告诉你,它只会结束一条 SQL。所以脚本里的 while true 才是把“批大小”和“跑批调度”结合起来的主角。这也是“化整为零”在外层脚本上的意义。
3.4 批大小和 sleep 参数怎么选
BATCH_SIZE 和 SLEEP_TIME 这两个变量是整个脚本里最值得花心思调的参数。批大小并非越大越好。如果你设置 10 万一批,那本质上又回到了一把梭的情况,事务锁的规模、回滚日志的规模仍然很大。如果你设置 100 一批,一个千万级的任务要循环十万次,脚本耗时也会非常离谱。我通常从 2000 到 10000 之间开始试,先观察单批执行耗时,再观察磁盘 IO 和锁等待情况,原则是单批执行时间控制在几百毫秒到一两秒比较理想。如果单批 UPDATE 执行时间超过了秒级,很可能当前批大小已经偏大,或者筛选条件缺少合适索引。
sleep 参数则是给数据库喘气的窗口。有些人觉得分批已经够温柔了,每批之间可以完全不等,但实际上批量任务如果一直高频提交,会不断抢占 redo 写入和 binlog fsync 的 IO 资源。高峰期跑批时,我在每批之间普遍设置 0.5 秒到 2 秒的间隔,这是一种以时间换稳定性的策略。如果跑批放在凌晨低峰期,sleep 可以设小一点,比如 0.1 秒。需要注意,sleep 并不是必需的等待时长,它保护的是从库和整体 IO 的抖动,建议保留但可以灵活调整。
4. 跑一遍看效果:重点验证事务是否真的被切开
4.1 模拟运行后的终端输出
我本地插入 20 万行测试数据后,直接执行脚本,批大小还是 5000,sleep 设置成 1 秒,输出大概是这样:
text复制本批更新 5000 行,累计 5000 行
本批更新 5000 行,累计 10000 行
本批更新 5000 行,累计 15000 行
本批更新 5000 行,累计 20000 行
...
本批更新 5000 行,累计 200000 行
没有新的待处理数据,退出循环
批量更新结束,总处理行数:200000
这 20 万行被切成了 40 个 5000 行的小事务。每批之间还 sleep 了 1 秒,所以整个任务的实际耗时相对较长,但这就是给稳定性买的保险。如果你在低峰期跑,可以把 BATCH_SIZE 调成 10000,SLEEP_TIME 调成 0.2 秒,整体会快不少。脚本结束后不要只看终端输出,还得去数据库里确认最终状态:
sql复制SELECT status, COUNT(*) FROM demo_db.order_info GROUP BY status;
SELECT COUNT(*) FROM demo_db.order_info WHERE status = 'expired' AND archive_time IS NULL;
正常情况下,执行结果应该是第一批聚合里 archived 有 20 万行,第二批汇总结果是 0。只要第二个查询返回 0,说明没有漏网数据。
4.2 怎么确认没有产生大事务
跑批过程中,我最常做的检查是从另一个会话里观察 information_schema.INNODB_TRX。因为单个大事务在事务表里能看到事务持续时间非常久,且会持有大量行锁。可以执行:
sql复制SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_query
FROM information_schema.INNODB_TRX\G
分批执行时,你会看到这个查询结果里的 trx_started 每次都是刚刚启动的时间,trx_rows_locked 也不会超过当前批的大小。如果出现一个事务启动了很长时间、锁定的行数等于几十万行,说明你的脚本设计已经走偏了,需要立刻停下来。另外还可以用 SHOW PROCESSLIST; 观察 Time 字段。一条大 SQL 的 Time 字段往往会一直增长到分钟级,而分批执行时每条 UPDATE 的时间通常都在 1 秒上下。
从库延迟也是很好的观测指标。如果你有主从架构,可以在执行批处理时定期查看从库:
sql复制SHOW SLAVE STATUS\G
主要关注 Seconds_Behind_Master。用一次性大事务更新 50 万行的时候,从库延迟通常会持续跳到几十秒甚至几百秒。拆成每批 5000 行之后,从库延迟基本能被压在很小范围内,因为每个小事务同步完了,从库会追
