我有一个习惯:每次接到“全量更新”的需求,第一反应不是马上写SQL开干,而是先问一句“这条SQL明天再跑行不行”。为什么这么谨慎?因为我在生产环境吃过一次大亏。那年某业务方提了个需求,要把一张7000万行的订单表里某个状态字段统一改成新值。当时觉得简单,一条UPDATE下去,结果不到半分钟整个实例的CPU被打满,连接数暴涨,业务侧下单、支付全部报错,最后我手动KILL了这条SQL,清理了将近二十分钟undo日志才恢复过来。
那个晚上之后我就意识到,MySQL里对百万级、千万级表做全量更新,绝对不是一个“会不会写UPDATE”的问题,而是一个涉及锁机制、日志落盘、索引维护、主从延迟、回滚段膨胀的综合性工程问题。这篇就把我这些年处理过的真实案例、踩过的坑、以及最终沉淀下来的可落地方案完整梳理一遍,给正准备对大数据量表做全量更新的同学一个参考。内容不局限于某一种写法,会覆盖分批更新、参数调整、临时表替换和在线变更工具这几条路线,并解释清楚每条路线背后的取舍逻辑。
1. 一条UPDATE是如何拖垮整个实例的:从故障现场还原到锁本质
很多人在本地库、测试库上更新几万条数据,感觉MySQL也就那么回事。一旦换成几千万行、几百GB的表,同样的SQL跑出来的结果完全不一样。这里先复盘一下那次故障,把全量更新的风险点一个个挑明。
1.1 故障现场:我跑了一条“看起来没问题”的UPDATE
当时的表结构大致是:
CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `user_id` bigint(20) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0', `is_sync` tinyint(4) NOT NULL DEFAULT '0', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB;需求是把is_sync = 0的老数据全量置为1,已同步的is_sync = 1的数据不动。我最初写的SQL是这样的:
UPDATE t_order SET is_sync = 1 WHERE is_sync = 0;逻辑上完全正确,索引上也有is_sync的隐性扫描需求,但问题就在于is_sync的区分度太差。表里绝大多数行都是is_sync = 0,优化器看了一眼,估算扫描成本之后直接选择了全表扫描。结果就是:InnoDB从聚簇索引的第一条记录开始,逐条判断、逐条加锁、逐条更新,事务迟迟不提交,行锁越积越多。
当时show processlist里能看到大量会话卡在Waiting for lock,这些会话都是正常的业务读写。更吓人的是SHOW ENGINE INNODB STATUS里显示History list length从几百一路涨到几万,回滚段疯狂膨胀,磁盘空间也在迅速下降。整个系统陷入了恶性循环:锁等待越多,堆积的请求越多,CPU和内存消耗越大,SQL跑得越慢。
1.2 InnoDB锁机制:为什么行锁会演变成“锁全表”的效果
InnoDB的行锁是挂在索引记录上的。听起来很美好:只锁你更新的那些行。但这里有个容易被忽略的前提——你得先找到那些行。
当UPDATE无法使用有效索引定位到目标行时,优化器会选择全表扫描或全索引扫描。扫描过程中,InnoDB会逐行读取聚簇索引记录,并对满足条件的记录加锁。在RC(Read Committed)隔离级别下,不匹配的记录会在判断后立即释放锁;但在RR(Repeatable Read)隔离级别下,为了支持半一致读和间隙锁,加锁的范围会被放大,甚至会把扫描路径上的记录和间隙一起锁住。
所以,一条看似只更新“少量行”的SQL,在实际执行时可能锁住了大量无关记录。外部会话一旦需要访问这些记录,就会立刻进入锁等待队列。
这里还要多说一句:MySQL的锁等待是有超时时间的,默认innodb_lock_wait_timeout = 50秒。如果一条业务SQL在50秒内拿不到锁,会直接报Lock wait timeout exceeded错误并回滚。结果就是,原本只想做一次全量更新的我,不小心把线上所有涉及到这张表的请求都“教育”了一遍。
提示:判断一条UPDATE会不会锁住太多行,最直接的办法是先跑一遍等价的SELECT,用EXPLAIN看它的扫描类型。如果type=ALL或type=INDEX,扫描行数又接近全表,那这条UPDATE几乎一定会引发大面积锁冲突。
2. 全量更新的成本到底花在哪:三个你必须算清的账
很多开发者的直觉是“反正就是改一列的值,数据量虽然大,但数据库应该能承受”。这种直觉在百万级以下勉强成立,到了千万级,你必须要认清全量更新背后的三笔硬成本。
2.1 并发账:行锁与间隙锁对在线业务的影响
第一笔账是并发账。对一个几千万行的表做全量更新,哪怕你分批做,每一批UPDATE都会持有一批行锁直到事务提交。如果业务对这张表的读写非常频繁,这些锁会直接影响线上延迟。
举个我后来遇到过的例子:某个商品表1200万行,每天凌晨有定时任务做价格全量更新。最初用一条UPDATE直接跑,耗时接近20分钟。这20分钟里,前台商品详情页的读请求虽然走的是主库(没做读写分离),但只要命中被更新行,就会卡在锁等待上。用户打开页面转圈,反馈投诉一晚上来了几百条。
后来我把更新拆成每批2000行的事务,每批执行完立即提交并sleep 0.2秒。整体耗时虽然变长到35分钟,但单次持锁时间只有几十毫秒,业务侧几乎感知不到异常。这就是“用总时长换可用性”的典型取舍。
2.2 日志账:binlog、redo log与undo log的三重压力
第二笔账是日志账。InnoDB的每一次数据页修改,至少会产生三条日志流:
- redo log:崩溃恢复用,记录物理页变更。全量更新产生的redo会持续刷盘,如果
innodb_flush_log_at_trx_commit = 1,每个事务提交都要fsync一次,大批量小事务模式下磁盘IO压力会很大。 - binlog:复制和恢复用,记录逻辑SQL或行变更。全量更新的binlog体量通常是数据体量的1.5到3倍,这些日志要同步给从库,会造成主从延迟。
- undo log:回滚和MVCC用,记录变更前的数据版本。更新多少行,undo里就要存多少份旧版本数据。如果更新中途失败回滚,InnoDB就要把这些undo一条条反向应用,这个过程可能比正向更新还要慢。
我见过一个极端案例,有人对一张5GB的表做全量UPDATE,跑到一半因为磁盘空间不足直接宕机。崩溃恢复阶段,InnoDB必须用redo重放未完成的事务,再加上undo回滚已更新但未提交的数据,整个恢复过程花了将近一个小时。从那以后,我对所有全量更新任务的第一要求就是“估算磁盘余量至少是表体积的2倍”。
2.3 索引账:二级索引更新是隐藏的CPU杀手
第三笔账是索引账。很多人天真地以为“我只更新一列,索引不受影响”。这句话只有在被更新列本身不在任何二级索引里时才成立。
InnoDB的二级索引叶子节点存储的是索引键值和对应的主键值。如果你更新的列刚好是某个二级索引的组成部分,那么MySQL不仅要修改聚簇索引里的记录,还要删除旧的二级索引项、插入新的二级索引项。一个二级索引就要做一次删除加插入,两个索引就是两次。这背后的随机IO、页分裂、页合并开销,远比更新聚簇索引本身高得多。
举一个直观的数据:我测试过对一个1200万行的表做“全表某列+1”的更新。当这列没有二级索引时,分批更新总耗时约40分钟;当这列上有一个二级索引时,同样分批更新总耗时飙升到110分钟。索引维护的隐性成本,不实测是很难想象到的。
3. 分批更新方案:从SQL设计到进度监控
如果你评估下来,这次全量更新必须走UPDATE路线,那么分批更新是目前兼顾安全与工程成本的最优解。核心思路只有一句话:把一个大事务拆成无数个小事务,每批及时提交,减少锁持有时间与undo堆积,并通过节奏控制让主从延迟可控。
3.1 主键范围分片:最稳定也最容易被忽略的写法
分批更新最常见、最稳妥的写法是基于主键范围分片。以订单表为例:
-- 第一次执行前先记录当前主键范围 SELECT MIN(id), MAX(id) FROM t_order WHERE is_sync = 0;假设范围是1到70000000,每批处理5000行,可以这样写:
UPDATE t_order SET is_sync = 1 WHERE id BETWEEN 1 AND 5000 AND is_sync = 0;第一批评完后提交,接着处理id BETWEEN 5001 AND 10000,依此类推。这个方式的优点有两个:
- 走主键,扫描路径稳定。主键是聚簇索引,范围扫描效率极高,不会因为数据分布不均产生全表扫描风险。
- 每批之间天然隔离。即使某一批因为特殊情况失败,其他批次的状态是已提交的,已完成后可以记录断点并从失败批次继续重跑。
但是在实际操作中,有个细节很容易踩坑:WHERE条件里的is_sync = 0不能丢。如果某一批里恰好有行已经被其他任务更新过,加了该条件就不会重复更新,不加就可能出现重复消费、覆盖更新的问题。
用存储过程把这些批次串联起来更省心。我常用的模板是:
DELIMITER $$ CREATE PROCEDURE `sp_batch_update_order_sync`( IN p_min_id BIGINT, IN p_max_id BIGINT, IN p_batch_size INT, IN p_sleep_seconds DECIMAL(10,2) ) BEGIN DECLARE v_start_id BIGINT DEFAULT p_min_id; DECIMAL v_end_id BIGINT; WHILE v_start_id <= p_max_id DO SET v_end_id = v_start_id + p_batch_size - 1; UPDATE t_order SET is_sync = 1 WHERE id BETWEEN v_start_id AND v_end_id AND is_sync = 0; COMMIT; SET v_start_id = v_end_id + 1; IF p_sleep_seconds > 0 THEN DO SLEEP(p_sleep_seconds); END IF; END WHILE; END$$ DELIMITER ;这里要注意,存储过程里COMMIT之前,一定要确保没有开启隐式提交干扰事务边界。一般我会在存储过程开头显式执行SET autocommit = 0,避免连接默认的自动提交导致批次边界失效。
3.2 游标循环更新:适合业务条件复杂但主键范围不好切分的场景
有一种情况不适合主键范围分片:更新条件不依赖主键范围,而是依赖某个业务字段,比如“更新最近30天内注册且等级大于3的用户”。这类条件在主键上不是连续的,硬用BETWEEN会导致大量无效扫描。
这时候可以用游标逐条或逐小批取主键:
-- 伪代码示意 DECLARE done INT DEFAULT FALSE; DECLARE v_id BIGINT; DECLARE cur CURSOR FOR SELECT id FROM t_user WHERE register_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND level > 3 AND flag = 0; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur; read_loop: LOOP FETCH cur INTO v_id; IF done THEN LEAVE read_loop; END IF; UPDATE t_user SET flag = 1 WHERE id = v_id; END LOOP; CLOSE cur;但这种“逐行更新”性能很差,可能一次提交一行动辄几百万次交互。更好的做法是:先把符合条件的主键插入一张临时表,拿到一个稳定的ID集合,然后再按临时表里的ID分批UPDATE。这样既绕开了复杂条件重复计算的问题,又能回到主键分批的轨道上来。
3.3 分批大小与间隔怎么定:不要拍脑袋,要看三个指标
很多博客会直接告诉你“建议每批5000条”。但我做过很多测试后发现,分批大小根本不存在固定最优值,它取决于三个动态指标:
- 行的平均长度。行越长,一个数据页能容纳的行数越少,同样5000行更新占用的缓冲池页面和锁范围就越大。如果一个表的行平均长度超过2KB,建议每批1000到2000行;如果行平均长度在300字节以内,5000到10000行都可以尝试。
- 主从延迟的容忍度。每批UPDATE都会生成binlog,从库SQL线程应用时需要逐条回放。观察
SHOW SLAVE STATUS里的Seconds_Behind_Master,如果延迟持续上升,说明批次太大或提交太频繁,需要调小批次或拉长间隔。 - 在线业务的锁等待情况。更新期间监控
information_schema.innodb_trx,如果发现大量事务处于锁等待状态,说明单批持锁时间过长,应该减小批次。
我自己比较保守的打法是:第一批用500行试水,观察锁等待和从库延迟,正常的话调整为1000行,继续观察,逐步加到2000到5000行。这种“慢启动”策略虽然前期耗时多一些,但能最大程度避免一开始就把数据库压垮。
3.4 断点续跑与进度监控:像跑数据任务一样管理全量更新
全量更新跑到一半,最怕的就是数据库重启、连接中断或事务回滚。如果没有断点机制,整个任务可能要白跑。推荐做法是维护一张执行进度表:
CREATE TABLE `t_update_progress` ( `task_name` varchar(100) NOT NULL, `last_done_id` bigint(20) NOT NULL, `target_max_id` bigint(20) NOT NULL, `batch_size` int(11) NOT NULL, `status` varchar(20) NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`task_name`) ) ENGINE=InnoDB;每完成一批UPDATE,就把当前最大的主键值写入这张表。下次任务启动时,直接从这个断点继续,不需要从头扫描。
监控方面,我常用的一条SQL是:
SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds, trx_rows_modified FROM information_schema.innodb_trx;如果能看到当前正在执行的大事务、已经修改的行数,就能预估剩余时间。另外,SHOW ENGINE INNODB STATUS里的History list length也要盯一下,这个值如果持续暴涨,基本可以断定undo log堆积失控,得紧急降速或暂停。
4. 更新前必须做的索引与外键体检:尽量减少不必要的索引维护压力
很多人把全量更新的准备工作等同于“写SQL”,其实SQL之外的索引和外键体检,才是决定任务成败的关键。这部分如果忽略,后面很容易出现半路跑不动、跑完性能反而下降的尴尬局面。
4.1 被更新列涉及的索引:到底该留还是该删
如果被更新列是二级索引的一部分,我的建议是:评估该索引在这个更新任务缓解后的价值,如果价值不大,先删掉,等更新完再重建。理由前面说过,更新索引列时,InnoDB会删除旧索引项再插入新索引项,会产生大量随机IO和页分裂。删除索引后再更新,只动聚簇索引;更新完成后再重建索引,可以利用批量排序算法高效构建,总体耗时反而更短。
举个实际对比:我处理过一个1200万行的用户表,要更新member_level列,而该列上恰有一个二级索引。保留索引分批更新,总耗时约2小时;DROP索引后分批更新,耗时约45分钟;更新完重新创建索引,耗时约3分钟。整体节约了超过一半的时间。
但删索引不能拍脑袋,要注意三点:
- 确认该索引没有被其他SQL的WHERE或ORDER BY依赖。删掉后可能导致线上慢查询突然暴露。
- 删索引要在低峰期操作。ALTER TABLE本身也会拿元数据锁,长时间不提交会阻塞该表的所有DML和DDL。
- 重建索引时同样要分批或选择工具完成,或者直接用
ALGORITHM=INPLACE, LOCK=NONE在线方式,避免长时间锁表。
4.2 触发器、外键和生成列:三个隐蔽的“隐藏成本”
更新一张千万级表,如果这张表上有触发器,那么每一次UPDATE都会额外触发触发器里的SQL。我曾经遇到一张只有几百万行的日志表,上面挂了一个很复杂的BEFORE INSERT触发器,每次全量更新时,这个触发器的执行时间比更新本身还长。排查了很久才发现罪魁祸首。实在无法删触发器的场景,尽量改成先INSERT到临时表、再统一处理,绕开触发器。
外键的影响更隐蔽。MySQL的InnoDB外键约束在更新父表时,会自动去检查子表是否有匹配记录。这个检查是逐行查找,在千万级父表上成本极高。如果更新过程中遇到Foreign key constraint fails,那说明数据本身有问题,需要先修数据再更新。我的经验是:全量更新前把外键检查临时关闭(SET FOREIGN_KEY_CHECKS = 0),更新完重新开启,但前提是你确认数据逻辑一致性没问题。
生成列(Generated Column)也要注意。如果你更新的是一个普通列,而表里有STORED生成列依赖这个普通列,那么每次更新都会级联重新计算生成列的值并写入磁盘。这个计算如果很复杂,等于给全量更新额外加了一倍甚至两倍的CPU压力。
4.3 备份与灰度验证:宁可慢一点也要有后悔药
无论你多自信,全量更新前一定要做备份。最基本的要求是mysqldump物理备份或至少导出被更新表的关键列:
mysqldump -uroot -p --single-transaction --quick --no-create-info t_db t_order > /backup/t_order_before_update_$(date +%Y%m%d%H%M%S).sql如果是千万级表,mysqldump会很慢,更推荐用mydumper或者直接依赖云厂商的快照备份功能,在低峰期打一个文件系统快照。快照的好处是恢复粒度精确到秒,不像逻辑备份需要重放SQL。
灰度这一步我也强烈建议保留。具体做法是先选一个只影响测试环境的ID范围,或者干脆先导出一万行到一个同结构的新表里跑一遍批量更新脚本,观察耗时和锁情况,再决定是否生产环境全量执行。
5. 参数调优与低峰期执行策略:让CPU和IO尽量“平滑”
分批更新解决了事务和锁的问题,但如果你所在的环境参数配置不合理,照样可能跑得很痛苦。全量更新期间,在确认可以接受短期风险的前提下,可以临时调整几个关键参数。
5.1 临时调整的三个关键参数:flush策略、锁超时与缓冲池
下面这几个参数,我不建议长期修改,只建议在低峰期的全量更新任务窗口内临时调整,任务结束后改回来。
| 参数名 | 建议调整值 | 调整理由 | 风险提示 |
|---|---|---|---|
innodb_flush_log_at_trx_commit | 从1临时改为2 | 降低每个事务提交时的fsync频率,减少磁盘IO压力 | 数据库异常宕机会丢失1秒左右已提交事务的数据 |
sync_binlog | 从1临时改为0 | binlog不强制每个事务持久化,缓解磁盘写放大 | 宕机时binlog可能落后于实际数据,影响恢复精度 |
innodb_lock_wait_timeout | 从50临时改为5 | 全量更新期间不希望业务SQL等待太久,快速失败让应用更快感知 | 如果一次正常业务操作真的需要超过5秒等锁,会误伤 |
这三个参数组合使用,能让大批量更新对磁盘的冲击明显下降。但请注意,只要业务对数据零丢失有严格约束,比如支付、订单核心链路,就不要轻易把前两个参数改成0或2,否则一次意外宕机的数据丢失责任谁都扛不起。
5.2 主从延迟与读写分离的矛盾:从库落后半小时怎么办
生产环境基本都有主从架构。全量更新生成的海量binlog会让从库SQL线程长时间追不上,Seconds_Behind_Master可能从几秒一路涨到几十分钟。如果业务对从库的读一致性有要求,这会直接导致线上读不到最新数据。
我的处理策略是“让主从延迟可控,而不是完全消除”:
- 从库所在机器的磁盘如果是机械硬盘,强烈建议先把从库的
innodb_flush_log_at_trx_commit临时改为2,减少从库自身IO压力。 - 控制主库的更新节奏,给每个批次之间加sleep。sleep时间不是随便加的,我一般是:从库延迟从X秒涨到X+Y秒时,sleep从0.2秒提升到0.5秒或1秒,等延迟回落再恢复。
- 实在无法控制延迟的场景,考虑临时把该从库从负载均衡中摘掉,等延迟追平再挂回来。
5.3 执行窗口选择:没有绝对的“低峰期”,只有相对的“低峰期”
全量更新最理想的执行窗口是业务请求最少的时间段,但这个“最少”每个系统不一样。一个电商系统,可能凌晨2点到5点是低谷;一个金融系统,可能只有凌晨4点到6点才是真正的空窗。
建议你在确定窗口之前,拉一下监控系统里近7天的QPS和活跃会话曲线,找出那个“峰值最低且持续时间最长”的时段。不要在业务方说“随便找个时间”就信了——他们不知道数据库的忙碌曲线,你作为执行者必须自己判断。
另外,执行前发个通知群里同步一下,写明更新窗口、预计影响、回滚预案。这个动作在关键时刻能省掉很多不必要的解释成本。
6. 千万级重写的终极方案:临时表替换与在线变更工具
如果全量更新的目标是“把一列的值大面积重算”,比如所有用户积分统一乘2、所有订单状态推倒重来,其实有一种更快的思路:别在原来的表上硬UPDATE,而是重建一张新表,导入新数据,然后改表名替换。这听起来像是DDL,但它本质上解决的还是数据更新的问题,而且往往比UPDATE快得多。
6.1 重建表方案的完整流程与成本对比
重建表的核心步骤是:
-- 1. 创建新表 CREATE TABLE t_order_new LIKE t_order; -- 2. 用INSERT...SELECT把数据写入新表,在写入过程中完成字段值转换 INSERT INTO t_order_new (id, order_no, user_id, status, is_sync, create_time) SELECT id, order_no, user_id, status, 1, create_time FROM t_order; -- 3. 原子替换表名 RENAME TABLE t_order TO t_order_old_bak, t_order_new TO t_order;这套方案为什么快?因为INSERT...SELECT走的是顺序写入聚簇索引的路径,InnoDB的批量插入会自动顺序化,不存在逐行修改二级索引项的问题。如果目标表上有二级索引,甚至可以采用先建表、后插数据、最后统一加索引的方式,让索引构建走批量排序算法,速度快得多。
我做过一组对比测试:同样是1200万行、行平均长度500字节的表:
| 方案 | 耗时 | 对在线业务影响 |
|---|---|---|
| 单条UPDATE全量执行 | 20分钟+锁表 | 业务严重阻塞 |
| 分批UPDATE(每批5000行+sleep0.2秒) | 约40-55分钟 | 轻微锁影响 |
| 临时表+INSERT...SELECT+RENAME | 约8-12分钟 | 仅在RENAME瞬间有毫秒级元数据锁影响 |
临时表方案在千万级全量更新场景下往往是最优解。
6.2 RENAME的原子性与外键/视图依赖:切换时最容易翻车的点
表名替换看起来简单,但有两个坑必须提前处理:
- 外键约束问题。如果原表是其他表的父表,RENAME之后,子表的外键定义仍指向旧的表名,需要同步修改子表外键。更麻烦的是临时表t_order_new上还没有建立外键关系,RENAME前要把外键也重建好。这个问题在真实环境非常容易忽略,我建议切换前先查一下外键依赖:
SELECT table_name, column_name, constraint_name, referenced_table_name FROM information_schema.key_column_usage WHERE referenced_table_name = 't_order';- 视图与存储过程依赖。视图和存储过程内部引用的表名是硬编码的。RENAME后,依赖原表名的对象会立刻失效。切换前需要评估所有视图、触发器、存储过程,必要时先DROP再重建。
6.3 在线变更工具的思路:pt-ost与gh-ost的处理逻辑
如果你没有足够的停机时间,又必须对在线表做全量更新/重写,可以参考两个开源工具的底层逻辑:pt-online-schema-change(pt-ost)和gh-ost。
pt-ost的做法是:创建一个与目标表结构一致的新表,然后通过触发器把原表上的增量变更同步到新表,再把历史数据按主键分批INSERT...SELECT导过去,最后通过RENAME切换。gh-ost的做法更激进:直接从binlog里解析增量变更,不依赖触发器,对原库的侵入更小。
这两个工具本意是解决Online DDL,但套用在“全量更新”上同样成立。如果你的全量更新需要几小时才能完成,而业务又完全不能停,你可以用同样的思路自己实现:创建新表、开启binlog监听、把增量变更同步到新表、批量回放历史数据、最后原子切换。这套方案我在一个2亿行的核心表上实践过,切换过程业务几乎无感,唯一的代价是前期代码开发和测试成本比较高。
那具体怎么判断该用哪种方案?我一般按这个标准来:
- 表行数在千万以下,更新列不涉及索引,业务可接受几十秒锁影响,直接分批UPDATE即可。
- 表行数在千万以上,更新逻辑是“整列大面积变换”,优先考虑临时表替换。
- 表行数在千万以上,业务要求全程在线,优先使用pt-ost或gh-ost这类工具。
7. 一批在千万级表上做全量更新时,我反复检验过的执行细节
前面几章讲的是框架和路线,这一章把我在实操中反复踩过、验证过、最后沉淀下来的一些零碎细节集中整理一下。这些东西单独看都小,拼在一起却能显著影响一次全量更新的成败。
7.1 唯一索引冲突:分批更新时最容易出现的“意外中断”
如果你更新的列上有唯一索引,分批更新时很可能出现“第一条插入成功、第二条插入冲突”的情况。这种事在全量更新里特别坑,因为同一批里两条记录互相冲突,或者更新目标值恰好与历史值重复,都会导致整个批次回滚。
应对方法:分批前先跑一遍查重SQL,把冲突数据单独拎出来处理:
SELECT is_sync, COUNT(*) AS cnt FROM t_order GROUP BY is_sync HAVING cnt > 1;如果确实存在历史脏数据,先清洗,再开启全量更新。
7.2 SQL中的WHERE条件与LIMIT组合:MySQL 8.0下的新玩法
MySQL 8.0开始,UPDATE语句真正支持了带LIMIT的语法,例如:
UPDATE t_order SET is_sync = 1 WHERE is_sync = 0 LIMIT 5000;这条SQL会只更新最多5000条符合条件的记录。虽然LIMIT在UPDATE里不保证“每次取的是同一个子集”,但结合循环调用,可以做到有边界地渐进更新,适合那些数据分布非常散、主键不好切分的场景。
但用LIMIT时一定要注意:不要在同一事务里反复循环执行同一条LIMIT语句。因为已更新的行已经变成is_sync = 1,下次执行时条件is_sync = 0会自动跳过它们,倒也不会死循环,但性能上会反复扫描全表找“还没被更新的行”,效率远不如主键BETWEEN。
7.3 更新后的数据校验:别只盯着执行成功,还要看影响行数
全量更新完成后,校验环节千万别省。我的校验习惯是执行前后各跑一条聚合SQL对比:
-- 更新前 SELECT COUNT(*) AS total, SUM(is_sync = 1) AS already_sync, SUM(is_sync = 0) AS pending_sync FROM t_order; -- 更新后 SELECT COUNT(*) AS total, SUM(is_sync = 1) AS already_sync, SUM(is_sync = 0) AS pending_sync FROM t_order;如果更新后pending_sync不为0,说明有部分行因为条件不满足或其他原因没被更新到,需要查一下原因。如果total总数都不一致,那问题更严重,得立刻查备份和binlog。
7.4 别忘了SHOW ENGINE INNODB STATUS:排查锁问题的第一抓手
最后分享一个排查习惯。无论分批更新还是临时表方案,只要我怀疑锁有问题,第一件事永远是执行:
SHOW ENGINE INNODB STATUS\G重点看TRANSACTIONS段的History list length、LOCK WAIT相关的TRX ID和持锁事务的SQL文本。很多锁问题,光看报错信息猜半天,不如直接看InnoDB事务状态来得直观。
那次事故复盘之后,我给自己定了一个规矩:凡是预估执行时间超过5分钟的全量更新,必须写成带进度表、可断点续跑的脚本,并配套监控告警。这不是小题大做,而是在一张几千万行、承载核心业务流的表上,一条鲁莽的UPDATE就可能让整个团队半夜爬起来处理故障。根据我的个人经验,最稳妥的路线永远是:能重建表就不硬UPDATE,必须UPDATE就分批加监控,急切的变更先推迟到低峰期再验证。数据量越大,越要把“稳”字放在第一位。