做MySQL运维或者开发的朋友,大概率都遇到过这种场景:业务表里积累了上千万甚至上亿条历史数据,磁盘空间告急,或者数据合规要求需要清理旧数据。你写了一条DELETE语句准备大干一场,结果执行了半天没结束,数据库负载飙升,业务查询开始变慢,甚至把主库锁死,最后只能手忙脚乱地KILL进程。这类问题在社区里几乎每周都有人问,但网上的回答要么太浅,要么给的建议脱离实际生产环境。我结合自己处理过的多次数据清理经验,把大规模数据删除这件事从原理到实操完整梳理一遍,希望你看完能少踩几个坑。
这个主题适合谁?主要是负责MySQL数据库维护的DBA、后端开发工程师,以及那些系统里已经积累了大量历史数据、正准备做清理但不知道从何下手的同学。文章不会只丢给你一个"分批删除"的结论,而是把为什么不能直接DELETE、底层到底发生了什么、不同量级和场景该选什么方案都讲清楚。
先说一句总结性的经验:在MySQL里删除大量数据,最忌讳的就是"想一次性删完"。无论你用的是InnoDB还是MyISAM,无论数据量是一百万还是上亿,只要一条DELETE涉及的数据量过大,几乎必然出事。下面我从原理开始拆。
1. 为什么MySQL删除数据这么慢:先搞懂底层原理
1.1 你以为的删除和MySQL实际做的删除完全是两件事
很多刚接触MySQL的人有一个误区,认为DELETE就是简单地把数据文件里的那几行划掉。如果是这样,删除几千万条数据应该几十秒就能完成。但实际情况是,InnoDB存储引擎的DELETE操作远比想象中复杂。
当你执行一条DELETE FROM order_history WHERE create_time < '2023-01-01'时,InnoDB做的第一件事是把符合条件的所有记录标记为"已删除"。注意,是"标记"而不是"物理抹除"。这些记录仍然占据着原来的磁盘空间,只是对外不可见了。真正的物理清理要等purge线程在后台异步执行,把那些被标记删除的记录从索引和聚簇索引中彻底移除。
这个机制本身是为了MVCC(多版本并发控制)设计的。如果一条DELETE语句执行到一半,另一条事务还需要读取被删除前的数据,它可以通过undo log找到旧版本。所以MySQL必须保留这些"已删除"的记录,直到所有可能引用它们的事务都结束。这就是为什么你删除大批量数据后,表空间文件大小并没有立刻下降,甚至通过SHOW TABLE STATUS看Data_length还纹丝不动。
1.2 慢的根源:redo log、undo log、binlog三方夹击
继续说为什么慢。InnoDB为了保证崩溃恢复能力,每次数据页的修改都要先写redo log(重做日志)。删除操作涉及数据页的变更,所以每一批记录的删除都会生成大量redo log。如果数据量大,redo log的写入量会相当惊人,磁盘IO直接被打满。
同时,每一行被删除的记录都会在undo log中记录旧值,用于事务回滚和MVCC。这意味着删除的数据越多,undo log膨胀得越厉害。如果你的删除事务一直不提交,undo log会持续累积,甚至撑爆undo表空间。更麻烦的是,过大的undo log会影响purge线程的推进效率,形成恶性循环。
别忘了还有binlog。MySQL的复制依赖binlog,任何写操作都会被记录到binlog中并同步到从库。一个删除几千万行的大事务,binlog体积可能达到几十GB。从库拿到这个巨大的binlog后,需要单线程执行(除非开启了并行复制),主从延迟瞬间飙升到几十分钟甚至几小时。线上只要出现这种大事务,基本等于给自己挖坑。
1.3 锁竞争和索引维护才是真正的"隐形杀手"
除了上述日志,锁的问题更直接。InnoDB的行锁是基于索引实现的,一条UPDATE或DELETE语句在扫描到符合条件的行时,会在该行上加锁。如果WHERE条件没有走索引,那么InnoDB会扫描整个表,每扫一行就加一把锁,最终相当于锁住了整张表。
而且InnoDB的锁不只是行锁,还有间隙锁(Gap Lock)和临键锁(Next-Key Lock)。在有二级索引的范围内执行删除时,间隙锁会阻止其他事务向该范围内插入数据。这意味着你的DELETE语句不结束,涉及范围内的写入全部被阻塞,业务表现为"卡死"。
索引维护也是开销大户。每张表的二级索引都需要同步更新。删除一行记录,聚簇索引中要标记删除,所有二级索引中对应的条目也要处理。如果这张表有5个二级索引,那么删除一行的代价就是6次索引维护操作。数据量一上去,这个开销被无限放大。这也是为什么很多表删除几百万条数据要花几个小时的原因——你以为在删数据,实际上是在维护索引。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分批删除:最务实且可控的落地方案
2.1 为什么说"DELETE ... WHERE id < N"不是好写法
既然一次删除太多会出问题,那么"分批"的思路自然被提出来。但具体怎么分,很多人没有思考过。网上最常见的写法是:
sql复制DELETE FROM order_history WHERE id < 1000000;
这种写法的问题是:如果你要删除的数据分布在整张表的不同位置,这种固定范围的写法不但控制不了每次删除的行数,还可能因为扫描范围过宽导致锁范围过大。一次删除50万行和一次删除500行,在锁的持有时间上完全不是一个量级。
另外一个新手常犯的错误是直接加LIMIT:
sql复制DELETE FROM order_history WHERE create_time < '2023-01-01' LIMIT 1000;
这个写法在MySQL中确实可行,但LIMIT的删除没有明确顺序,行会被随机抽取。更关键的是,这种写法每次重新扫描表,无法有效利用之前删除的进度。如果表非常大,每次全表扫描的成本会随着删除的进行不断恶化。
2.2 标准分批删除姿势(附SQL)
我推荐的方式是"基于主键范围+每次限定行数+睡眠"。核心思路是:每次删除一批,记录本次删除的最大主键ID,下一批从这个ID开始继续。这样每一批的删除范围都非常小,锁的持有时间极短,而且不产生全表扫描。
具体做法如下:
sql复制-- 每次删除1000条,循环执行
DELETE FROM order_history
WHERE id BETWEEN 1 AND 1000;
-- 记录本次删除的最大ID,下一次从1001开始
DELETE FROM order_history
WHERE id BETWEEN 1001 AND 2000;
如果想写成一个存储过程自动执行,可以参考下面这段逻辑:
sql复制DELIMITER $$
CREATE PROCEDURE batch_delete()
BEGIN
DECLARE v_max_id BIGINT DEFAULT 0;
DECLARE v_batch_size INT DEFAULT 1000;
DECLARE v_affected INT DEFAULT 1;
WHILE v_affected > 0 DO
-- 找到当前最小ID
SELECT MIN(id) INTO v_min_id FROM order_history WHERE create_time < '2023-01-01';
IF v_min_id IS NULL THEN
SET v_affected = 0;
ELSE
-- 按ID顺序删除一批
DELETE FROM order_history
WHERE id <= v_min_id + v_batch_size
AND create_time < '2023-01-01';
SET v_affected = ROW_COUNT();
-- 每批之间暂停2秒,让主从同步和purge线程有喘息时间
DO SLEEP(2);
END IF;
END WHILE;
END$$
DELIMITER ;
注意上面的存储过程用了两步:先取最小ID,再删除一个ID区间内的数据。这样做的好处是每次删除都走主键范围,锁的范围精确可控,而且每一批的进度可以随时通过SELECT MIN(id)来确认。
2.3 批量参数的选型参考表
分批删除时,每批删除的行数、睡眠时间、删除条件的选择都需要根据实际环境调优。我把自己常用的参数范围整理成表格,方便你对照参考:
| 参数 | 建议范围 | 说明 |
|---|---|---|
| 每批删除行数 | 500~5000 | 行数太少则循环次数多、总耗时长;行数太多则单次事务过大 |
| 每批睡眠时间 | 1~10秒 | 给主从同步、purge线程留时间;主从延迟高时取较大值 |
| WHERE条件 | 必须走索引 | 优先用主键范围,其次是二级索引;禁止全表扫描 |
| 是否记录进度 | 必须 | 程序中途崩溃时可以从上次进度恢复 |
| 事务提交方式 | 每批一提交 | 不能用一个大事务包住所有批次 |
这里特别注意:睡眠时间不是越长越好。如果你在凌晨低峰期操作,睡眠1~2秒就够了;如果在业务高峰期操作,建议直接暂停,或者把每批降到100条左右,睡眠时间加长。从成本角度衡量,分批删除虽然慢,但它把一个大事务拆成了很多小事务,每批对系统的影响都是短促可控的,这是它的核心价值。
3. 表重建:从物理结构上彻底解决删除问题
3.1 新建表+切换的完整流程
分批删除适合数据量在千万级别以下的清理。如果一张表已经积累了几个月甚至几年的数据,需要清理的量超过总数据量的50%,再逐批DELETE就不划算了。因为DELETE之后产生的碎片依然占用磁盘空间,即使purge线程清理完,表空间也无法自动收缩。
这时候最有效的方案是"表重建"——保留需要的数据,新建一张表,把旧表的数据迁移过去,然后切换表名。这个方案的本质是绕过DELETE,直接从物理层面重新组织一张紧凑的新表。
具体流程如下:
第一步:保留旧表结构
sql复制CREATE TABLE order_history_new LIKE order_history;
第二步:把需要保留的数据复制到新表
sql复制INSERT INTO order_history_new
SELECT * FROM order_history
WHERE create_time >= '2023-01-01';
这一步要特别注意,如果保留的数据量也很大(比如几千万),不能一条INSERT完成,同样需要分批。可以用INSERT INTO ... SELECT ... LIMIT循环执行,或者用pt-archiver这样的工具来做数据搬迁。
第三步:切换表名
sql复制RENAME TABLE order_history TO order_history_bak,
order_history_new TO order_history;
RENAME TABLE是一个原子操作,切换瞬间完成,不会造成数据不一致。
第四步:验证数据,确认无误后删除旧表
sql复制-- 验证新表数据量、行数等
SELECT COUNT(*) FROM order_history;
-- 确认后删除备份表
DROP TABLE order_history_bak;
在这个流程中,最耗时的是第二步数据复制。如果保留的数据量有5000万条,即使每秒插入5万条,也需要1000秒。这个过程对生产环境有IO压力,建议在低峰期进行。
3.2 切换表时如何避免业务抖动
上面这个流程看似简单,但生产环境切换表名不是随随便便就能执行的。最大的问题是:在复制数据期间,旧表依然在接受新的写入。如果业务端持续往order_history里插入新数据,那么从"复制完成"到"执行RENAME"这一小段时间内新增的数据会丢失。
解决这个问题有几种思路,按照对业务的影响从小到大排列:
-
业务停写窗口:在流量最低的凌晨,暂停所有写入操作(可以通过开关配置实现),然后执行复制和切换。适用于业务上可以接受短暂中断的场景。
-
双写方案:在复制开始前,让业务同时写旧表和新表。复制完成后,确认新表数据追平,再切换到新表。这种方案实现复杂,需要应用层配合,但可以做到不停机。
-
用pt-archiver控制切换节点:pt-archiver支持把数据复制到新表后,自动对旧表执行删除。操作完成后,新表持有全部需要保留的数据,旧表剩下的都是需要清理的历史数据。这时再执行RENAME,不会丢数据。
多数情况下,我用的是第一种方案。找一个凌晨3点到5点的窗口,压测确认业务负载可控后,直接把写入开关关掉,花一小时复制数据,再花一秒钟切换表名,全程无感。
3.3 自增主键、外键、触发器这些"坑"怎么处理
表重建方案看似简单,实际执行时有很多容易忽略的细节,我一个个列出来:
自增主键:新建表时,CREATE TABLE order_history_new LIKE order_history会带上AUTO_INCREMENT的当前值。但如果你手工创建表结构而忘了设置AUTO_INCREMENT,重建后新表插入数据时主键可能从1重新开始,一旦与旧数据的主键冲突,会产生主键冲突错误。所以复制数据前要确认:
sql复制SHOW CREATE TABLE order_history;
看清楚AUTO_INCREMENT的值,在创建新表时显式指定。
外键:如果旧表被其他表通过外键引用,直接RENAME会破坏外键关系。MySQL会修改引用表的外键定义,指向新表名。但这个过程可能失败或者产生锁等待。更稳妥的做法是:先确认外键约束的关系,必要时先删除外键,重建完成后再加回来。
触发器:触发器是跟着表走的。你CREATE TABLE ... LIKE只能复制表结构,不会复制触发器。如果旧表上有触发器,需要在新表上手动重建。千万别漏掉这一步,否则新表上线后,业务逻辑会悄悄少掉一部分行为,排查起来非常痛苦。
数据校验:切换表名后,不能只看行数一致就完事。我一般会做几项抽查:最大ID、最小ID、关键维度的汇总值(比如总金额、总条数),确保数据没有因为在复制过程中漏掉一部分而出现偏差。
4. 分区表方案:从设计上让删除"秒完成"
4.1 什么场景适合用分区表
分区表是解决"删除历史数据"这个问题的另一个方向,但它的本质不是"优化删除",而是"让删除变得没有必要"。如果你的业务表本身就是按时间维度积累的,比如订单表、日志表、流水表,而且查询也几乎都带时间条件,那么分区表是非常值得考虑的设计。
MySQL支持的分区类型有RANGE、LIST、HASH、KEY四种。对于按时间清理数据的场景,RANGE分区是最常用的。它的核心思想是:把不同时间范围的数据放在不同的物理分区中,删除某个时间段的旧数据时,直接DROP PARTITION即可,这个操作只涉及数据字典的修改,不产生逐行删除的IO消耗,速度是毫秒级的。
4.2 RANGE分区按月建,删除分区就是删文件
假设你有一张登录日志表login_log,按月份分区,建表语句类似:
sql复制CREATE TABLE login_log (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
login_time DATETIME NOT NULL,
PRIMARY KEY (id, login_time)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(login_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
PARTITION p202404 VALUES LESS THAN (TO_DAYS('2024-05-01')),
PARTITION p202405 VALUES LESS THAN (TO_DAYS('2024-06-01')),
PARTITION p202406 VALUES LESS THAN (TO_DAYS('2024-07-01'))
);
当2024年1月的数据不再需要时,执行:
sql复制ALTER TABLE login_log DROP PARTITION p202401;
这个操作是瞬间完成的。对比之前讲的分批DELETE,效率差距是几个数量级。
而且分区表带来的另一个好处是查询优化:如果查询条件包含分区键(比如WHERE login_time BETWEEN '2024-03-01' AND '2024-03-31'),MySQL会直接进行分区裁剪,只扫描对应的分区,IO开销大幅降低。
4.3 分区表要注意的坑
分区表虽好,但坑也不少。首先,分区键必须是主键或唯一索引的一部分。这是MySQL的硬性规定。上面例子中主键从id改成了(id, login_time),就是为了满足这个要求。
其次,分区的数量不是越多越好。MySQL单个表最多支持8192个分区,但实际使用中,几百个分区就可能导致查询优化器计算成本增高。一般按月分区,保留24个月的数据就够了。更老的数据可以用归档或者清理的方式处理。
第三个很常见的坑是:分区表的分区字段如果用了函数(比如TO_DAYS),那么查询语句中必须用同样的函数表达式,否则分区裁剪会失败。很多人建了分区表,结果查询时发现走全表扫描,就是这个原因。
还要注意,如果一张已经存在的表要改造成分区表,需要用ALTER TABLE ... PARTITION BY重新组织数据。这个操作在数据量大时同样耗时,本质上也是一次表重建。所以分区表最好是业务上线前就设计好,或者至少在设计阶段想清楚数据增长和保留周期。
5. 常见问题与排查技巧实录
5.1 删除时间长了出现锁等待超时
分批删除时,每批DELETE的事务虽然小,但如果业务系统持续有写入,依然可能出现锁等待超时。报错通常是Lock wait timeout exceeded; try restarting transaction。
排查思路:先查information_schema下的INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS,确认当前哪些事务在等待锁。很多情况下是其他业务的UPDATE和DELETE在同一张表上产生了行锁竞争。解决办法是调整删除批次大小、增加睡眠时间,或者错开业务高峰。
还有一种情况是分批删除虽然每批提交了,但前一批commit之后,后一批还在运行时,业务的一条长事务正好卡在中间,阻塞了一批删除的加锁。这时可以考虑临时把锁等待超时时间调大:
sql复制SET SESSION innodb_lock_wait_timeout = 600;
但不建议长期设置过大的锁等待超时,否则任何一个慢事务都会让系统卡顿更久。
5.2 主从延迟居高不下
大批量删除最常见的一个现象是:主库执行完一批DELETE后,从库的延迟一直降不下来。原因很简单,从库需要执行主库传过来的每一条binlog,而删除操作的binlog是逐行记录的(除非设置了binlog_row_image=minimal)。一个删除1000万行的事务,在从库上就要回放1000万行。
排查办法:先看SHOW SLAVE STATUS里的Seconds_Behind_Master,确认延迟量级。如果延迟很高,临时把并行复制开起来(MySQL 8.0下配置slave_parallel_workers和slave_parallel_type),可以有效缓解。但根本的解决办法还是要控制每次删除的数据量。我个人的经验是:每批删除500~1000行时,从库延迟基本可以忽略;一旦每批超过5000行,延迟就明显了。
5.3 磁盘空间不降反升
很多人在执行DELETE后查看磁盘空间,发现不仅没变小,反而更大了。这是因为InnoDB的数据文件是自包含的,删除操作产生的undo log和临时文件会临时占用额外空间。如果还有大事务,undo表空间可能持续膨胀。
排查思路:先看SHOW ENGINE INNODB STATUS中的History list length,这个值表示未清除的旧版本数量。如果它持续增长,说明purge线程跟不上删除速度。另外,确认你启用了innodb_file_per_table,这样每个表的数据文件是独立的,重建表之后可以通过OPTIMIZE TABLE或ALTER TABLE ... ENGINE=InnoDB来收缩物理文件。
有一种情况是:即使分批删除了大量数据,order_history.ibd文件依然很大。这是因为文件被逻辑删除的页还在等待purge。如果确认所有旧数据不会再被查询,可以使用表重建方案彻底收缩,或者用OPTIMIZE TABLE重建整个表。
5.4 pt-archiver工具上手
除了手写SQL,Percona Toolkit里的pt-archiver是一个专门用于大批量数据归档和删除的工具,非常值得推荐。它能在控制锁粒度、限制批大小的情况下,把旧数据从一张表搬移到另一张表(或者直接删除)。
一个典型的删除命令是:
bash复制pt-archiver \
--source h=localhost,D=test,t=order_history \
--where "create_time < '2023-01-01'" \
--limit 1000 \
--txn-size 1000 \
--sleep 1 \
--purge \
--no-check-charset
解释一下几个关键参数:--limit表示每次获取的行数,--txn-size表示每批事务处理的行数,--sleep表示每批之间的等待时间(秒),--purge表示直接删除数据而不归档到别处。它最大的优势是会自动控制锁和事务大小,还会阻塞其他查询,实际用起来比手写SQL更安全。
我个人处理线上数据清理时,超过千万级的删除任务基本都用pt-archiver。它的参数设计经过大量生产环境检验,比自己造轮子靠谱得多。
写到这里,关于MySQL大规模数据删除的核心方案和避坑点都讲到了。最后再说说我的真实感受:数据清理这件事,方案选型很重要,但更重要的是一颗敬畏生产环境的心。我踩过最深的坑就是低估了"删除操作"对在线业务的影响,以为数据库服务器性能好就大胆DELETE,结果把一个晚上的核心报表查询全部堵住,第二天业务方反馈了一整天。之后再处理这类任务,我固定了一套流程:确认索引、控制批大小、留足睡眠间隔、先小规模试运行再全量执行、全程盯紧主从延迟和锁等待。每一步都不复杂,但缺一步就可能出大事。
