☰
MySQL海量数据批量删除:5种方案与实战踩坑指南
2026/10/9 3:41:28 网站建设 项目流程

咱们搞MySQL的,十有八九都撞上过这么个需求:线上有个几千万行的大表,要删掉其中大部分数据。可能是一批历史日志、一批过期订单,或者按某个业务维度清理无效数据。我见过太多新手上来就是一条DELETE FROM big_table WHERE create_time < '2023-01-01',然后执行进去,数据库瞬间卡死,主库连接打满,从库延迟一路飙升,最后闹到要重启实例。这篇文章就专门聊这个——MySQL里批量删除海量数据到底有哪些靠谱路子,各种方案适合什么场景,实际操作中又会踩到什么坑。

先说清楚一个核心认知:删除海量数据的难点从来不在“删除”本身,而在删除动作引发的一连串连锁反应。你要删1000万行,MySQL要逐行标记删除、记录binlog、维护二级索引、写入undo log供事务回滚和MVCC使用。这些操作都会落在磁盘和内存上,加上行锁、间隙锁互相争抢,最终表现为数据库性能断崖式下跌。理解了这一层,你就能理解为什么所有人都说“大表不能直接DELETE”了。

这篇文章覆盖的分批删除、按主键范围分段删除、分区表清空、建新表切换、pt-archiver工具归档这几类主流方案,都是我实际用过的。我会把每个方案的原理讲明白,给出可以直接抄的SQL和脚本,再把我踩过的坑一并列出来。适合正在为清理大表发愁的DBA、后端开发,以及所有被慢查询日志吓到过的同学。

1. 先搞明白:为什么直接DELETE会“删崩”数据库

1.1 你删的不只是数据,还有一堆“隐形成本”

很多人以为DELETE就是把磁盘上那几行数据划掉,但实际上在InnoDB存储引擎里,一次DELETE消耗的资源远超你想象。咱们一条条拆。

第一,行锁和间隙锁的覆盖范围。一条不带精确条件的DELETE,比如DELETE FROM orders WHERE status = 'expired',在RR隔离级别下,InnoDB会扫描所有匹配到的记录并对它们加锁。如果这个条件没走索引,那就是全表扫描加锁,等于把整张表锁了个遍。就算走了索引,大量行同时加锁,锁冲突的概率也急剧上升,直接影响同一张表所有其他读写请求。

第二,undo log膨胀带来的连锁反应。InnoDB的事务隔离靠MVCC,MVCC靠undo log保存历史版本。你删除多少行,就要往undo log里写多少条反向操作记录。1000万行的DELETE,undo log随便就是好几GB。这还不是最要命的,更要命的是这些undo log在事务结束前不能清理,它们对应的旧版本数据还会被其他事务读到,于是在repeatable read隔离级别下,长事务+大删除组合会让undo表空间暴涨到撑爆磁盘。

第三,binlog的传输放大。MySQL主从同步依赖binlog,默认情况下DELETE产生的binlog是statement格式,一条SQL在主库刷一遍、在从库也要刷一遍。但如果你开了row格式(很多生产环境为了数据安全都会开),那么1000万行就会生成1000万条binlog记录事件,主从同步的网络开销、从库应用日志的CPU开销立刻拉满。我见过一个案例,主库删了500万行用了8分钟,从库追了2个小时没追上。

1.2 二级索引和碎片:删完之后表反而“变大”了

InnoDB的二级索引和聚簇索引是分开存储的。删除数据时,聚簇索引里的记录被标记删除,但二级索引里对应的索引条目不一定能及时回收。大量删除之后,索引B+树的叶子节点出现大量空位,索引扫描效率下降,物理文件(.ibd)不会自动缩小。结果就是:你辛辛苦苦删了5000万行,SELECT COUNT(*)确实变少了,但磁盘占用一点没降,查询速度和删除前几乎没区别。后面我讲“重建表”方案时你会看到,这才是彻底清理物理空间的唯一出路。

1.3 看清楚场景再选方案

既然直接删不行,那就得找替代方案。但替代方案不是一根筋走到黑,得看你具体什么场景。我把实际工作中常见的场景归成三类,你可以对号入座:

场景特征典型例子推荐方案
删少量数据(几万行以内)清理一批用户标记的错误数据直接DELETE,加上合适索引
删大量数据但希望保留表结构、保留在线服务清理过期日志、过期订单,但表还要继续使用分批DELETE、按主键分段删除、pt-archiver
删掉大部分数据、或要彻底释放磁盘表内数据基本都没用了,只留最近一小部分新建表+切换、分区表清空

这里头的关键区别在于:你要“温和地删”还是“彻底地换”。如果是前一种,重点是控制每次删除的量、控制锁和主从延迟;如果是后一种,重点是让MySQL在极短时间内完成“无痕切换”,靠的是DDL级别的原子操作和元数据替换。

2. 最通用的方案:分批循环DELETE,把大事务拆成小事务

2.1 核心思路与SQL写法

分批DELETE的本质,是把一个大事务拆成N个小事务。每删几千行就提交一次,锁持有时间短、undo log及时释放、binlog分批传输,对在线业务的影响被压缩到最小。这个方案不挑表结构、不挑数据分布,适用面最广,我建议所有场景都先从这个方案起步。

最基础的写法是靠着主键ID范围来分批:

DELETE FROM big_table WHERE id BETWEEN 1 AND 10000;

执行完后继续删下一批:

DELETE FROM big_table WHERE id BETWEEN 10001 AND 20000;

如果你不想手动算范围,可以选择每次取一批要删的主键ID,然后用主键IN去删除:

DELETE FROM big_table WHERE id IN ( SELECT id FROM ( SELECT id FROM big_table WHERE create_time < '2023-01-01' LIMIT 5000 ) t );

注意这里为什么套了一层子查询:MySQL不允许直接在DELETE的WHERE里SELECT同一张表的子查询(错误码1093),包一层派生表就能绕过这个限制。外层每次触发会扫描到满足条件的5000个ID然后删除。这种方式不依赖ID连续,比BETWEEN方式更通用。但它的缺点是子查询本身也要扫描,如果筛选条件没走索引,性能同样会很差。

2.2 批次大小怎么定:别拍脑袋,看三个指标

分批大小的选择直接决定方案的成败。批次太大会退化成大事务,太小则删除效率低下,循环几万次能把人急死。我从实际操作经验里总结了三个参考指标。

一是看主库的锁等待和活跃会话数。单批次DELETE执行期间,SHOW ENGINE INNODB STATUS里面的History list length不能持续暴涨,活跃会话也不能长时间趴满。如果单批次5000行执行时间超过2秒,就要减小批次。

二是看从库的延迟情况。设置定时任务循环删除的场景,每次循环之间SLEEP几秒钟。用SHOW SLAVE STATUS观察Seconds_Behind_Master,如果延迟在增长,说明删除速度超过从库应用binlog的速度,必须加大间隔或减小批次。

三是看undo表空间增长速度。一次性删太多,可用SHOW GLOBAL STATUS LIKE 'Innodb_history_list_length'观察,这个值代表未清理的undo日志量,如果它持续高位不下降,说明系统里有长事务或者删除太快来不清理,这种时候加大SLEEP时间。

以一个线上案例说:某张2亿行的用户行为日志表,按天清理60天前的数据,每次删8000行,删除耗时约1.2秒,循环之间SLEEP 20秒。整体算下来每秒约删除400行左右,主库负载稳定在20%以内,从库延迟控制在5秒内。如果按每次50000行去删,单次执行时间就飙到8秒,从库延迟直接涨到两分钟以上,这就不行了。

2.3 循环脚本的完整写法

生产环境里我更推荐用存储过程来做,一来避免应用层频繁发起连接,二来可以在数据库端精确控制循环逻辑。下面这个存储过程是我经常用的模板,你按自己的表结构调整下就能用:

DELIMITER $$ CREATE PROCEDURE batch_delete_big_data(IN p_batch_size INT, IN p_sleep_seconds INT) BEGIN DECLARE v_rows INT DEFAULT 1; WHILE v_rows > 0 DO DELETE FROM big_table WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY) ORDER BY id LIMIT p_batch_size; SET v_rows = ROW_COUNT(); COMMIT; IF v_rows > 0 THEN DO SLEEP(p_sleep_seconds); END IF; END WHILE; END$$ DELIMITER ;

这个存储过程有两个核心设计值得说说。第一,用了ORDER BY id LIMIT ?的形式,保证每次删除的都是最老的一批数据,而且走主键有序扫描,效率高。第二,每删完一批就COMMIT一次,让事务及时结束,undo log和行锁都在每个批次结束后立即释放。还有一个细节:很多人在循环里纠结要不要commit,MySQL默认是自动提交的,但存储过程的事务控制最好还是显式写清楚,防止将来修改隔离级别时行为变化。

调用方式很简单:

CALL batch_delete_big_data(5000, 5);

这里我解释一下参数怎么配:p_batch_size从5000起步往上调,p_sleep_seconds从5起步往上加。每次加参数跑一小段,观察主库负载和从库延迟,找到当前机器配置下的最优组合。内存大、磁盘快(SSD)、从库延迟容错高的环境,可以把批次加大到10000。我自己的经验是:宁肯稍保守一点,别追求一次删太多导致业务抖动。

2.4 分批DELETE的两个主要局限

这个方案虽然通用,但有两个短板你得知道。第一,它只解决了“删除动作”对数据库的压力,没解决“物理文件不释放”的问题。分批删完数据后,表空间依然是满的,.ibd文件大小不会缩。如果你清理数据的目的是为了腾出磁盘空间,那分批DELETE并不能直接满足你,需要配合后面说的重建表。第二,如果业务高峰期删数据,即使每批只有几千行,依然可能影响性能。这个方案更适合业务低峰期执行(比如凌晨1点到5点),尽量不要在白天大流量时段跑。

3. 主键有序场景下的高效方案:按主键范围分段删除

3.1 原理:顺序扫描远比随机扫描划算

如果说分批DELETE是“小步快跑”,那按主键范围分段删除就是“化整为零,分区块推平”。它的思路是:先把要删除的数据主键范围划分成若干连续的区间,然后逐个区间删除。比如你要删掉id从1到1亿之间的部分数据,就可以分成1000个区间,每个区间10万行,按顺序依次删。

为什么这样更快?因为InnoDB聚簇索引本身是按主键组织的B+树,以主键范围删除时,扫描和删除都是顺序的,充分利用了磁盘预读和内存缓存。相反,如果按create_time等条件去随机找数据,每一次定位都要走二级索引再回表,频繁的随机IO会大大拉低速度。

这个方案比较适合那些主键和业务删除条件高度相关的场景。比如说订单表,主键就是订单自增ID,而你要删的都是半年前的订单,那这些订单的ID一定集中在一个相对靠前的区间,按ID范围分段就非常合适。如果主键是UUID或者完全无序的字符串,这个方案优势就不明显了。

3.2 怎么找分段边界:MAX/MIN查询代替COUNT(*)

很多人在分段前习惯先执行一次SELECT COUNT(*) FROM big_table WHERE create_time < 'xxx',想确认总共有多少数据要删。我劝你不要这么做。大表上COUNT(*)是全表扫描或大范围索引扫描,光这一下就能把数据库拖垮。

正确做法是直接用MIN(id)和MAX(id)定位边界,然后按固定步长推进:

SELECT MIN(id), MAX(id) FROM big_table WHERE create_time < '2023-01-01';

比如查出来要删的数据id分布在 1000000 到 58000000 之间,那你就可以从1000000开始,每100000为一个区间,依次删除:

DELETE FROM big_table WHERE id >= 1000000 AND id < 1100000; DELETE FROM big_table WHERE id >= 1100000 AND id < 1200000;

如果你希望全流程自动化,也可以写一个存储过程按主键游标推进。但要注意:分段删除的每个区间删除量并不均匀——有些区间可能恰好有大量要删的行,有些区间则很少。所以单个区间执行时间可能有波动,批次间SLEEP仍然不能省。

3.3 一个重要优化:在删除期间暂停二级索引更新

这个技巧我在实践中觉得非常好用,但知道的人不多。如果你那张表有多个二级索引,每次DELETE都要同时维护这些索引。索引多了,删除一行要更新的索引条目也多,代价翻好几倍。

有一种做法是:先拿到要删除数据的id集合,存到一张临时表,然后删除原表上的二级索引,删完数据再重建索引。这里有一个典型Trade-off:删除索引期间查原表会变慢(因为少了索引),但删除数据的整体速度会显著提升。如果你清理的是“要删除大比例历史数据、但近期数据仍然要被业务频繁查询”的表,二级索引的维护成本占了删除总开销的大头,这个优化效果会非常明显。

但它也有风险:删除索引和重建索引都是大操作,重建索引期间表上所有依赖这个索引的查询都会受影响。因此这个技巧我建议只在“删除后要重建索引”的前提下使用,并且放在低峰期,配合后面的“删除后重建表”一起做。

3.4 分段删除的边界与最佳实践

这个方案最大的坑是:删除的区间里如果混着“不该删”的数据,你有多大概率误删?所以分段必须建立在“主键范围和业务条件强相关”的基础上。如果两者的相关性弱,你得在DELETE的WHERE里同时加上业务条件,比如:

DELETE FROM orders WHERE id BETWEEN 1000000 AND 1100000 AND status = 'expired';

这样写会稍微降低删除效率(因为多了条件判断),但安全性高很多。我自己一般会在条件里保守一点,宁可多扫一点数据,也不冒误删的险。原因很简单:海量删除一旦误删,恢复成本比多跑几分钟高得多。

4. 表结构允许时的最优解:用分区表,删数据就是删文件

4.1 为什么分区表能“秒删千万行”

如果你的表本身做了分区,那海量删除的问题几乎被降维化解了。MySQL 8.0支持的分区类型主要是RANGE、LIST、HASH、KEY,对时间维度的历史数据清理来说,最常用的是RANGE分区。比如订单表按月份做RANGE分区,每个分区存一个月的数据。要删除某个月之前的数据,直接:

ALTER TABLE orders DROP PARTITION p202301;

注意这个操作是DDL,不是DML。InnoDB在DROP PARTITION时做的本质是删除分区对应的物理文件,整个操作不产生逐行删除的行为,所以速度极快——一个1亿行的分区,DROP它可能也就几秒钟。这是所有DELETE方案都比不了的。

如果你的业务周期划分不是按月,也可以按季度、按年。只要你在分区创建时预留好未来分区的余量(也就是提前建好后面几个月的分区),清理数据的时候直接drop掉过期分区,根本不用写任何DELETE语句。

4.2 分区表改造的步骤与注意事项

如果你现在这个表还没分区,想改成分区表,那就要谨慎了。对已有大数据量的表执行分区操作有几种方式,最稳妥的是“新建分区表+导入数据+切换”的方式:

-- 1. 创建带分区的目标表 CREATE TABLE orders_partitioned ( id BIGINT NOT NULL, order_no VARCHAR(64), create_time DATETIME, ... PRIMARY KEY (id, create_time) ) ENGINE=InnoDB PARTITION BY RANGE (YEAR(create_time) * 100 + MONTH(create_time)) ( PARTITION p202301 VALUES LESS THAN (202302), PARTITION p202302 VALUES LESS THAN (202303), PARTITION p202303 VALUES LESS THAN (202304), PARTITION p202304 VALUES LESS THAN (202305), PARTITION pMax VALUES LESS THAN MAXVALUE );

注意:分区表要求分区字段必须包含在主键和唯一索引中。这是MySQL的一条硬性规定(“every unique key on the partitioned table must include every column in the partitioning expression”)。比如上面我把id和create_time放一起做了联合主键,才满足要求。这个规定非常坑人,很多表结构改分区失败都是栽在这里。如果你的业务主键是单列id,又想按create_time分区,那要么改主键为联合主键(对现有业务有影响),要么放弃分区方案。

-- 2. 将历史数据按分区规则插入 INSERT INTO orders_partitioned SELECT * FROM orders WHERE create_time < '2023-04-01'; -- 3. 业务停写或低峰期切换表名 RENAME TABLE orders TO orders_old, orders_partitioned TO orders;

4.3 没有提前规划,那就用在线DDL工具做分区

如果你的表已经几千万行了,尺寸巨大,直接在线上执行ALTER语句分区的耗时和风险都让人望而却步。可以考虑使用gh-ost或者pt-online-schema-change这类在线DDL工具来执行分区操作。它们通过触发器或者binlog复制的方式,在目标表上重建新结构,切换过程几乎不影响读写。但因为涉及复杂的表结构转换,我只建议有一定经验的同学操作,并且一定要在测试环境完整演练一遍再上生产。

4.4 分区的维护成本:别把“分区”当万能药

讲了分区这么多好处,我也要泼一盆冷水。分区表不是没有代价的。最明显的一点是:某些查询没带分区键时,性能可能不升反降。比如你设置了按create_time分区,但业务查询经常只按order_no查,那MySQL会扫所有分区,等于退化成了全表扫描。所以是否上分区,要先梳理清楚核心查询的WHERE条件。另外,分区本身也是DDL操作,每次拆分、合并、删除分区都要小心全局元数据锁(MDL)的影响。生产环境的表要操作分区,依然建议低峰期做,最好配合在线DDL工具一起用。

5. 腾出磁盘空间的一劳永逸方案:新建表 + 切换 + 重建

5.1 核心思路:数据“搬家”,而不是逐行“删除”

前面说过,普通DELETE不管你怎么删,物理文件都不会变小。如果你清理海量数据的最终目的是释放磁盘空间或者让表恢复到干净紧凑的状态,那正确思路是“把要保留的数据复制到一张新表,然后用新表替换旧表”。保留的数据越少,新表就越小,旧表直接DROP掉即可。

这个方案的执行流程是:

  1. 根据业务条件,把要保留的数据导到新表。
  2. 业务停写或低峰期,做一次数据补齐和表名切换。
  3. DROP掉旧表,磁盘空间随之释放。

5.2 完整操作步骤(以保留近3个月数据为例)

第一步:创建新表。最简单的方式是复制旧表结构:

CREATE TABLE orders_new LIKE orders;

这个语法会复制表结构、索引、自增属性,但不会复制数据,非常适合用来做换表。

第二步:把需要保留的数据搬进新表。

INSERT INTO orders_new SELECT * FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 3 MONTH);

这里有一点要注意:如果保留数据量也很大(比如上亿行),INSERT不进去那么快,而且会产生大量binlog。可以考虑在业务低峰期执行,或者用INSERT INTO ... SELECT ... WHERE ...加上分批处理逻辑,和前文的分批删除逻辑相通。无论如何绝对不要在业务高峰期执行这个INSERT SELECT,它会对源表加锁,可能导致线上写入被阻塞。

第三步:切换表名。这里最安全的是用两个原子性RENAME:

RENAME TABLE orders TO orders_old, orders_new TO orders;

MySQL的RENAME TABLE是原子操作,也就是说这一步要么全部成功,要么全部失败,中间不会有“表不存在”的间隙。这也是为什么推荐用两条RENAME而不是先DROP旧表再RENAME新表。如果先DROP再RENAME,中间那几秒你线上所有访问orders的语句都会报“table doesn't exist”。

切换完成后,应用立刻开始访问新表。新表里只有近3个月数据,所有查询都会比原来快不少。

第四步:验证后DROP旧表。

-- 验证新表数据量、索引、自增值都正常 SELECT COUNT(*) FROM orders; SHOW INDEX FROM orders; -- 确认无误后,删除旧表释放磁盘空间 DROP TABLE orders_old;

DROP表的瞬间会释放磁盘文件,这个过程中I/O会有些波动,但对业务基本无感。需要注意的是,DROP一张几十GB的大表本身也可能产生瞬时I/O压力。如果表特别大(比如超过100GB),可以在低峰期执行,或者考虑用硬链接方式分批释放文件,这里不展开,知道有这回事就行。

5.3 这个方案有一个必须处理的脏数据问题

新建表切换方案最大的隐患在于数据一致性。从“把数据复制到新表”到“切换表名”之间,旧表可能又有新的写入或修改(如果你没有完全停写的话)。这些增量数据不会自动出现在新表里。

要规避这个问题,要么在完全停写窗口内完成第二步和第三步(适合可以接受短时间停服的场景),要么利用binlog或应用层双写来补齐增量。对于大部分互联网业务来说,最现实的是选一个业务量最低的时间窗口,比如凌晨2点,申请5到10分钟的只读维护窗口,一口气完成复制和切换。这个方案在架构上是最干净的,也是我推荐你在数据量特别大、清理比例又特别高时优先考虑的路径。

5.4 什么时候“换表”优于“删除”

这里给个简单的经验总结。假设表总行数X,要保留行数Y。如果Y/X的比例低于30%,换表方案的综合效率显著高于DELETE方案。因为这种情况下你要拷贝的数据少,新表很小,切换后所有查询受益,磁盘也彻底释放。如果Y/X超过50%,新建表拷贝的数据量太大,反而不如分批DELETE+碎片整理划算。记住这个比例分界线,你做技术选型时就不用再纠结了。

6. 生产环境更优雅的归档工具:聊聊pt-archiver

6.1 pt-archiver到底解决了什么问题

如果你不想自己写存储过程、又要批量删除+归档数据,Percona Toolkit里的pt-archiver就是为这个场景量身定做的。它是业界最常用的MySQL归档和清理工具,核心优势有三点:一是自动分批,每次操作一行或一批,控制锁粒度;二是可以同时把删除的数据插入到归档表;三是不要求你手写循环逻辑,参数化配置就行。

它适合的场景包括:定时清理归档历史数据、大表冷热数据分离、删除时保留一份备份到归档库(比如归档到另一张表或者另一个实例)。我自己在生产上用过它清理过一张几千万行的流水表,工具平滑度非常高,没有出现锁等待或从库延迟异常。

6.2 一条命令教会你用pt-archiver

基本用法如下,需求是:把orders表里2023年之前的数据删除,同时把删除的数据备份到orders_archive表:

pt-archiver \ --source h=127.0.0.1,P=3306,u=archive_user,p=xxx,D=test,t=orders \ --dest h=127.0.0.1,P=3306,u=archive_user,p=xxx,D=test,t=orders_archive \ --where "create_time < '2023-01-01'" \ --limit 1000 \ --txn-size 1000 \ --sleep 0.5 \ --bulk-delete \ --bulk-insert \ --statistics

参数含义拆解一下:

  • --source和--dest:源表和目标表。如果只做删除不需要归档,可以省略--dest。
  • --where:删除条件,这是核心筛选条件,注意这里必须走索引,否则工具会提示全表扫描风险。
  • --limit 1000:每批取多少行。
  • --txn-size 1000:多少行提交一次事务。这两个参数配合控制锁粒度。
  • --sleep 0.5:每批之间的休息时间,单位秒,用来控制删除速率,这是保护从库不延迟的重要参数。
  • --bulk-delete和--bulk-insert:使用批量删除、批量插入的方式,比逐行操作高效得多。
  • --statistics:输出执行统计,方便事后分析。

在实际执行前,官方工具支持--dry-run参数,只做语法检查而不真正执行,可以拿它先验证命令是否正确:

pt-archiver --source ... --where ... --dry-run

6.3 使用pt-archiver的3条实践经验

第一,连接账号权限最小化就好,只需要源表SELECT、DELETE,目标表INSERT的权限,别一上来就给root。这不仅是安全习惯,也能防止操作失误时影响面扩大。

第二,如果目标归档表和源表结构完全一样,--dest指定的表可以提前建好,否则工具会自动尝试创建,但字段映射可能出错。我建议手动先建好归档表,不要依赖工具自动建表。

第三,必须确认WHERE条件能走索引。pt-archiver执行时会做 explain 检查,如果发现全表扫描,它有--no-check-charset之类的参数跳过一些检查,但全表扫描删除依然会拖垮库。执行前用EXPLAIN看一眼执行计划最稳妥。

7. 删除之后的善后工作:表空间整理与索引维护

7.1 为什么删完数据表文件还是那么大

回到前面提到的那个历史遗留问题:DELETE只做逻辑删除,标记记录为“已删除”,物理空间不会立刻返还给操作系统。InnoDB表空间内部虽然可以复用这些空间给后续INSERT,但是文件大小不缩。因此如果你执行完大批量DELETE之后,用ls -lh看.ibd文件,会发现文件大小一点没变。

如果你的目标是压缩物理文件,目前通用的做法是重建表。在MySQL 5.7之后的版本里,执行:

ALTER TABLE big_table ENGINE=InnoDB;

可以重建主表数据。这个操作在MySQL 5.6++以后是Online DDL,允许在重建期间继续读写,但它仍然会消耗额外的磁盘空间(因为要生成临时表文件),耗时也比较长,依旧建议在低峰期操作。

MySQL 8.0还有一种新姿势,用ALTER TABLE ... ALGORITHM=INPLACE配合在线操作,但也要看版本和表结构具体情况。最稳妥的仍是按前面第5节说的“建新表+切换”方案,彻底搬家一次,物理和逻辑上都干干净净。

7.2 清理碎片和重建二级索引

另外一个容易被忽略的问题是索引碎片。大量删除后二级索引叶子节点会有很多空位,索引空间利用率下降,查询性能受影响。除了重建表,你还可以对某个二级索引单独做整理:

ALTER TABLE big_table DROP INDEX idx_create_time, ADD INDEX idx_create_time (create_time);

这个操作的本质是删索引再重新建索引,索引重建期间相关查询会变慢。如果有多个索引需要重建,建议一个一个来,别批量操作,避免表上长时间没有可用索引。

7.3 统计信息更新:ANALYZE TABLE不能省

大量删除之后,MySQL的优化器基于采样得到的统计信息可能已经严重过时,于是执行计划就可能出错:本该走索引的查询走了全表扫描,本该用小索引的查询选了个大索引。高负载下你还会看到information_schema.tables里的数据行数完全对不上(那里面的值是估算的,不是实时的)。这时候就需要主动更新统计信息:

ANALYZE TABLE big_table;

这个操作在MySQL 8.0里执行很快,因为它只是重新采样计算统计信息,不需要重建数据。在批量删除后的当天夜里或第二天的低峰期执行一次,能有效避免优化器误判。

8. 常见问题排查与踩坑实录

8.1 问题一:删着删着从库延迟越来越大

这应该是最常遇到的现象。分批DELETE本身没问题,但如果你批次太大、SLEEP太短,从库应用binlog的速度就跟不上主库产生binlog的速度。排查流程:先执行SHOW SLAVE STATUS\G看Seconds_Behind_Master,确认延迟数值和趋势;再看主库上是不是有长事务在跑,information_schema.innodb_trx里如果有TIME很大的事务,优先确认是不是自己的删除脚本没提交。最终解法就是把批次调小、SLEEP调大,或者在脚本里增加“延迟超过阈值自动暂停”的逻辑,这个逻辑不算复杂但非常实用。

8.2 问题二:DELETE走了索引还是很慢

很多情况下你确实给WHERE条件加了索引,但执行计划依然选择了全表扫描。原因多半是数据分布让优化器认为走索引不如全表扫描划算。比如要删除的数据占了表中大部分行,优化器估算下来全表扫更快。排查方法是执行EXPLAIN DELETE ...看type列和key列,如果发现是ALL或者key为NULL,那么要么用FORCE INDEX强制走索引,要么换成“按主键分段删除”的写法,绕开优化器的错误判断。

8.3 问题三:删除过程中出现锁等待超时

错误信息一般是Lock wait timeout exceeded; try restarting transaction。这说明你的DELETE在等一把别人持有的锁。排查方式:用SHOW ENGINE INNODB STATUS查看当前锁等待,也可以直接查sys.innodb_lock_waits视图看谁堵了谁。通常的原因有两个:一是业务本身正在高频更新你要删除的数据范围,这种场景要错峰删除;二是你的删除批次太大,单批持有大量行锁,导致后续删除互相等待。解法:减小批次、增加SLEEP、尽量在低峰期执行。

8.4 问题四:删除期间undo表空间暴涨

删除动作写undo是必然的,但暴涨到撑爆磁盘就有问题了。先看SHOW GLOBAL STATUS LIKE 'innodb_history_list_length',如果这个值一直增加不降,说明有老事务一直没结束,导致undo日志无法purge。排查information_schema.innodb_trx里是否有长时间未提交的事务,尤其注意是不是有APP服务开启了事务但没提交。一个连接空闲但没commit,就能让你所有的删除产生的undo都清不掉。遇到这种情况先确认没有长事务再说删除的事,否则你删得越快,undo涨得越凶。

8.5 问题五:DROP旧表时磁盘I/O瞬间打满

DROP大表时,操作系统需要释放对应的大文件。InnoDB删除表空间文件的过程不是瞬间完成的,但对磁盘I/O的冲击是真实存在的。如果旧表有100GB以上,建议在业务最低峰DROP,或者考虑分硬链接释放文件的技巧:先把.ibd文件硬链接到另一个路径,然后通过TRUNCATE一点点截断文件来释放空间,让I/O压力平滑化。这个技巧属于进阶操作,普通开发环境不需要用,但生产上遇到“不敢DROP大表”的情况可以尝试搜索一下官方文档和相关文章来深入。

写在最后的经验

聊到这儿,批量删除海量数据的几条主流路线基本都过了一遍。我个人的体会是:没有万能的方案,只有适合当下场景的方案。如果只是日常删几十万行,把分批DELETE写好、参数调对就够了;如果删的数据占比特别高,优先考虑新建表切换;如果表结构设计时就想清楚了数据生命周期,那么分区表是最省心的长期方案;如果公司有Percona Toolkit的运维基础,pt-archiver能让你的活变得非常轻松。

另外还有一点小建议:不管用哪种方案,动手前先把表结构和数据分布摸清楚,备份必须做好。海量删除不是不能做,而是要在可控的节奏里做。你每一次删除前多花十分钟做EXPLAIN、做备份、确认时间窗口,线上就能少一次血泪教训。希望这篇文章能帮你把那块压在心口的大石头平稳落地。

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

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

立即咨询