批量更新这种事,最怕一把梭。早几年我自己在后台跑批时就吃过亏:一条 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 等主流模型。
