做MySQL运维或者后端开发的朋友,基本都经历过这种时刻:一张表一亿多行,业务方跟你说“这个表历史数据可以清了”,你顺手敲了一行DELETE,然后它就开始跑。五分钟、半小时、一小时后,不只是这个查询卡住,连周边业务的UPDATE、INSERT都开始排队。更糟的是,一主两从的环境里,从库延迟从几秒直接飙到几千秒,监控告警响成一片。
这个场景我这些年处理过很多回,处理得多了就发现,“大规模数据删除”从来不是一句DELETE FROM的事,而是事务、锁、binlog、复制、磁盘IO共同参与的一次系统工程。很多优化文章上来就讲“分批删除”,但很少讲清楚为什么要分批、每批删多少、怎么控制节奏、不同场景还有什么更快的招。这篇文章就围绕这些点,把我在实际环境里验证过的思路和踩过的坑完整梳理一遍,希望你在面对大表清理时,不用再赌运气。
1. 为什么大规模的DELETE会翻车
1.1 先从一条DELETE引发的“血案”说起
有一年我接手一个订单流水表,大概3亿行,里面存了五六年的历史数据。业务方要求删掉三年前的数据,量级算下来差不多8000万行。当时我年轻,也没多想,直接跑了一条:
sql复制DELETE FROM order_flow WHERE create_time < '2020-01-01';
执行下去之后,前几分钟看起来还挺正常,CPU缓步上升,扫描行数在涨。到了第十分钟,事情开始失控:数据库连接数打满,所有写请求都堵在锁等待上,从库延迟肉眼可见地往上跳,最后只能kill掉这条SQL,让业务先恢复。
事后查innodb_trx发现,那条DELETE已经积累了上千万行的undo,事务迟迟不提交,锁越持越多,几乎等于把整张表锁住了。那次之后我彻底明白:删除数量一旦上了量级,就不能再用“一条DELETE删到底”的思路,必须把大事务拆成小事务,否则数据库会被拖垮。
1.2 行锁、undo与binlog的连锁反应
很多人不理解,DELETE不就删几行数据吗,为什么能把数据库拖垮?这得从InnoDB的实现机制说起。
InnoDB删除记录并不是真的马上物理抹掉,而是先在记录上打删除标记,并在undo log里保留旧版本,供MVCC多版本并发控制使用。事务不提交,这些undo就不能被purge线程清理。你一个事务删了8000万行,undo log就会膨胀到几十GB甚至上百GB,磁盘空间吃紧不说,purge线程长时间追不上,还会导致历史版本堆积,后续查询的代价变高。
锁的影响更直接。DELETE是逐行加锁的,删多少行就加多少把行锁,锁信息要占用内存,锁等待链也会越来越长。加上默认隔离级别是REPEATABLE READ,范围删除还可能触发间隙锁,两个事务互相等锁的概率直线上升,死锁几乎是早晚的事。
还有一个容易被忽略的点:binlog。如果你的binlog格式是ROW,每删除一行记录,binlog里就要写入一条完整的“前镜像+后镜像”事件。删8000万行,binlog体积可能膨胀到几十GB,主库写入binlog的IO压力、从库回放binlog的网络和CPU压力全都会被放大。所以主从延迟飙升不是玄学,是机制上必然的后果。
1.3 执行计划不对时,删除有多慢
除了事务和锁的开销,还有一类坑藏在执行计划里。DELETE语句和SELECT一样有执行计划,如果WHERE条件没走对索引,或者过滤条件选择性太差,优化器可能选择全表扫描。全表扫描意味着每一行都要读出来判断是否满足删除条件,3亿行表哪怕是扫描一遍,也够喝一壶了。
我之前还踩过一种更隐蔽的坑:WHERE条件用了create_time < '2020-01-01',但表上只有主键id的索引,没有create_time索引。优化器评估下来,走索引去找create_time再回表,还不如直接全表扫描,于是硬生生扫了整张表。扫描过程中又不断加锁、记录undo,性能进一步恶化。
所以在执行大批量删除之前,第一件事不是写DELETE,而是先用EXPLAIN确认一下执行计划,看看是否命中了合适的索引。必要的时候,给WHERE条件里的字段建一个二级索引,删除速度会有质的提升。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分批删除的正确姿势与参数设计
2.1 第一批优化:LIMIT批量删除
既然一条DELETE删到底不行,那就把大事务拆成小事务,删一批提交一批。最直接的方式就是加LIMIT:
sql复制DELETE FROM order_flow
WHERE create_time < '2020-01-01'
LIMIT 2000;
应用层写个循环,每个循环执行一次这条SQL,直到返回的影响行数为0。每次只删2000行,事务提交一次,锁持有时间极短,undo不会无限膨胀,binlog增量也在可控范围内。这是所有分批删除方案的基础。
但这个方案有个问题:每批删除都要重新扫描一遍表,定位到满足条件的前2000行。如果删除条件命中的记录分布在整个表里,越到后面,扫描成本越高,整体效率会逐渐下降。拿上面那张3亿行的表来说,前面几千批可能还算流畅,跑到后面一批要比前面慢好几倍。
2.2 更稳的方案:基于主键范围切片
我后来在实际生产环境里用得最多的,是按主键id做范围切片,而不是单纯依赖LIMIT。思路是这样的:先查出要删除的数据范围,也就是id的上下边界,然后按id区间一段一段删,每段删完提交一次。
sql复制-- 第一步:摸清边界
SELECT MIN(id), MAX(id) FROM order_flow WHERE create_time < '2020-01-01';
假设查出来id范围是100万到2亿,那就按步长去切。比如每批处理5000条id范围内的记录:
sql复制DELETE FROM order_flow
WHERE id BETWEEN 1000000 AND 1005000
AND create_time < '2020-01-01';
这里为什么要保留create_time < '2020-01-01'条件?因为id是连续的,但create_time并不完全有序,同一个id区间内可能混着不需要删除的新数据,加上条件更稳妥。
这个方案最大的好处是:每次删除都走主键索引,定位成本很低,不需要反复扫描“前面已经删过的区间”。但有个细节要注意:BETWEEN条件如果区间内大部分数据都不满足删除条件,每批可能只删掉很少几行,批次数会大大增加。这种情况下,可以把区间步长调大一些,让每批实际删除行数更饱满。
2.3 批次参数怎么定:行数、sleep与总耗时
很多新手会问:每批到底删多少行合适?500?2000?10000?
我的经验是,单批删除行数要综合考虑三个因素:事务大小、锁持有时间和从库回放压力。单批行数越大,事务越大,锁持有的时间越长,单批的binlog也越大;单批行数太小,循环次数太多,频繁提交也浪费性能。
一般来说,单批500到5000行是比较常见的区间。如果表行数特别大,比如单行数据也比较宽,我建议从500开始测试;如果表结构比较紧凑,可以适当提到2000到5000。判断标准很直接:一批跑完,看从库的Seconds_Behind_Master是否在可控范围内,看主库的Threads_running有没有堆积。
除了批次大小,还要控制执行节奏。最原始的办法是在每一批之间加个sleep:
bash复制# 伪代码
while true; do
affected=$(mysql -e "DELETE FROM order_flow WHERE ... LIMIT 2000")
if [ $affected -eq 0 ]; then break; fi
sleep 0.5
done
sleep 0.5秒的意思是:每删完2000行,歇半秒再删下一批。这个间隙让redo落盘、从库回放、purge线程清理都有喘息时间。主从延迟敏感的环境,sleep可以调到1秒甚至更长;延迟压力小的环境,调到0.1秒也没问题。
还有一个参数值得关注:总耗时。你可以先跑一批,看看这一批花了多少毫秒,然后用“总批次数 × 单批耗时 + 批次数 × sleep时间”粗算一下整个删除任务要跑多久。这样心里有数,也可以提前和业务方确认维护窗口够不够。别等到删了一半才发现时间不够,进退两难。
3. 用存储过程把删除任务封装成可控作业
3.1 一个可复用的存储过程模板
很多人觉得存储过程是老古董,但在批量删除这种场景里,它反而特别合适:逻辑封装在数据库里,不需要额外写一堆脚本,循环、提交、中断控制都能自己搞定。
下面是我在旧项目里用过的一个模板,整体逻辑是:按主键id区间循环分批删除,每批提交一次,同时输出进度信息。
sql复制DELIMITER $$
CREATE PROCEDURE delete_old_data()
BEGIN
DECLARE v_batch_size INT DEFAULT 2000;
DECLARE v_sleep_seconds DECIMAL(3,1) DEFAULT 0.5;
DECLARE v_affected INT DEFAULT 0;
DECLARE v_min_id BIGINT;
DECLARE v_max_id BIGINT;
DECLARE v_current_id BIGINT;
-- 找到需要删除的最小id,作为起点
SELECT MIN(id) INTO v_min_id FROM order_flow
WHERE create_time < '2020-01-01';
WHILE v_min_id IS NOT NULL DO
-- 以当前id为起点,向后取一批id的上界
SELECT id INTO v_max_id FROM order_flow
WHERE id >= v_min_id AND create_time < '2020-01-01'
ORDER BY id LIMIT 1 OFFSET v_batch_size;
-- 如果上界为空,说明只剩最后一批
IF v_max_id IS NULL THEN
DELETE FROM order_flow
WHERE id >= v_min_id AND create_time < '2020-01-01';
SET v_affected = ROW_COUNT();
ELSE
DELETE FROM order_flow
WHERE id BETWEEN v_min_id AND v_max_id
AND create_time < '2020-01-01';
SET v_affected = ROW_COUNT();
END IF;
SELECT CONCAT('deleted ', v_affected, ' rows, id up to ',
COALESCE(v_max_id, 'END')) AS progress;
COMMIT;
-- 控制节奏
DO SLEEP(v_sleep_seconds);
-- 下一轮起点继续
SET v_min_id = v_min_id + v_batch_size;
END WHILE;
END$$
DELIMITER ;
这个存储过程的思路核心是“以主键id作为进度标记”。每一批结束后,下一个起点是v_min_id + v_batch_size,而不是重新扫描整个表。无论删了多少批,总能把所有满足条件的记录覆盖完,不会漏也不会重,比单纯靠LIMIT更可控。
3.2 加日志、异常处理和断点续跑
上面的模板能跑通,但生产环境里我还会做两件事:记录进度日志,处理中断恢复。
记录进度很简单,建一张表来存删除任务状态:
sql复制CREATE TABLE delete_task_log (
id INT AUTO_INCREMENT PRIMARY KEY,
task_name VARCHAR(100),
last_id BIGINT,
total_affected BIGINT,
update_time DATETIME
);
每一批删除完成后,更新last_id和total_affected。万一删到一半存储过程崩溃、连接断掉、或者你主动kill了,下次重跑时不用从头开始,先从delete_task_log里读出上次的进度,从断点继续。
中断恢复还有个更简单的思路:把每批删除写成独立事务,事务提交了就不可回滚,所以重跑时只需要让DELETE条件本身具备幂等性。我们保留create_time < '2020-01-01'这个条件,重复删同一批也不会有影响,已经删掉的记录不满足条件,自然不会被再处理。
异常处理方面,我一般会在循环体里加一个退出条件:如果某批影响行数为0,或者连续几次出现死锁错误,就记录日志并退出,避免无限循环或者陷入锁冲突的泥潭。比如:
sql复制DECLARE CONTINUE HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SELECT 'error occurred, rollback and exit' AS message;
END;
这里要注意,存储过程里的SAVEPOINT和ROLLBACK语义要理解清楚,别把已经提交的事务也回滚了。我的经验是:一批一个事务,批内失败就回滚当前批,前面已提交的批次不撤销。
4. 极端场景下更快的方案:表重建与分区表
4.1 表重建:用CREATE TABLE AS SELECT绕过逐行删除
如果删除的数据占比特别大,比如一张表要删掉80%的行,只保留20%,那分批DELETE反而不是最优方案。这时候更狠的办法是“表重建”:把需要留下的数据复制到新表,然后切换表名,最后把旧表DROP掉。
sql复制-- 第1步:把要保留的数据复制到新表
CREATE TABLE order_flow_keep AS
SELECT * FROM order_flow
WHERE create_time >= '2020-01-01';
-- 第2步:加索引,重建约束
ALTER TABLE order_flow_keep ADD PRIMARY KEY(id);
ALTER TABLE order_flow_keep ADD INDEX idx_create_time(create_time);
-- 第3步:切换表名
RENAME TABLE order_flow TO order_flow_del,
order_flow_keep TO order_flow;
-- 第4步:确认无误后,删除旧表
DROP TABLE order_flow_del;
这个方案为什么快?因为整个过程没有逐行DELETE,只有一次大范围的INSERT INTO SELECT,相当于把保留数据整体搬迁到新表,然后一次性DROP掉老表。DROP是物理删除数据页,速度接近文件删除,远比逐行打标记快。
但表重建也有明显代价:第1步复制数据期间,原表会一直持有锁或者至少产生大量读压力,期间如果有新写入,可能就丢掉了。所以这个方案一般要配合业务维护窗口,或者用工具在在线状态下处理。
如果对在线性有要求,可以用gh-ost或者pt-online-schema-change这一类工具来改表,它们能通过触发器或binlog同步增量数据,把对业务的影响降到最低。我自己在核心表上更倾向用pt-osc,图个省心。
4.2 分区表:DROP PARTITION才是物理删除
表重建再快,终究还是要复制数据。如果你的表在设计阶段就做了分区,那删除历史数据的方案会彻底不一样——直接删分区。
比如日志表按月份分区:
sql复制CREATE TABLE event_log (
id BIGINT NOT NULL,
event_time DATETIME NOT NULL,
content VARCHAR(500),
PRIMARY KEY (id, event_time)
)
PARTITION BY RANGE (YEAR(event_time) * 100 + MONTH(event_time)) (
PARTITION p202401 VALUES LESS THAN (202402),
PARTITION p202402 VALUES LESS THAN (202403),
PARTITION p202403 VALUES LESS THAN (202404)
);
当2024年1月的数据过期后,一条SQL就把整个分区干掉:
sql复制ALTER TABLE event_log DROP PARTITION p202401;
DROP PARTITION是物理级别的删除,InnoDB直接把对应的分区数据文件空间释放掉,速度极快,几秒钟就能搞定几千万行的清理,而且不会影响其他分区线上的读写。这是大规模数据清理的终极方案。
当然,分区表也有代价。主键必须包含分区键,很多业务表的自增主键突然不合规,需要重新设计;查询条件如果不带分区键也可能导致分区剪裁失效,出现扫描全部分区的问题。所以分区表更适合时间序列数据、日志数据这类访问模式非常规律的表,而不是所有表都值得改成分区。
4.3 pt-archiver:专业工具一键归档删除
如果你的团队内网可以安装Percona Toolkit,那pt-archiver必须了解一下。这是做大批量删除和归档最省心的工具,它把批量删除、主从延迟检测、sleep控制这些功能都内置了,不需要自己写存储过程。
一个典型的清理命令长这样:
bash复制pt-archiver \
--source h=127.0.0.1,P=3306,u=admin,p=pass,D=test,t=order_flow \
--where "create_time < '2020-01-01'" \
--limit 2000 \
--txn-size 2000 \
--sleep 0.5 \
--purge \
--max-lag 5 \
--check-interval 3
参数含义说明一下:
--limit 2000:每批处理2000行。--txn-size 2000:每2000行提交一个事务。它和limit保持相同,是为了保证一批就是一个事务。--sleep 0.5:每批之间sleep 0.5秒,控制主库压力。--purge:只删除,不把数据备份到文件。如果要做归档,就把--purge换成--dest指定目标表。--max-lag 5:当从库延迟超过5秒时,工具会自动暂停,等延迟降下来再继续。--check-interval 3:每3秒检查一次从库延迟。
这个工具我实测下来很稳,尤其适合主从架构下的日常清理任务。关键是它从设计上就考虑到了复制延迟问题,你不需要自己在脚本里写一堆判断逻辑。
5. 常见问题与排查技巧实录
5.1 删除任务卡住,如何定位锁问题
批量删除跑着跑着突然不走了,或者应用侧报锁等待超时,这是最常见的事故。第一步永远是去看当前有哪些事务在跑、持有哪些锁。
sql复制SELECT * FROM performance_schema.data_locks;
SELECT * FROM information_schema.innodb_trx\G
SELECT * FROM sys.innodb_lock_waits\G
sys.innodb_lock_waits会直接给出阻塞者和被阻塞者的关系,重点看blocking_pid,这就是你需要处理的元凶。大多数情况下,都是某一条大事务没有及时提交,把其他事务都堵住了。如果阻塞者是自己的删除任务,想想是不是单批提交的行数太大;如果是别的业务事务,就得联系对应负责人确认是否能提前提交。
排查完之后,如果确实需要立刻释放资源,可以执行KILL <pid>杀掉阻塞事务。但这里有个注意事项:杀戮之前先确认这个事务是不是别人的核心业务,别把人家的正常操作也杀了。稳妥的流程是先发通知,协商窗口,再动手。
5.2 主从延迟飙升,怎么快速恢复
在分批删除过程中主从延迟飘高,基本上和batch的控制节奏有关。要么是单批行数太大,要么是sleep时间太短,从库来不及回放。
缓解手段有几个:先暂停删除任务,让从库追赶主库进度;然后把批次调小、sleep调大,比如从2000行/批降到500行/批,sleep从0.2秒调到1秒;如果从库本身IO性能差,可以考虑临时提升从库配置,或者考虑用pt-archiver的max-lag参数自动控制节奏。
这里要特别提醒一点:主从延迟不仅影响读,还可能造成从库数据不一致。有些半同步复制架构下,主库写事务需要等从库ACK,延迟过大会反过来拖慢主库写入。所以在清理任务期间,还是要定时盯着延迟指标,不要想着一跑完就没事。
5.3 DELETE执行完之后,磁盘空间并没有变小
总有朋友被这个坑坑过:DELETE删了几千万行,用了SELECT COUNT检查,确实删干净了,但看磁盘,表空间文件还是那么大。这不是删除没生效,而是InnoDB的碎片和空间复用机制决定的:DELETE释放的行空间,在表空间层面标记为可复用,但不会立刻归还给操作系统。
如果后续这张表还要继续写入新数据,其实无所谓,复用的空间会被慢慢填上。但如果你希望立刻收缩表空间文件,两个办法:一是OPTIMIZE TABLE order_flow,把表重建一遍,释放碎片;二是用前面讲的表重建方案直接重建表。这两个操作都会锁表或者至少产生比较大的IO,一定要在低峰期执行。
我之前有个客户,清理完3亿行后发现磁盘还占着200多GB,急得不行。后来我帮他在凌晨跑了OPTIMIZE TABLE,等了几个小时,表大小才从200多GB降到50多GB,磁盘告警才解除。这事告诉我们,大表清理不是DELETE执行完就结束了,空间回收往往还有后续动作。
5.4 误删之后能救回来吗,怎么预防
误删这个问题,我得先泼盆冷水:如果没开binlog,或者binlog没有备份,大表误删基本是救不回来的。所以预防永远比恢复重要。
我的习惯是,在跑任何大批量删除之前,先跑一条SELECT确认范围:
sql复制SELECT COUNT(*), MIN(id), MAX(id) FROM order_flow
WHERE create_time < '2020-01-01';
确认这个数量级和预期一致,再动手删除。如果担心手滑,还可以把SQL写在脚本里,加上--safe-updates启动参数,也就是MySQL的非UPDATE/DELETE保护模式,它会强制要求WHERE和LIMIT,防止无条件的全表删除。
如果真的误删了,唯一的希望是binlog。如果binlog格式是ROW,理论上可以用mysqlbinlog解析binlog,找到删除前的镜像,再构造反向INSERT恢复。但问题是,大表删除产生的binlog体量极大,解析和回放都是噩梦,恢复时间可能比删除时间还长。我做过一次类似的救援,花了两天时间才捞回大部分数据,过程极其痛苦。所以别指望恢复,做好备份和预防才是最靠谱的。
最后说一点实际操作里的体会
大表删除这事,很多时候不是技术不会,而是低估了它对整个数据库环境的影响。一条DELETE在3亿行表上跑,不只是“删数据”,它会牵动事务日志、锁竞争、主从复制、磁盘空间、业务可用性这些环节。我自己的习惯是,删除任务之前先写一个一两百字的方案,说清楚怎么删、每批多少、预计耗时多久、如果延迟飙了怎么降,然后用测试环境先模拟一遍,再上生产。这流程看起来繁琐,但值得。尤其那些核心业务表,删错了没有重来的机会,谨慎点总没错。
