接了一个线上告警:订单流水表已经涨到30多亿行,磁盘使用率84%,业务方要求把2022年之前的脏数据全部清掉。当时组里新人直接写了一条 DELETE FROM order_flow WHERE create_time < '2022-01-01' 丢到主库执行,结果不到十分钟,主库活跃线程飙到200多,大量写入请求锁等待超时,从库延迟从0涨到半小时,整个订单服务差点被打挂。
这条SQL最后被我kill掉了,但教训很深。后来我把MySQL大规模数据删除的几种方案全部梳理了一遍,从分批DELETE到分区表,再到pt-archiver归档,结合生产环境的实际参数调优,总结出了这套优化技巧。这篇文章不是教科书式的讲解,是我在实际业务中踩过坑、验证过效果的方案汇总。
1. 裸DELETE为什么会把生产搞挂:从锁和日志说起
1.1 一次误操作背后的连锁反应
很多人以为DELETE语句很简单,删掉不就行了。但放在大表场景下,一条DELETE不是“删掉”这么简单,它会在数据库内部引发一连串的连锁反应。
先说一个基本事实:在InnoDB存储引擎里,DELETE不是物理清除,而是标记删除。被删除的记录行会在聚簇索引上打上删除标记,真正的物理清理要等purge线程异步完成。这带来的第一个问题就是:你删1亿行,实际上聚簇索引、二级索引的每一个页都要被标记,期间还会产生大量redo log和undo log,binlog要记录每一行的完整镜像。
我们当时那条SQL执行了大概4小时还没跑完。我去 SHOW PROCESSLIST 看,会话状态一直是“Updating”,innodb_trx表里显示这个事务已经写了几百GB的undo,binlog文件从几个涨到了几十个。最致命的是锁:InnoDB默认隔离级别是RR(可重复读),DELETE会沿着索引扫描路径对命中的记录加X锁,范围大时还会加间隙锁。业务写请求插不进来,订单入库全部堵住,主库线程被占满,从库又因为没有并行回放能力,延迟越拉越大。
1.2 单事务删除为何比想象中危险
很多人不理解“大事务”到底危险在哪。我打个比方:你把1000箱货一次全搬进仓库,看起来效率高,但搬运过程中整个仓库门口都被你堵住,别人进不来出不去。如果中途有一箱碎了(遇到死锁、锁等待超时),你还得把已经搬进去的货全部搬出来,这个回滚过程比正常搬货还要慢好几倍。
MySQL也一样。一个事务删除几百万行,commit之前所有变更都在内存里,依赖undo log才能回滚。一旦事务执行中途被kill、或触发死锁回滚,MySQL需要重放undo log恢复所有数据,回滚时间往往比正常执行时间更长。我们曾经遇到过删除1.2亿行的超大事务,kill之后回滚花了7个多小时,期间表依然被锁着,业务完全不可用。
所以大规模删除的第一条铁律就是:永远不要把一个大批量删除做成一个事务。要把一个大任务拆成无数个小事务,每个小事务只影响一小部分数据,这样任何一个批次的失败都不会引发灾难性的回滚和数据不可用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分批删除:主键区间拆批,才是稳定落地的第一步
2.1 按主键区间拆批的思路与代码
最朴素但也最通用的大规模删除方案就是分批。关键不是“分多少批”,而是“怎么分”。我见过很多同事用 DELETE FROM t WHERE status='expired' LIMIT 1000 这种写法,看似在分批,实际存在两个问题:
一是没带ORDER BY时,LIMIT每次取的行可能不同,可能出现删不干净或重复扫描。二是如果条件列没有索引,每一批都会触发全表扫描,批数越多,整体开销越大。
我的惯用做法是:通过主键范围去切分任务。先查出要删除的数据主键最小值和最大值,然后按固定步长切区间。每批只负责一个主键区间,这样每批的SQL执行计划固定走主键索引,不会出现全表扫描,而且天然支持断点续跑。
python复制import pymysql
import time
conn = pymysql.connect(
host="127.0.0.1", port=3306,
user="app", password="xxx",
database="appdb"
)
cur = conn.cursor()
# 先确认待删除数据的id范围
cur.execute("""
SELECT MIN(id), MAX(id) FROM order_flow
WHERE create_time < '2022-01-01'
""")
min_id, max_id = cur.fetchone()
batch_size = 1000
batch_id = min_id
while batch_id is not None and batch_id <= max_id:
upper = batch_id + batch_size - 1
sql = "DELETE FROM order_flow WHERE id BETWEEN %s AND %s"
affected = cur.execute(sql, (batch_id, upper))
conn.commit()
# 检查主从延迟,超过阈值就暂停
cur2 = conn.cursor()
cur2.execute("SHOW SLAVE STATUS")
row = cur2.fetchone()
if row:
seconds_behind = row[32] # Seconds_Behind_Master 字段位置
if seconds_behind is not None and seconds_behind > 10:
time.sleep(5)
if affected < batch_size:
break
batch_id = upper + 1
time.sleep(1)
cur.close()
conn.close()
这段脚本有几个关键点:
BETWEEN区间是闭区间,注意upper = batch_id + batch_size - 1,不要出现重复或漏删。- 影响行数小于请求行数时代表这个区间后面已经没有数据了,直接break退出循环。
- 每次commit后做一个sleep,给InnoDB后台线程刷脏页、给从库回放留出时间。
2.2 批大小、sleep和事务边界怎么定
这是很多新手最纠结的地方:一批到底删多少行合适?sleep多久?
我给一个经验范围:每批500~2000行比较稳妥。具体取决于单行大小。如果表的字段很多、还有text/blob字段,单行镜像可能达到几KB,删除500行产生的binlog就有几十MB,批大小就要往小调;如果表字段少、纯数值类型,3000行也没问题。
sleep时间一般设置在0.5~2秒之间。它的作用有三个:让redo log有机会落盘、让从库SQL线程追上主库进度、给其他业务线程插队执行的机会。如果sleep时间是0,从库延迟一定会滚雪球。因为row格式binlog下,从库回放一个DELETE事务,等于在主库上重新执行一次索引查找和删除,成本只会比主库更高。主库1秒删2000行,从库要1.2秒才回放完,没有间隔的话,延迟只会越来越大。
另一个经验是:可以临时调整InnoDB刷盘参数来加速,但要想清楚代价。比如 innodb_flush_log_at_trx_commit 从1改成2,本来每次提交都刷redo到磁盘,改成每秒刷一次,删除速度会明显提升。但代价是断电时可能丢最多1秒的事务。建议只在低峰期、且业务可以接受极端情况下丢少量数据时才动。sync_binlog 同理,为了速度从1改成0,断电可能丢binlog,我不建议在生产环境做这个妥协,尤其是有主从的架构下,binlog一旦丢失,从库数据完整性就会出问题。
2.3 为什么我不推荐用存储过程做删除
也有同事喜欢写存储过程在数据库里循环删,例如:
sql复制DELIMITER $$
CREATE PROCEDURE batch_delete()
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < 5000 DO
DELETE FROM order_flow WHERE id BETWEEN i*1000+1 AND (i+1)*1000;
COMMIT;
DO SLEEP(1);
SET i = i + 1;
END WHILE;
END$$
这种方案能用,但有几个短板:
第一是不好加监控。脚本方式可以在每个批次前查主从延迟、查锁等待,异常时随时中断;存储过程跑在数据库内部,异常时你只能看到一条存储过程调用在跑,没法知道跑到第几批了。第二是熔断困难。如果业务方反馈有锁等待,你想立刻停下来,只能kill连接,但COMMIT已经提交过的批次是不能回退的,这本身没问题;问题是你没法“暂停”再“继续”。第三是存储过程本身占用的连接资源、临时表资源在高峰期可能和你业务会话抢资源。
我在生产环境始终倾向外部脚本+数据库工具的组合,存储过程只适合老环境里DBA权限受限、无法安装客户端的特殊情况。
3. 分区表:把删除从DML降级成DDL的秒删方案
3.1 分区表适合什么样的业务
如果数据天然带时间维度,而且业务查询也常用时间范围过滤,那么分区表是比分批删除优雅得多的方案。思路很简单:按月建分区,历史数据过期时,直接 ALTER TABLE t DROP PARTITION p202201,语法上是DDL,秒级完成,磁盘空间直接释放。
为什么能秒删?因为InnoDB开启 innodb_file_per_table=ON(默认开启)后,每个分区就是一个独立的表空间文件。DROP PARTITION本质上就是删一个文件,不需要逐行处理,不产生大量binlog,从库同步时也只是执行一个DDL,不存在回放大事务的压力。
我当时接手过一个订单日志表,超过40亿行,只保留最近12个月的数据。业务查询永远带着 create_time BETWEEN ... AND ...。我把表按月份改成RANGE分区,清理策略变成每个月月初drop掉13个月前那个分区,整个清理过程从原来的“删几天几夜还可能引发事故”变成了 0.1秒内完成、不影响任何在线业务。
3.2 设计要点与改造代价
分区表不是万能的,它有几个前置条件很苛刻:
第一,MySQL要求分区键必须包含在主键/唯一键里。比如你原来的主键是 id,要按 create_time 分区,就得把 create_time 加进主键变成联合主键 (id, create_time)。这会对所有依赖单值主键的业务代码产生影响,改造量不小。对无法改主键的大表来说,这基本是劝退级的限制。
第二,查询条件必须稳定带上分区键。如果业务经常按用户ID查这个表,不带时间范围,优化器会扫描所有分区,性能比普通表还差。分区表最怕的就是“分区键不在查询条件下”。所以上分区表之前一定要和业务确认查询模式。
建表语法参考:
sql复制CREATE TABLE order_log (
id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
order_no VARCHAR(64) NOT NULL,
amount DECIMAL(12,2),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE COLUMNS(created_at) (
PARTITION p202301 VALUES LESS THAN ('2023-02-01'),
PARTITION p202302 VALUES LESS THAN ('2023-03-01'),
PARTITION p202303 VALUES LESS THAN ('2023-04-01'),
PARTITION p202304 VALUES LESS THAN ('2023-05-01'),
PARTITION p202305 VALUES LESS THAN ('2023-06-01'),
PARTITION p202306 VALUES LESS THAN ('2023-07-01'),
PARTITION p202307 VALUES LESS THAN ('2023-08-01'),
PARTITION p202308 VALUES LESS THAN ('2023-09-01')
);
分区数量不建议太多。我一般按月分,保留36~48个分区,超过1000个分区后元数据管理和查询优化开销会明显增大,得不偿失。
第三,已经存在的大表改造成分区表是一个大动作。ALTER TABLE t PARTITION BY RANGE COLUMNS(created_at) (...) 对InnoDB来说基本是COPY级别的重建,会读一遍全部数据再写新表,需要额外一倍左右的磁盘空间,耗时以小时计。所以这个方案最靠谱的落地时机是建表初期,或者在灰度环境下提前用新表结构导入数据,再切换,而不是对着一张大表直接ALTER。
4. pt-archiver:归档删除一步到位,附带主从延迟自动熔断
4.1 pt-archiver的基本用法
如果分区表改不了,自己写脚本又嫌维护成本高,那就直接上Percona Toolkit里的 pt-archiver。它是专门为长时间大批量归档/删除设计的工具,底层做的事情和我上面写的手工分批脚本一样,但多了很多生产级保护逻辑。
最常用的删除命令长这样:
bash复制pt-archiver \
--source h=127.0.0.1,P=3306,u=app,p=pass,D=appdb,t=order_flow \
--where "create_time < '2022-01-01 00:00:00'" \
--limit 200 \
--txn-size 200 \
--sleep 1 \
--max-lag 5 \
--check-interval 5 \
--purge \
--no-version-check \
--statistics
参数含义直白:
--limit 200:每批SELECT 200行主键。--txn-size 200:每个事务处理200行,事务边界等于批次大小。--sleep 1:批间睡1秒。--max-lag 5 --check-interval 5:从库延迟超过5秒自动暂停,每5秒重新检查一次。这是我自己手写脚本最难做好的部分,pt-archiver直接内置了。--purge:只删除数据,不归档。--statistics:结束时打印统计信息。
正式执行前,一定要先加 --dry-run 跑一遍,它会打印要执行的SQL和执行计划,不会真正删数据。我每次上生产前都至少干跑两次,确认WHERE条件、影响行数符合预期再动手。
4.2 参数调优的实战经验
用pt-archiver不是一把梭就完事,有几个参数需要根据表实际情况调:
要不要开 --bulk-delete。默认是逐行删除,也就是SELECT一批主键后,循环发单条DELETE。这种方式对网络开销大,但单条SQL轻,锁持有时间短。--bulk-delete 会把一批主键拼成 DELETE WHERE id IN (...),SQL数量少了,但单条SQL变长,锁范围也有所增加。实测下来,如果表行宽小、网络延迟较高,开bulk-delete能快一半以上;但如果在高并发业务上,我建议用默认逐行删,锁粒度更细。
wait,注意文档里max-lag对bulk-delete的支持。在较早版本里,bulk-delete模式下max-lag不生效,我遇到过删除过程中从库延迟飙到60秒还在跑的情况。后来查阅文档确认了:使用bulk-delete时,单批次操作是作为一个整体完成的,max-lag检查不会中断一个正在执行中的批量DELETE。所以如果你对主从延迟非常敏感,就不开bulk-delete,或者把limit调小。
归档场景怎么操作。需要把老数据搬到另外一张历史表时,命令要加 --dest 参数:
bash复制pt-archiver \
--source h=127.0.0.1,D=appdb,t=order_flow \
--dest h=127.0.0.1,D=appdb,t=order_flow_archive \
--where "create_time < '2022-01-01 00:00:00'" \
--limit 200 --txn-size 200 --sleep 1 --max-lag 5
注意目标表要提前建好,结构最好和源表一致,并加上相同的索引。pt-archiver是先SELECT源表,再INSERT目标表,最后DELETE源表,整个过程在同一事务内完成,两边不会出现不一致。
进程挂掉了怎么办。pt-archiver默认按主键顺序处理,重启后加上相同的 --where 条件,它会跳过已经处理过的主键范围,相当于天然断点续跑。所以不用怕执行到一半进程被杀,重跑就行。但前提是你没有改WHERE条件,一旦改了,它可能从新条件的起点重新扫一遍。
5. 删除之后:binlog、碎片和从库延迟的连锁反应
5.1 为什么删完了表文件还是那么大
第一次用分批DELETE删掉1亿行之后,我干过一件蠢事:删完了去看表文件大小,发现一点没变小,以为是删除没生效。后来才明白,InnoDB删除行时,空间不会立刻归还给操作系统。记录只是被标记删除,页里留下的空洞会优先给后续INSERT复用。
用下面的SQL可以查看空洞大小:
sql复制SELECT
table_name,
ROUND(data_length/1024/1024, 2) AS data_mb,
ROUND(index_length/1024/1024, 2) AS index_mb,
ROUND(data_free/1024/1024, 2) AS free_mb
FROM information_schema.tables
WHERE table_schema = 'appdb' AND table_name = 'order_flow';
data_free 就是已经分配但未使用的空间。如果这个值很大,说明表里空洞严重。
要不要马上整理碎片?取决于你的目的。如果只是为了清理老数据、让后续写入复用空间,可以不管。如果磁盘空间确实紧张,要把空间还给文件系统,就需要重建表:
sql复制OPTIMIZE TABLE order_flow;
或者等价写法:
sql复制ALTER TABLE order_flow ENGINE=InnoDB;
这个操作代价极大,等于把整张表的数据拷贝一遍再切换文件,需要额外一倍的磁盘空间,期间IO和锁的开销都不小。如果表有300GB,磁盘剩余不到300GB,优化到一半磁盘可能就满了。我的建议是:大表尽量用分区表方案,DROP PARTITION后文件直接删除,空间立即释放,根本不用碰碎片整理这个坑。
5.2 binlog膨胀和从库延迟的善后
大批量删除期间,binlog的膨胀速度远超很多人预期。row格式下,删除一行产生的binlog大小约等于这一行所有字段的实际数据大小。删1亿行、平均每行500字节,binlog就要写50G左右。如果删除前没确认磁盘剩余空间,很可能出现“删除没完成,binlog先把磁盘写满”的尴尬局面。
所以删除前我固定会做两件事:
一是 df -h 看磁盘剩余,然后粗略估算 预估删除行数 × 平均行长 × 2 作为binlog增量预算(乘以2是因为binlog还要包含事务头和可能的UPDATE前镜像)。预算超出可用空间的30%,就必须缩小每批删除量、拉长sleeep,降低单位时间binlog产出。
二是确认从库消费进度。删除完成后,在主库执行:
sql复制SHOW MASTER STATUS;
然后去所有从库执行:
sql复制SHOW SLAVE STATUS\G
观察 Relay_Master_Log_File 和 Exec_Master_Log_Pos 是否追上了主库的File和Position。在所有从库都消费完毕之前,不要清理过期binlog,否则从库会因找不到binlog文件而中断复制。确认追平后,可以清理一天前的binlog:
sql复制PURGE BINARY LOGS BEFORE NOW() - INTERVAL 1 DAY;
从库延迟的恢复也要有耐心。即使主库删除任务已经跑完,从库可能还有大量binlog排队等待回放。千万不能在延迟高企时直接清理binlog。我见过有同事因为磁盘紧张,在主从未追平时就purge binlog,结果从库复制直接卡死,只能重建从库,教训非常惨痛。
6. 极端场景:磁盘告急时的硬链接删表与文件收缩
6.1 为什么DROP TABLE也可能卡
有人觉得,既然DELETE慢,那我直接DROP TABLE总行了吧。大表场景下,DROP TABLE也不一定是秒完成的事。InnoDB在DROP表时,要把该表在缓冲池里关联的页面全部失效,如果表数据量大、buffer pool里缓存了很多该表的页,这个过程会持续很久,期间磁盘IO和CPU都可能出现尖刺。更麻烦的是,如果磁盘空间已经满了,DROP执行到一半可能因为写不了临时数据而失败,或者卡住。
针对“磁盘快满、又要快速释放空间”的极端情况,有一个比较geek但很有效的技巧:硬链接 + truncate 收缩文件。
6.2 硬链接删表的操作步骤
原理是利用文件系统的硬链接计数。正常情况下,表的.ibd文件link count是1。我们手动加一个硬链接,link count变成2。此时执行DROP TABLE,InnoDB只删除它自己的那个目录项,物理文件因为还有另一个硬链接存在,不会被真正删除,磁盘空间暂时不释放,但表结构已经没了。之后我们就可以像处理普通大文件一样,把硬链接文件一点点缩小,最后删掉。
具体流程:
bash复制# 1. 确认表文件路径
mysql> SHOW TABLE STATUS LIKE 'order_flow'\G
# 2. 进入数据目录,给.ibd创建硬链接
cd /var/lib/mysql/appdb
ln order_flow.ibd order_flow.ibd.hlink
# 3. 正常DROP TABLE,此时表删除很快,但物理文件还在
mysql> DROP TABLE order_flow;
# 4. 把硬链接文件逐步缩小,最后删除
truncate -s 80G order_flow.ibd.hlink
truncate -s 60G order_flow.ibd.hlink
truncate -s 40G order_flow.ibd.hlink
truncate -s 20G order_flow.ibd.hlink
rm order_flow.ibd.hlink
为什么不用 rm 一步到位?因为rm一个几百GB的文件,相当于一次性把文件占用的块全部释放,对文件系统来说是一波很大的IO冲击,磁盘监控很可能被打穿。用 truncate 逐步截短,可以让IO平滑地释放,对在线业务影响小得多。
这里必须强调风险:
- 只适用于独立表空间的表,也就是
innodb_file_per_table=ON的表。如果是系统表空间ibdata1里的共享表,不能这么做。 - 操作顺序不能错。必须先ln再DROP TABLE。如果先DROP了表再想ln,文件已经不存在,无从下手。
- DROP TABLE成功后,应用层不能再访问旧表。硬链接文件只是一个无主的文件壳,业务应该通过新建表或切换表名来恢复服务。
- 这个操作在测试环境先演练一遍再上生产,因为不同MySQL版本对文件路径的处理可能有差异。
7. 删除任务上线前,我固定的检查清单
写了这么多,最后分享一份我每次做大规模删除前都会过一遍的检查清单。这些内容都是我踩过坑之后沉淀下来的,直接照着做,能避开大部分事故:
- 确认待删除的数据量级:
SELECT COUNT(*)估算,大表用information_schema.tables的行数做个粗估即可,不必精确。 - 确认删除条件能走索引,最好是主键范围。
EXPLAIN一下,看到 filesort 或全表扫描就立刻停下来改造SQL。 - 确认磁盘剩余空间,估算binlog增量,预留至少30%的余量。
- 确认所有从库的复制状态正常,记录删除前的
Seconds_Behind_Source基线值。 - 选择业务低峰窗口,发变更审批,让应用负责人知晓删除期间可能出现慢查询。
- 先小批量试跑(比如先删1000行),观察耗时、锁等待、从库延迟曲线,确认平稳后再放开。
- 全程用脚本或监控工具盯着
SHOW PROCESSLIST、sys.innodb_lock_waits、从库延迟三项指标,异常时立刻暂停或kill。 - 结束后确认从库延迟归零,再清理binlog。
- 确认
data_free情况,决定是否需要重建表释放空间。
我个人在实际操作中的体会是:千万级数据的删除,方法对了也就是几分钟的事,方法错了就是几小时的故障。MySQL的大规模数据删除没有银弹,分区表是上限最优解,pt-archiver是通用稳妥解,手工分批是兜底方案,极端磁盘场景才轮到硬链接技巧。真正拉开差距的不是你会用哪个工具,而是你对删除过程中的锁、日志、复制、IO有全盘的掌控力,并且每一步都给自己留好了退路。
最后再分享一个小技巧:大批量删除任务跑完后,不要立刻走人。观察至少30分钟,确认业务写入延迟恢复正常、从库追平、磁盘空间回落到预期水位,才算真正结束。删除和写入一样,都是生产环境的正常操作,但它比写入更隐蔽、更容易出事——把每次删除都当作一次小型变更来对待,你就不会栽在坑里了。
