☰
MySQL大表全表更新安全实践:从分批更新到表重建方案
2026/10/16 10:22:00 网站建设 项目流程

我接触过的生产环境里,因为一条全表UPDATE把业务搞停的案例,一只手数不过来。MySQL处理千万级、亿级大表时,UPDATE t SET status=1这种写法,线上环境跑一次,轻则锁表拖垮读写,重则主从延迟到天亮,磁盘打满直接宕机。很多人觉得"全表更新"不过是加个WHERE条件的事,但真正决定成败的,恰恰是那些看起来不起眼的细节:事务大小、主键范围、扫描方式、binlog量。这篇文章结合我做过的几次大表更新实践,从头梳理一套可以安全落地的操作方案,从分批更新到表重建工具,再到一次真实故障的复盘,希望能帮打算动手改大表数据的同学少踩几个坑。

1. 大表全表更新最危险的地方:不是数据量,是锁和日志

先说一个反直觉的结论:全表更新真正的风险不是"更新多少行",而是"一次事务锁住多少行"以及"产生多少日志"。数据量只是放大了问题,根源在InnoDB的事务机制。

1.1 一条UPDATE在InnoDB里究竟做了什么

当你执行UPDATE t SET status=1,MySQL的Server层负责解析、优化、生成执行计划,InnoDB存储引擎层负责实际的数据修改。整个过程大致是:

  • 按主键索引扫描,读出满足条件的每一行;
  • 对每一行加排他锁(X锁),直到事务结束才释放;
  • 把旧值写入undo log,用于回滚和MVCC;
  • 修改数据页中的记录,同时写入redo log;
  • 每行修改都要记录binlog(如果开启了binlog)。

问题就出在"锁直到事务结束才释放"这句话上。在没有WHERE条件(或WHERE条件无法走索引)的情况下,全表更新相当于一次性把整张表的所有行都锁住。期间任何其他会话的读操作,如果走的是当前读(比如SELECT ... FOR UPDATE、UPDATE、DELETE),都会被阻塞;普通的一致性快照读(SELECT)虽然不会被阻塞,但InnoDB需要根据undo log构建旧版本的数据,锁持有的时间越长,undo log被清理得越慢,整个系统的压力就越大。

更麻烦的是锁的升级和扩散。InnoDB有锁升级机制,当单个事务持有的行锁数量超过阈值,或者锁的内存开销过大,可能从行锁升级为表锁。虽然InnoDB的锁升级和MyISAM不同(InnoDB原则上是行锁),但在高并发写入场景下,大量行锁会导致锁等待链变长,最后表现为数据库的QPS骤降,线程池打满,业务端一堆"Lock wait timeout exceeded"报错。

1.2 大事务的三重副作用:undo膨胀、binlog放大、主从延迟

大事务的第二个坑是undo log膨胀。InnoDB的多版本并发控制(MVCC)依赖undo log保留数据行的历史版本,事务越大、改动行数越多,undo log增长越快。如果事务长时间不提交,undo log会被保留,导致表空间膨胀,甚至出现"undo log文件占用几十GB"的情况。有些线上故障就是大事务直接干爆了undo表空间。

第三个坑是binlog的放大效应。假设一行数据改动前是200字节,改后也是200字节,但binlog里每一行都写了两份完整的镜像(before image和after image),再加头信息,实际写入量是数据本身的好几倍。千万行级别的表,binlog轻松超过10GB。这些binlog要同步到从库,从库的SQL线程是单线程重放的(即使开启了并行复制,也有并行度的上限),主库10分钟干完的活,从库可能要卡一个小时。

这意味着在大表全量更新期间,最容易被拖垮的不是主库,而是从库。业务上如果依赖于从库做读写分离,那么一次鲁莽的全表更新会让所有读流量直接打到旧数据上,甚至读到长期不一致的数据。真实的故障模式往往是:主库没事,从库延迟报警刷屏,接着监控页面全飘红。

理解了这三点,就能理解为什么"分批提交、控制事务大小"是大表更新的第一原则。不是MySQL不能更新大表,而是不能在一个事务里更新大表。

2. 分批更新:最稳妥的常规路线

对于千万级的大表,分批更新是首选方案。核心思路是:把一次更新拆成N次小更新,每次更新几千到几万行,提交后再接着下一批,让每个事务都很小、很快结束。这样既不长时间持有锁,也能控制binlog的产生速率,给从库留出追赶的时间。

但分批更新不是LIMIT 1000循环update那么简单,这里面的坑比想象中多。

2.1 为什么"LIMIT分批"是错的,按主键范围才是对的

最常见的错误写法是:

UPDATE t SET status=1 WHERE status=0 LIMIT 1000;

然后循环执行到影响行数为0。这种写法在MySQL里有一个致命问题:MySQL的UPDATE不支持LIMIT子句(不像DELETE在某些版本里支持),所以这条语句在MySQL中会直接报语法错误。即便用DELETE ... LIMIT的思路绕开,基于LIMIT的分批也隐含着大问题:每次执行都重新做全表扫描,越到后面扫描成本越高,性能退化严重,而且status=0这个条件在更新过程中会动态变化,很容易出现漏更新或重复更新。

正确做法是按主键范围分批。主键在InnoDB里是聚簇索引,按主键范围扫描走的是索引区间扫描,每次只扫一小段,成本可控,也不会越来越慢。配合主键边界变量,可以把每一批扫描限定在固定区间内。

我常用的基础框架是这样的:

-- 假设主键为id,每次处理1万行 -- 先找到当前需要处理的批次边界 SELECT MIN(id), MAX(id) FROM t WHERE status=0; -- 假设得到 min_id=100000, max_id=2000000 -- 循环处理 WHILE (当前起始id <= max_id) DO UPDATE t SET status=1 WHERE id BETWEEN 当前起始id AND 当前起始id + 10000; -- 记录当前起始id为下次边界 END WHILE;

更严谨的做法是先把需要更新的主键集合捞出来存到临时表,再按主键关联更新。尤其是在WHERE条件不是主键分区、过滤率又不高的情况下,直接扫描原表多次,成本依然很大。

2.2 chunk大小的取舍:不是越大越好,也不是越小越好

分批的批次大小(chunk size)是核心调优参数,需要根据表结构、数据行宽度、服务器配置综合决定。批次太大失去分批的意义,太小则循环次数过多、整体耗时长。

我一般在千万级表上这样估算:

  • 单行记录如果是200字节左右,1万行的数据页大概是200MB/16KB约等于2000多个数据页,读起来比较轻快;
  • 1万行的事务在binlog里大约产生8~20MB,主从之间可以接受;
  • 单批次执行时间控制在1~5秒,这个区间内锁持有时间短,业务几乎无感知。

如果是亿级表,chunk可能要降到5000甚至2000,因为表的整体体积在那里摆着,单批次的page扫描开销会随数据分布不均匀而波动。具体的实践法则是:先跑一批测试批次,观察执行时间和主从延迟,再微调。不要指望有一个万能数字,每张表的分布都不一样。

多提一句:批次之间要不要sleep?我建议加,但不用长。0.1秒到0.5秒的sleep就能显著缓解主从延迟的波动,因为从库的SQL线程需要时间追binlog。如果你发现从库延迟持续上升,优先的调整方向不是减小chunk,而是增加sleep时间。有一个监控技巧:分批脚本执行期间,每批结束后立即查一下SHOW SLAVE STATUS(或SELECT * FROM performance_schema.replication_applier_status_by_worker),看Seconds_Behind_Master(从库落后主库的秒数)的变化趋势,只要它在可接受范围内波动,就说明写入速度是安全的。

2.3 分批脚本的落地细节:事务边界、连接复用和异常恢复

实操中还有三个容易忽视的点。

一是事务边界。每批更新必须是独立事务,自动提交模式下语句执行完即提交,这没问题;如果显式开启事务,必须每批COMMIT一次,绝对不能把所有批次包在同一个大事务里,那样分批就失去意义了。

二是连接复用。分批更新时不能每批新建一个数据库连接,连接建立的开销在批量场景下会被放大很多倍。应该用连接池或者干脆一条连接跑到底。但一条连接长时间连续执行更新,又要注意MySQL的wait_timeout和interactive_timeout参数,有些场景下连接长时间空闲会被服务端断开,不过分批更新是连续执行的,一般不会触发。

三是异常恢复。任何一批失败(比如死锁、锁等待超时)都要有明确的处理逻辑。我的习惯是:把当前批次的主键范围记录下来(或者记录批次序号),脚本支持指定起始范围继续执行,而不是从头再来一遍。如果是从头重跑,前面已经更新的行会被再次扫描,白白浪费时间。另一个细节:失败批次不要急着重试,先观察是死锁还是磁盘问题,大多数死锁在重试后能成功,但磁盘满这种问题重试只会雪上加霜。

下面给一个我实测可用的Python伪代码样式的分批更新流程,包含监测量输出:

import time, pymysql conn = pymysql.connect(host='...', user='...', passwd='...', db='...') def get_min_max_id(conn): cur = conn.cursor() cur.execute("SELECT MIN(id), MAX(id) FROM t WHERE status=0") return cur.fetchone() def batch_update(conn, start_id, end_id): cur = conn.cursor() sql = "UPDATE t SET status=1 WHERE id BETWEEN %s AND %s" cur.execute(sql, (start_id, end_id)) return cur.rowcount min_id, max_id = get_min_max_id(conn) batch_size = 10000 current_start = min_id while current_start <= max_id: current_end = min(current_start + batch_size - 1, max_id) start_ts = time.time() affected = batch_update(conn, current_start, current_end) elapsed = time.time() - start_ts print(f"batch {current_start}-{current_end}, affected={affected}, elapsed={elapsed:.2f}s") current_start = current_end + 1 time.sleep(0.2)

这个脚本本身不复杂,但能跑得稳,前提是表的主键均匀分布。如果主键不均匀(比如中间有大量空洞,或某段数据特别密集),建议先做一次SELECT COUNT(*)按FLOOR(id / batch_size)分组统计一下数据分布,或者直接改用更智能的"先取主键列表、再切片"的方式。

3. 亿级表更新的进阶选择:表重建与在线工具

当表的数据量到亿级,即使分批更新也面临一个尴尬:全表扫描和多次更新带来的总耗时可能长达数小时甚至数天,期间CPU和IO一直处于高位,对整个实例的影响面太大。这种情况下,常规的分批更新可能已经不是最优解,更靠谱的路线是"表重建"——新建一张表、把数据倒过去、然后原子切换。

3.1 表重建的基本流程和注意点

表重建的思路是:把"更新"转化为"插入"。原表不动,业务读写照常。新建一张目标表,表结构相同但数据已经按新规则写好,然后通过原子操作切换表名,把旧表替换掉。

通用流程大致是:

  1. 根据原表结构建新表t_new,需要变更的逻辑(比如status=1)在写入时直接体现;
  2. 把原表的历史数据分批写入t_new,边写边同步增量数据(如果业务允许短暂停写,可以简化这一步);
  3. 选择一个业务低峰窗口,做最终切换:将原表改名t_old_backup,将t_new改名为原表名;
  4. 验证数据量和抽样一致性,确认无误后,将t_old_backup择机删除或归档。

听起来很简单,实操中最大的坑在于"边写边同步增量"。在数据搬迁过程中,原表可能持续有新的写入,如果不停写,t_new永远追不上。解决思路一般有两种:

  • 第一种,业务允许的话,在切换窗口内对原表加一个全局读锁(LOCK TABLES t READ),快速把最后一次增量同步过去,然后切换。加锁窗口尽量控制在秒级。
  • 第二种,利用触发器或者Binlog同步工具,按照主键或时间戳增量同步变更,在切换时先短暂停写,处理最后一批增量,然后切换。这个方案实现复杂度高,但是可以做到接近在线迁移的效果。

表重建方案的优势是:新表的写入是顺序的、批量插入,效率远高于逐行UPDATE;旧表在重建过程中不受影响,业务读压力完全照旧;新表可以在切换前做一遍完整校验,出问题的概率大大降低。

不过表重建也有明显代价:需要额外的存储空间(相当于表体积的一倍以上),迁移期间磁盘IO压力大,而且整个流程的复杂度比分批更新高一个量级。所以它更适合数据量特别大、更新比例特别高的场景。如果只是把1000万行中100万行的某个字段改个值,没必要搞表重建。

3.2 在线变更工具(如pt-osc)的原理与使用

如果你不想手工写表重建的脚本,可以直接用现成的在线变更工具。这类工具最知名的代表是Percona Toolkit里的pt-osc(pt-online-schema-change)。注意,pt-osc的大名虽然打着"online schema change"的旗号,但对于"全表UPDATE"这种数据变更场景同样适用,因为它做的事情本质上也是表重建:创建影子表、增量同步、原子切换。

pt-osc的工作流程是:

  • 在目标表上创建一个结构相同的新表;
  • 在原表上创建触发器(AFTER INSERT、AFTER UPDATE、AFTER DELETE),把切换期间业务产生的增量变更同步到新表;
  • 分批从原表读取数据写入新表;
  • 数据同步完成后,短时间内锁住原表,将原表改名,新表改名成原表名,删除触发器。

好处是写完一个指令交给它就行:

pt-online-schema-change \ --alter "MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1" \ --no-drop-old-table \ --max-load "Threads_Running=50" \ --chunk-size 2000 \ D=db_name,t=table_name \ --execute

但这里要提醒一点:用pt-osc做全表更新的适合场景是"需要把大量行的字段改成同一个表达式或同一个常量值",它可以配合--alter直接写字段的默认值或表达式。不过如果更新逻辑特别复杂(比如要根据其他表的状态联动更新),--alter就写不出来了,不如直接用SQL分批,或者用自定义重建脚本。

pt-osc在亿级表上最大的优点是内部自带了负载控制和断点恢复机制:

  • --max-load:当数据库的线程数或负载超过阈值时,自动暂停写入,避免压垮实例;
  • --chunk-size:内部会结合主键范围动态调整批次行数,避免每个批次扫描的行数不均衡;
  • 支持断点续传,因为工具内部会记录已处理到的位置,中断后可以继续。

我个人的使用心得是:跑pt-osc之前一定要确认磁盘空间充足,因为它不会删除旧表(默认参数下切换后旧表会保留为_old后缀),在亿级表场景下这份空间开销非常大。另外,触发器在高并发写入下会给原表的DML语句带来额外延迟,所以尽量在业务低峰期执行,并设置好--max-load的保护阈值。

3.3 分批更新和表重建该怎么选

两种方案不是互斥的,可以根据表的大小、更新比例、业务容忍度做选择。给一个我常用的判断框架:

判断维度适合分批更新适合表重建方案
数据量千万级,最大到亿级偏小亿级以上,数据量大到分批耗时不现实
更新比例需要更新的行数占全表比例较低(例如小于30%)全表大部分行都要改,或需要修改字段默认结构
可用窗口有较充裕的低峰时间,可以从容分批不能长时间影响业务,要求切换窗口尽量短
磁盘空间剩余空间紧张,无法容纳双份表磁盘充足,能承受表体积翻倍
运维能力团队能接受跑脚本和监控能接受复杂度更高的工具或脚本流程

具体到我实际做过的场景,某张流水表有7000万+行,需要把其中近4000万行的biz_type改值,更新比例超过一半。如果走分批更新,单次UPDATE要扫的行太多,整体耗时预计6小时以上,而且期间磁盘IO被UPDATE反复占用。最后选了表重建方案,整个迁移过程大约是2小时,切换时业务仅停顿了秒级。而另一个订单表只有1500万行,只改了其中几百万行的状态位,这种场景分批更新几十个批次就能轻松搞定,没必要动用表重建。

4. 一次亿级更新引发的线上事故复盘

方案再完备,实战里依然会遇到意外。这里分享一次真实的故障处理过程,表名和字段做了脱敏处理,但排查链路和教训是完整的。

4.1 事故经过:一条本该"分批"的语句变成了一次性大事务

背景是某业务系统有一张用户行为表(模拟项目X),数据量在亿级。某日需要给一批特殊用户打标,开发同学根据运营需求写了一条UPDATE语句,为了让"所有符合条件的用户都更新",没有加任何分批逻辑:

UPDATE user_behavior SET is_special=1 WHERE user_id IN (SELECT user_id FROM special_user_list);

这条语句的WHERE条件里有一个子查询,但子查询返回的结果集有几百万行。MySQL优化器在处理IN子查询时,如果子查询结果集不大,通常会做半连接优化;但这里结果集过大,执行计划走了全表扫描+逐行判断。更糟的是,这条语句被放进了一个显式事务里,代码里还有其他操作,导致整个事务长时间不提交。

结果就是:

  • user_behavior表的几亿行被逐行扫描、逐行加锁;
  • 其他业务的写入全部阻塞,大量Lock wait timeout;
  • 从库延迟呈直线上升,从几秒一路飙到几个小时;
  • 磁盘空间因为undo和binlog的膨胀开始告警。

整个故障持续了四个多小时才慢慢缓解。最讽刺的是,这条UPDATE真正需要更新的行数只有几十万,但因为执行计划选择失误和事务设计问题,拖垮了整库。

4.2 排查过程:定位到持锁会话和执行计划

事故发生时,第一反应是查当前有哪些会话在跑、持有多少锁。快速定位的SQL基本是这样:

-- 查看当前有哪些长时间运行的事务 SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 10; -- 查看锁等待关系 SELECT * FROM sys.innodb_lock_waits;

通过innodb_trx能看到一个事务已经运行了数百秒,trx_rows_modified的值还在持续增长。再配合performance_schema.events_statements_history_long找到该事务正在执行的具体SQL,终于看到了那条不带任何分批逻辑的UPDATE。

更让人头大的是执行计划。手动EXPLAIN那条语句,发现user_behavior表的type是ALL,即全表扫描,而s subquery部分也产生了大量的临时表关联。也就是说,MySQL优化器在大IN列表场景下没有选择"先物化子查询结果再走索引"的路线,而是走了一条最笨的路。如果当初能先确认执行计划,再决定要不要分批,或者干脆把子查询结果导出成临时表、分批关联更新,就不会出现这场事故。

4.3 止血与善后:kill事务只是开始

确认问题后的第一反应是kill掉那个长事务。但这里有一个关键细节:当一个事务已经修改了大量行时,直接kill会触发巨大的回滚操作,回滚的耗时可能比事务本身还长。那个事务当时已经改了上千万行,直接KILL后回滚又持续了一个多小时,期间锁依然释放不干净。这是很多人忽略的一点:对大事务"快刀斩乱麻"式的kill,往往不是最快的止血手段。

当时正确的处理方式是:如果在事务刚开始、行数修改不多时发现,可以立刻kill,回滚代价小;如果事务已经运行了很久、改了很多行,硬kill的回滚代价可能让人崩溃。这种情况下要评估是否是继续等事务跑完更划算,同时让业务层做熔断降级,尽量避免更多请求进来。

善后阶段我们做了三件事:

  • 等锁彻底释放后,将需要更新的用户清单导出到一张临时表,按ID范围拆成5000行一批,用分批UPDATE的方式重新执行,39分钟跑完,全程无锁等待。
  • 对从库延迟做巡检,等binlog追平后观察数据一致性。好在binlog没有损坏,最终员工对账也通过。
  • 给所有大佬表和核心表加了一套更新前审查的拦截机制:任何UPDATE/DELETE语句在执行前需要确认WHERE条件走索引、影响行数预估在阈值以内,否则必须走审批和分批流程。

这次事故的教训可以浓缩成一句话:大表更新的正确做法不是"什么时候想更新就去更新",而是"先弄清楚执行计划、影响行数、锁代价、日志量,再决定用哪种分批方案"。做DBA或者后端开发,很多线上问题不是技术有多深,而是大家太信任一条看似简单的SQL。

5. 大表更新的底线清单与习惯养成

经历了多次成功和一次惨痛教训之后,我把大表更新的注意事项总结成了一份清单。每次做这类操作前,我都会打开这张清单逐项确认。

5.1 动手前的检查项

检查项里有一部分是硬性的,不能满足就只能换方案或者等条件:

  • 确认UPDATE的WHERE条件能走索引,避免全表扫描。用EXPLAIN看type字段,至少要range或ref,ALL直接否决。
  • 预估影响行数。用SELECT COUNT(*)或者EXPLAIN+rows估算,明确更新比例。
  • 确认实例的磁盘空间剩余,尤其要关注undo表空间和binlog所在目录。大事务的日志膨胀速度经常超出预期。
  • 检查主从延迟基线。如果从库原本就有追不上的趋势,尽量延后操作。
  • 计算一下总事务数和总耗时,判断是否在业务可接受的窗口内。
  • 准备回滚方案。如果是置一个状态值,可以反向再更新回去;如果是删除数据,要提前做备份。

这些检查看起来繁琐,但每一条背后都是线上代价换来的。

5.2 执行过程中的监控指标

分批更新的执行过程不是"脚本扔上去等结果",要实时监控几个信号。我最常盯的是:

  • 数据库当前活跃线程数(SHOW GLOBAL STATUS LIKE 'Threads_running'),如果持续高位,说明负载过大,脚本应该降速。
  • 主从复制延迟(从库上SHOW SLAVE STATUS的Seconds_Behind_Master),一旦该值持续增大,立刻调小chunk或者加大sleep。
  • 磁盘空间变化(df -h),binlog增长速率如果异常,检查是不是哪一批更新实际影响行数远超预期。
  • 锁等待情况(information_schema.innodb_trx),随时能看是否有其他会话在等锁。
  • 慢查询日志,确认自己的更新有没有变成慢SQL,或者有没有其他业务的慢查询被带起来。

脚本本身最好写成可以安全中断的:每批处理后记录进度(比如把当前批次的主键范围写到一张文本或状态表),一旦需要暂停,随时能从中断处继续。

5.3 值得固化为团队规范的几点习惯

最后说几条我强烈建议在任何团队内部推广的规范,不一定都是MySQL层面的,但每次执行都会受益:

  • 凡是涉及生产库的批量更新,必须提供一个"影响行数预估+执行计划+分批策略"的简短评估,哪怕是发到群里的三行字。这一步能逼着操作者把执行计划看清楚。
  • 数据变更脚本要有幂等性。比如按主键范围重复执行一批,也不会把已经更新的行再改一遍造成错误。这一点用主键范围和条件版本号比较容易实现。
  • 多个批次的更新顺序尽量保持稳定,不要一会儿从前面扫一会儿从后面扫,避免同一行被不同批次重复处理或者漏处理。
  • 在测试环境用同比例缩小的数据量完整演练一遍,尤其是表重建类方案。生产环境亿级表和测试环境百万级表的执行计划可能完全不同,演练至少能验证流程的正确性。

这些习惯不能保证一次更新绝对零故障,但能把"意外"压缩到可控范围内。

最后再分享一个我个人用了很久的小技巧:像这种大表全表更新的任务,在低峰期跑是一方面,更关键的是先做一次SELECT范围的跑批演练,用只读方式把整个计划的数据分布摸一遍,再用EXPLAIN确认执行计划符合预期。很多时候你在演练阶段就会发现"按这个条件跑,实际扫描的行数是预期的五倍",从而在真正动手前及时调整策略。MySQL的优化器有时候会给出让人意外的计划,不要信直觉,要信EXPLAIN。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询