
你有没有遇到过这种活一张表几百万行业务方拍拍脑袋说要批量把某个字段整体刷一遍或者把一批订单的状态从“待处理”改成“已归档”。这类场景的核心就是数据库表的批量更新也是今天要聊的优化方案的来源。数据库表还在线上跑着不敢宕机不能锁表偏偏数据量还挺大。这种场景几乎每个开发都躲不掉而最容易踩的坑就是一条UPDATE下去数据库卡死连接池被打满主从延迟飙到天上去。这篇文章就专门聊数据库表场景下做批量更新的优化方案——从最稳的兜底策略到高效的黑科技手段全部按实操流程来适合像我一样既要接需求又要背运维锅的兄弟们参考。文章不写什么高深理论只讲怎么落到生产环境里不出事。我会先从底层原理说起讲明白为什么批量更新会慢然后给出一套方案选择和取舍的判断逻辑最后直接上SQL和脚本每一步怎么执行、要注意什么都按实操流程写清楚。不管你是刚入行的开发还是已经在线上环境救过几次火的“老油条”下面这些内容都能直接用。1. 先搞清楚为什么一条UPDATE能把数据库拖垮1.1 一条UPDATE背后发生了什么我们平时写UPDATE语句程序员视角就是“改一行或者改多行”但数据库内部远没有这么简单。拿InnoDB举例当你执行UPDATE orders SET status ARCHIVED WHERE create_time 2024-01-01;MySQL要先通过WHERE条件定位到目标行。如果create_time上有二级索引那就先扫二级索引得到一批主键ID然后再回表到聚簇索引拿完整记录如果create_time上没有索引那就是全表扫描把每一行都读一遍再判断是否命中。命中的每一行不仅要修改记录本身还要处理行锁、写undo log方便回滚、写redo log保证崩溃恢复、维护二级索引更新数据页。这些动作全部发生在磁盘I/O和内存之间数据量一大磁盘和CPU都遭不住。你可以把数据库想象成图书管理员你让他把一本厚书所有页码上的“2023”改成“2024”。他必须一页一页翻翻到了还要写个批注undo在小本本上登记redo再把涉及页索引的卡片也改了。他干活的过程很规范但一定很慢。图书管理员慢一点没关系数据库在线上业务里慢牵连的就是接口超时、连接池打满甚至整个库不可用。1.2 批量更新慢的四大根源根据我个人的经验批量更新慢的原因基本可以归为四类排查的时候按这个思路走方向基本不会歪。第一大根源随机I/O。更新操作不是顺序写而是要修改分散在不同数据页上的记录。哪怕你用主键范围分批更新如果目标行跨度很大也会产生大量随机读写让SSD都扛不住。第二大根源锁竞争。InnoDB默认对更新的行加排他锁。当一条UPDATE同时命中大量行锁的持有时间会很长。如果此时有SELECT或者别的DML操作碰同一批行只能排队等待直观表现就是数据库“卡住”。第三大根源索引维护。每次更新索引列B树可能需要做节点分裂、旋转、重组。如果表上有多个二级索引等于每改一行要同时维护好几棵树。索引越多更新越慢这是实打实的物理开销。第四大根源事务日志过度膨胀。一个超大事务包含几百万条更新redo log和undo log都会急剧膨胀。如果binlog格式是row还会把每行变更前后的完整镜像都写进binlog主从同步时从库重放这些日志延迟自然飙升。这些因素不是独立存在的而是叠加在一起。遇到问题先别急着改SQL把这四个方向过一遍你基本就能判断瓶颈在哪。1.3 什么量级才需要认真考虑优化说实话几千行的更新哪怕是全表扫描在现代硬件上也就几十毫秒不值得大动干戈。我的经验是单条SQL涉及的更新行数超过5万或者事务执行时间超过1秒并且会频繁出现这时候才有必要认真考虑优化。还有一个更重要的信号线上连接池开始报警慢查询日志里频繁出现同一张表的UPDATE。等到这时候已经不是“要不要优化”的问题而是“怎么把当前这锅快糊掉的面救回来”。所以不要等到生产事故了再着急提前在方案的选型上做功课才不会在关键时刻一把梭。2. 优化方案选型先别急着写UPDATE把场景问清楚2.1 三个问题决定你走哪条路面对“某一个表修改大量数据”的需求我接手的第一个动作不是写SQL而是先问三个问题。第一有没有明确的主键或唯一键范围如果有主键ID区间或者业务上能拆出连续ID分批更新会非常顺手如果只能靠一个不带索引的业务字段筛选得先考虑补索引或者用临时表。第二数据量到底多大几千条、几万条、几百万条对应完全不同的策略。量级没搞清楚就动手方案复杂度会失控。第三表上有几座“大山”比如外键、触发器、二级索引。外键和触发器会在更新时做额外约束校验二级索引会让写放大。如果这几样都占了那么即便批量更新方案再高级也会被这些额外机制拖慢。我看到不少人一上来就搜“批量更新优化方案”找个CASE WHEN或者临时表JOIN的模板套上去再说。这样往往忽略了最关键的限制条件生产上很容易翻车。选型前多问两个问题比多写几十行SQL更有价值。2.2 常用优化方案的横向对比我把常见的几种优化方案放在一起做了个对比表平时选型基本看这张表就够了。方案适合场景主要风险推荐指数单条UPDATE循环几万行以内、允许较长时间网络往返太多事务太大不推荐分批小事务更新任意量级、保底方案执行时间长需要脚本支撑强烈推荐临时表JOIN更新更新条件复杂、数据量大需要构建临时表临时表数据量也要控制推荐CASE WHEN批量拼接几千到几万条映射数据SQL超长易触发参数限制中等删除索引后更新再重建表很大、业务允许索引短暂缺失期间相关查询变慢重建耗时谨慎推荐在线DDL工具pt-osc超大表、要求不停机需要安装工具、变更审批有条件推荐这张表不是死的很多时候要组合使用。比如你既可以用分批的方式每次处理1000行再在每批内用CASE WHEN构造更新效果往往更好。关键不是追求某一种“最优写法”而是找到当前数据量、锁竞争和运维窗口之间最舒服的平衡点。2.3 外键批量更新里最容易被忽视的限制提到“数据库表的外键”很多同学最熟悉的是建表时加一个FOREIGN KEY真正到批量更新的时候外键的存在感反而被忽略了。实际上外键对更新性能的影响非常大。当子表更新引用列时父表需要判断约束是否满足反过来如果更新父表的主键子表还会触发级联查找。这些检查都发生在事务内部而且会加共享锁处理不当会造成大范围的锁等待。我在生产环境处理过一张订单表子表订单明细通过外键关联主表订单ID。当时需求是把一批历史订单的状态全部更新结果SQL执行到一半就卡住了看了诊断信息才发现子表的外键检查把另一条业务线的查询全部堵死。后来把外键临时禁用更新完再恢复才把影响降到最低。所以在做批量更新前一定要检查外键关系提前评估是否要临时禁用或者直接删除外键、更新完成后重建。这些操作涉及元数据锁最好选在低峰期执行。3. 四种落地打法从最稳到最快的批量更新实操3.1 准备测试环境从建库建表开始先不要急着在生产环境操练我建议你在本地MySQL环境把下面这套流程完整跑一遍。操作之前先建一个测试库和测试表CREATE DATABASE IF NOT EXISTS test_batch DEFAULT CHARSET utf8mb4; USE test_batch; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, score INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL ) ENGINEInnoDB; CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, KEY idx_user_id (user_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user (id) ) ENGINEInnoDB;这里建了两张表t_order还有一个外键关联t_user。为什么要故意加这个外键因为很多线上表确实存在外键而外键对批量更新的影响你不实测一遍很难有体感。你也可以用存储过程造点数据DELIMITER $$ CREATE PROCEDURE init_data() BEGIN DECLARE i INT DEFAULT 1; START TRANSACTION; WHILE i 100000 DO INSERT INTO t_user(name, status, score, create_time) VALUES (CONCAT(user, i), i % 5, i * 10, NOW() - INTERVAL i DAY); SET i i 1; END WHILE; COMMIT; END$$ DELIMITER ; CALL init_data();造完数据以后再往t_order里插一批订单模拟一个百万行级别的更新场景。环境准备好后面四个方案就可以一个个试。3.2 分批小事务任何场景都摔不坏的“安全绳”如果你只想背一个保底方案那就是分批更新。为什么它最稳因为它的核心逻辑是把一个大事务拆成若干个小事务每一批最多影响几百上千行锁的粒度小了事务日志也小万一某一批失败只需要回滚这一批而不是整盘崩掉。下面这个存储过程就是按主键id每5000行一个批次地更新数据DELIMITER $$ CREATE PROCEDURE batch_update_orders(IN batch_size INT, IN max_id INT) BEGIN DECLARE start_id INT DEFAULT 0; DECLARE end_id INT DEFAULT 0; DECLARE affected_rows INT DEFAULT 0; WHILE start_id max_id DO SET end_id start_id batch_size; UPDATE t_order SET status 1 WHERE id start_id AND id end_id AND status 0; SELECT ROW_COUNT() INTO affected_rows; COMMIT; SELECT SLEEP(0.05); SET start_id end_id; END WHILE; END$$ DELIMITER ; CALL batch_update_orders(5000, 300000);这里有几个细节很重要。一是WHERE条件里用主键id的范围确保MySQL能用主键索引快速找到数据二是加上status0这种过滤条件避免对已经更新过的行做无效写操作三是每批都显示提交COMMIT并且SLEEP半秒钟给InnoDB和主从同步一个喘息的时间。如果你不用存储过程也可以用Python脚本循环执行同一条UPDATE效果一样。我当时用这套方案在订单表更新了120万行每批5000条总耗时大概十几分钟。耗时不算短但整个过程中数据库的锁等待、主从延迟都完全可控业务无感这就是分批方案最核心的价值牺牲一部分速度换取稳定。3.3 临时表JOIN更新把大而杂的更新变成精准打击分批方案虽然稳但如果遇上更新条件特别复杂比如要从Excel导入五十万行“每个订单要更新成不同状态”再一条条UPDATE循环那就要跑半天。这时候可以考虑临时表JOIN更新。思路很简单把需要更新的目标数据主键ID 新值先导入到一张临时表然后在临时表的目标字段上建索引最后通过JOIN语句一次性把主表更新掉-- 先创建临时表导入目标数据 CREATE TEMPORARY TABLE tmp_orders ( id INT PRIMARY KEY, status TINYINT NOT NULL ); -- 假设这里用load data或其他方式导入了十万行目标数据 -- 批量更新主表 UPDATE t_order o JOIN tmp_orders t ON o.id t.id SET o.status t.status;为什么这样能快因为临时表很小只存了目标行的主键和新状态JOIN时MySQL可以去临时表上走主键索引对主表做精准的“主键查找”而不是全表扫描。如果这一步再配合分批比如每批只JOIN临时表里的5000条那就更完美了。我实践时发现一个坑如果临时表上没有主键或索引JOIN更新会退化成临时表全表扫、然后每一行去主表靠主键访问性能反而比直接UPDATE还差。所以当你看到有人说“JOIN更新直接起飞”先检查目标表有没有索引别被幸存者偏差带偏。另外临时表是会话级的连接断开数据就没了如果数据量特别大可以用普通的中间表步骤一样只是别忘了最后清理。3.4 CASE WHEN拼接适用于“同一字段不同值”的小批量快更新如果你要更新的数据量没那么大但是每个行更新的值又不同比如把一批订单号映射到新状态用CASE WHEN构造一条SQL往往是最简单粗暴的UPDATE t_order SET status CASE order_no WHEN no_0001 THEN 3 WHEN no_0002 THEN 4 WHEN no_0003 THEN 5 ELSE status END WHERE order_no IN (no_0001, no_0002, no_0003);注意WHERE条件把范围限制在我们要更新的order_no列表里不然如果表里有几百万行其他行的status也会被重写一遍。虽然值一样但依然会产生binlog和undo。CASE WHEN拼接适合映射数量在几百到几千的场景。数据量太大的时候SQL文本会超出max_allowed_packet限制或者超过MySQL对SQL长度的解析能力反而得不偿失。如果你想自己拼这种SQL务必处理好字符串里的单引号转义否则数据里带个引号会直接让SQL语法错误。我在代码里生成SQL时的习惯是先把幂等条件写全比如AND status 新值这样即使脚本因为某种原因重跑也不会白白刷一遍数据。另外一次拼接几千个CASE分支已经够长了再多就要拆批拆批的时候最好带上事务确保多批之间要么全部成功要么回滚别出现半张脸。3.5 高级手段临时摘掉索引和外键如果你的表非常大上面的方案还是觉得慢那可以考虑在窗口期内临时删除二级索引和外键更新完再重建。这在有DBA把关的环境里需要审批但对于纯内部系统或者可以接受短时间索引缺失的场景效果立竿见影。比如有一张表上面有三个二级索引更新100万行你会发现光维护索引的时间可能占整个更新耗时的40%甚至更多。因为每改一行三个索引都要同步修改B树。索引摘掉以后更新就只碰聚簇索引速度完全不在一个量级。操作步骤大概是-- 临时禁用外键和唯一键校验 SET FOREIGN_KEY_CHECKS 0; SET UNIQUE_CHECKS 0; -- 应用批量更新方案分批/临时表JOIN/CASE WHEN -- 恢复校验 SET UNIQUE_CHECKS 1; SET FOREIGN_KEY_CHECKS 1;这个方式最大的风险在于如果更新过程中有新写入的数据而这些数据恰好依赖那根唯一索引或外键约束就可能在更新期间产生脏数据。所以务必评估业务是否允许。对于二级索引更稳妥的做法是ALTER TABLE t_order DROP INDEX idx_user_id更新完再ADD INDEX。索引重建同样耗时但可以让“更新时间”可控比让一条UPDATE在锁里慢吞吞地熬着要强。4. 实战排坑批量更新时最容易踩到的五个问题4.1 主从延迟飙升binlog才是隐藏的大头很多同学测试环境只搭了单机批量更新跑得很欢一上生产就发现从库延迟到告警。在一主一从或者一主多从架构里主库执行大批量UPDATE产生的binlog要同步给从库重放。如果你的binlog_formatROW每一行变更前后都会写两条完整镜像五百万行更新意味着至少上GB的binlog从库重放起来怎么可能不慢解决办法也很直白控制批量大小。分批更新时把每批行数从5000降到1000让从库可以“追”上主库的进度。如果延迟依然明显可以在低峰期操作或者优先对相关表加上索引让从库重放时不要触发全表扫描。我用过最有效的一招更新前在从库上执行STOP SLAVE;MySQL 8.0用STOP REPLICA;更新完再启动并用START REPLICA;跟上。当然这种操作要看团队是否允许不能擅自用。4.2 锁等待超时不是所有问题都能靠调参数解决批量更新时最容易看到的报错就是Lock wait timeout exceeded; try restarting transaction。这个报错并不是索引或者数据量的问题而是你的事务在申请行锁时超时了。换句话说另外有会话正在修改同一批数据而你排队等太久。遇到这个问题我的排查顺序是先查阻塞源SELECT * FROM performance_schema.data_lock_waits\G看到阻塞线程后再反查它执行的SQL。很多情况下是因为写程序的时候更新顺序不一致。比如线程A按id升序更新线程B按id降序更新两边很容易互相持有对方等待的锁形成死锁。解决办法是统一调整更新顺序所有批处理都按主键从小到大提交同时控制事务运行时间批量别太大。调innodb_lock_wait_timeout这个参数其实是个治标不治本的招它只是把报错时间往后拖并不能解除锁竞争。除非是DBA评估后认为可以适当放宽否则别只依赖改参数。4.3 外键带来的隐性锁等待前面方案选型里提到外键这里再说一个真实案例。有一次我更新主表的主键ID这本身就是个危险操作表上有子表通过外键引用结果更新语句跑了一上午都没结束。查看performance_schema发现父表更新时InnoDB会对子表记录加共享锁执行约束检查。子表要是几十万行这个检查本身就是土豪级的开销。这种情况下我建议你先评估外键字段是否真的需要维护。如果业务外键只是为了查询方便完全可以用普通索引替代约束。如果确实要保留外键批量更新期间可以临时SET FOREIGN_KEY_CHECKS0更新完再开启。但要提醒一句关闭外键检查并不会把已有外键删掉它只是跳过一致性校验如果更新会产生违反外键约束的数据开关一开就会被拒。4.4 数据量不大却更新慢先查WHERE条件有没有索引有一次同事反馈一条只有一万行的UPDATE特别慢我把SQL拿过来一看WHERE条件是UPDATE_TIME DATE_SUB(NOW(), INTERVAL 7 DAY)而UPDATE_TIME压根没有索引。一万行在主表里不算多但如果是两千万行的大表这个WHERE就是全表扫描加逐行判断慢才是正常的。为了解决这种场景与其用复杂的优化方案不如直接在条件列上加一个二级索引让MySQL快速定位到那批需要更新的行。不过加索引也需要权衡。索引不是越多越好二级索引会拖慢DML所以只建议为高频出现、且能显著缩小更新范围的WHERE条件建索引。如果你发现某个批量更新的WHERE条件过滤后只剩几万行加上索引以后更新扫描成本会指数级下降。这个点经常被忽略但往往是最便宜的优化。4.5 常见问题速查表我把这几年处理批量更新问题的一些典型场景整理成了一个速查表以后遇到类似问题可以对着查症状可能原因建议动作一执行UPDATE就卡死全表扫描/无索引条件检查WHERE条件索引补索引或走临时表大批量更新后主从延迟单事务binlog过大分批提交控制每批行数Lock wait timeout行锁等待超时查阻塞源统一更新顺序Deadlock found多事务锁顺序不一致按主键固定顺序刷新缩小事务范围外键表更新极慢子表约束检查开销临时禁用外键检查低峰期更新唯一键重复冲突更新导致唯一键重复检查目标数据先清理脏数据再改这个表里的经验不是绝对的但大概率能帮你少走弯路。我个人在实际操作中的体会是批量更新最怕的不是数据量而是“你以为自己看到了全部数据”。有一次我做状态刷新按主键分批跑了2小时最后发现因为表里存在孤儿数据其中几批的更新明明失败了却没有任何报错——原因是存储过程用了异常处理异常被吞掉以后看起来是执行完了实际上数据根本没变。所以无论用哪种方案执行完一定要做数据校验统计受影响行数、抽查目标ID、对比前后分布值。批量更新这事快不是最终目标稳才是。最后再分享一个小技巧在正式执行之前把你计划的批量更新SQL放到测试库用与线上同等量级的数据先跑一遍记录每批的耗时和锁等待做到对风险心里有数。这个习惯帮我挡了至少三次生产事故强烈建议你们也试试。