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,这样速度最快,因为减少了提交次数,每批提交一次会拖慢速度。这种说法在“离线跑批,且不在乎对其他会话的影响”时是正确的。但我们现在讨论
