先说个背景,这问题几乎所有做后端或者数据库运维的人都躲不掉。业务跑了两三年,日志表、流水表、审计表动辄几十亿行,磁盘一天比一天紧,查询一天比一天慢。领导丢过来一句话:把历史数据清理一下。你要是直接一条 DELETE FROM big_table WHERE created_at < '2024-01-01' 丢上去,轻则锁表拖垮业务,重则回滚日志直接撑爆磁盘,整个实例都可能被拖死。
这个标题说的"另一种思路",核心就是把"删数据"这件事从耗时几十分钟的 DML 操作,变成几十毫秒的 DDL/DML 切换操作。简单说,不要盯着"怎么把不要的数据一行行删掉",而是想"怎么让这些数据更快地从表里消失"。下面我把这套思路完整拆开讲,包括适用场景、操作步骤、踩过的坑,直接抄作业就行。
1. 先搞清楚:为什么直接 DELETE 大表历史数据不行
1.1 大批量 DELETE 的三大坑
先说第一个坑:锁。MySQL InnoDB 里,DELETE 不是你想删就删的,它会对涉及的行加锁。几千万行数据,就算不是全表锁死,间隙锁加上行锁累积起来,对并发写入的影响是灾难性的。我见过一次线上事故,就是一个 DELETE 删 8000 万行历史数据,把整个订单服务的写请求堵了快 20 分钟,最后只能 kill 掉。kill 之后更惨,已经开始的事务要回滚,回滚比删除还慢,磁盘 IO 还被打满,这就是个连锁反应。
第二个坑是日志。只要是用 DML 删除,每一行数据的变化都要写 undo log、redo log,同时 binlog 也会记录。我实测过一个例子,一张 20GB 的表删除 80% 数据,事务日志和 binlog 加起来暴涨了接近 100GB。磁盘容量不够的话,直接实例宕机,而且 InnoDB 的 purge 线程要慢慢清理 undo,未必能及时释放出来。
第三个坑是表空间。InnoDB 删除数据之后,如果 innodb_file_per_table 开启,表文件会留下大量空闲页,但物理文件不会自动收缩。你删完数据,用 du -sh 一看,还是那么大。磁盘空间根本没回来,只是变成了表内部的空洞。而且这些空洞会导致索引碎片化,后续查询全表扫描的 IO 更高,性能反而可能更差。
这三个坑叠在一起,结论很明显:大表历史数据的删除,靠一条 DELETE 解决是行不通的。这也是为什么大家都在找"另一种思路"。
1.2 分批 DELETE 的局限
你可能会说,那用 DELETE ... LIMIT 1000 一批批删,每次删完 sleep 几秒,避开高峰期跑一整夜总行吧?
这种分批 DELETE 确实在很多场景里是安全兜底的做法,但它的上限很低。我对它的评价是:用来"止血"可以,用来"根治"不行。
分批 DELETE 的局限至少有四点:
- 速度太慢。每批 1000 行,几千万行数据要跑几万轮,一轮就算 0.5 秒,那也是几个小时起步。而且每批都是一次独立事务,事务本身的提交开销也要算进去。
- 碎片和空间问题依然存在。你怎么删,InnoDB 的表文件都不会自动压缩,物理空间还是占着。
- 对从库不友好。每一批删多少行,binlog 就记录多少,主库跑完一批,从库要接力执行,主从延迟可能越拉越大。很多线上主从延迟告警就是这么来的。
- 操作窗口要求高。你得找业务低峰期,还得观察每批的执行时间,整套流程又长又脆。
所以我认为,分批 DELETE 更适合那些"数据量不算特别大,但业务完全不能停"的场景。如果数据量真的到了"亿级"或者"表占用空间超过几百 GB",直接换思路才是正解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 思路一:把"删除"变成"切分区"——分区表 DROP PARTITION
2.1 分区表怎么设计才不后悔
如果历史数据清理是你系统里长期要面对的问题,最好的解是提前规划分区表。
分区表的核心逻辑很简单:数据按照某个规则(通常就是时间)拆到不同分区里,删除历史数据变成 ALTER TABLE t DROP PARTITION p202401;。这是 DDL 操作,InnoDB 层面只是删除分区对应的物理文件加元数据更新,速度是秒级的,也不需要逐行写 binlog。
但分区表不是建了就完事,有几个点必须在设计阶段想清楚:
- 分区键必须选对。按时间清理,就用
created_at或者DATE(created_at)。注意:查询条件里如果没带分区键,会走全分区扫描,比普通表还慢。这是分区表最常用的坑。 - 分区粒度要合理。我建议日表/月表都行,但月分区最常见。分区太多也有管理负担,MySQL 8.0 虽然支持最多 8192 个分区,但几千个分区会让优化器开销变大,没必要的。
- 提前规划保留周期。比如"线上保留 6 个月",那就定期新建分区,同时把 6 个月前的分区 drop 掉,两个动作打包成存储过程或定时任务。
如果你的表已经是普通表,也别慌,可以用 ALTER TABLE t PARTITION BY RANGE ... ; 在有数据的情况下重建分区。不过这个操作会锁表重建,表很大时要评估好停机窗口,建议在主从架构上先在从库执行、切换后再处理主库。
2.2 删除流程与实操细节
我以 MySQL 为例,把完整流程走一遍。
假设表结构关键部分如下:
sql复制CREATE TABLE `order_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_id` varchar(32) NOT NULL,
`created_at` datetime NOT NULL,
...
PRIMARY KEY (`id`, `created_at`)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(created_at)) (
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 p_max VALUES LESS THAN MAXVALUE
);
这里有个必须注意的点:表的主键或唯一键必须包含分区键。MySQL 要求分区键必须是主键的子集,所以上面我把 PRIMARY KEY (id, created_at) 改了,否则建表直接报错。如果业务上有单独的 id 主键查询,这就有点难受,但为了分区清理功能,这个代价通常值得。
到了 2024 年 4 月 1 日,想要清理 2024 年 1 月的数据,直接执行:
sql复制ALTER TABLE order_log DROP PARTITION p202401;
这一条命令在千万级数据分区上,通常 1 秒以内完成。物理文件直接删除,磁盘空间立即释放,binlog 记录也只是很小的元数据变更,从库执行也快。
不过,DML 删除会写入完整行数据到 binlog,DROP PARTITION 则只是删分区的元数据事件,不包含被删数据的具体内容。这是一个非常大的优势,尤其是如果你想做跨机房同步,binlog 流量能省几百 GB。
新增分区也要及时跑:
sql复制ALTER TABLE order_log ADD PARTITION (
PARTITION p202405 VALUES LESS THAN (TO_DAYS('2024-06-01'))
);
建议写个每个月 1 号凌晨执行的定时任务:先创建下个月分区,再删除 7 个月前的分区。
实操中我还会加一道保险:DROP PARTITION 前,不直接删,而是先用 ALTER TABLE t EXCHANGE PARTITION p202401 WITH TABLE t_202401_bak; 把分区变成独立表,确认归档完成之后再 DROP。这样即使发现误删,还有个表可以抢救。Exchange 操作在 MySQL 里也是秒级的,代价很低。
2.3 分区方案在 Oracle 和 PostgreSQL 的落法
这套思路在 Oracle 里是主流做法,而且它还有一个更"暴力"但非常好用的操作:分区交换(Partition Exchange)。
Oracle 里删除历史分区:
sql复制ALTER TABLE order_log DROP PARTITION p202401;
如果想备份出来再删,直接:
sql复制ALTER TABLE order_log EXCHANGE PARTITION p202401 WITH TABLE order_log_202401 INCLUDING INDEXES;
之后 order_log_202401 就是一张独立的普通表,你可以随便归档,甚至丢到备份表空间。整个过程也是秒级。
PostgreSQL 从 10 开始支持原生声明式分区,到 11 之后 DETACH PARTITION 和 DROP TABLE 分开了,删除历史数据也有很好的手段:
sql复制CREATE TABLE order_log_202401 PARTITION OF order_log
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
ALTER TABLE order_log DETACH PARTITION order_log_202401;
DETACH 之后分区变成独立表,你可以先保留观察,确认没问题再 DROP TABLE order_log_202401;。PostgreSQL 里 DETACH 同样只需要改 catalog,速度接近秒级。
所以"用分区把删除变成切分/切表"是跨数据库的通用思路,不是某个数据库独有的技巧。
3. 思路二:把"删数据"变成"换表"——影子表重建
3.1 影子表的完整操作流程
如果你的表已经是大表了,且没有做分区,来不及补分区,那么影子表(或者叫重建表)是最实用的"另一种思路"。
核心思想:不逐行删除旧表数据,而是新建一张表,只把要保留的数据查出来放进去,然后通过 RENAME TABLE 瞬间切换,旧表直接丢弃或延迟删除。对数据库来说,"删除"这个动作被简化为一次文件名和元数据的交换,速度极快,对业务的影响窗口非常短。
操作步骤我以 MySQL 为例。
第一步,创建新表结构和旧表一致:
sql复制CREATE TABLE order_log_new LIKE order_log;
第二步,把需要保留的数据迁入新表。这里通常有两种情况:
- 如果保留"最近 6 个月",就 INSERT SELECT 近 6 个月数据;
- 如果保留的数据量很小(比如只保留状态异常的记录),直接就按条件抽。
sql复制INSERT INTO order_log_new SELECT * FROM order_log
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH);
这一步是唯一耗时的环节,但它不会锁旧表写入吗?会。如果直接一条 INSERT SELECT,会在目标表上加锁,源表读取时可能影响并发。所以这里建议也分批做,比如按天范围循环插入,每批几万行,源表和目标表都留出喘息空间。虽然整个迁移可能要跑几十分钟到几小时,但它和直接 DELETE 的区别是:迁移期间旧表还在正常服务,业务影响小得多。
第三步,切表。在业务低峰期执行:
sql复制RENAME TABLE order_log TO order_log_del, order_log_new TO order_log;
这条语句是原子的,瞬间完成。MySQL 的 RENAME 只是修改数据字典,不拷贝数据。业务连接在语句执行完的瞬间切换到新表上。
第四步,确认一切正常后,删除旧表:
sql复制DROP TABLE order_log_del;
大表 DROP 的速度取决于物理文件大小和 IO。一个 200GB 的文件在普通磁盘上删可能要几分钟,这期间 InnoDB 会持有一些锁,但从库同步也可能有延迟。所以建议放在低峰期执行。
3.2 换表过程中的并发与一致性处理
很多人会问:迁移要跑半小时,这期间旧表还在接收新数据,切表之后新表里没有这半小时的新数据怎么办?
这是个非常现实的问题。解决方案至少有三种:
方案一:在业务低峰(比如凌晨)执行,业务写入量本身很少,接受轻微数据补偿。切表后写一个脚本把 order_log 上凌晨之后的新数据补到新表,再重新切一次。运维成本低,但不够优雅。
方案二:双写。迁移期间,应用层写操作同时写旧表和新表,切表时两边数据基本一致。这种方式对应用代码有侵入,适合小团队能快速改代码的情况。
方案三:利用 binlog 或 CDC 工具追平。迁移期间开启一个解析任务,把旧表的增量 binlog 同步到新表,等追平到当前时间点后,再快速执行一次小范围的 INSERT ... ON DUPLICATE KEY UPDATE 补齐最后几秒的数据,然后切表。这是我在生产环境里验证过最稳的方式。如果你手头有 canal / Debezium 这类组件,直接复用即可。
另外,换表时有几个对象千万别漏:
- 自增主键。
CREATE TABLE LIKE会保留AUTO_INCREMENT的当前值,但 INSERT SELECT 之后AUTO_INCREMENT会变成当前最大值 + 1,这个通常没问题。但你得确认没有手动指定过 id 的不连续,导致新表自增值小于旧数据。稳妥起见,切表后手动执行一下ALTER TABLE order_log AUTO_INCREMENT = 20000000;也不为过。 - 触发器、外键、视图、存储过程。
CREATE TABLE LIKE不会复制触发器,外键也可能阻碍 RENAME。MySQL 的 RENAME 遇到外键关联时是有限制的,如果旧表被外键引用,会直接报错。这类约束要么先删除,要么改为软删除策略。 - 索引和统计信息。LIKE 复制了索引结构,但统计信息会在新表数据导入后自动重算。如果执行计划异常,可以手动
ANALYZE TABLE order_log;。
3.3 旧表怎么安全销毁
影子表方案的最后一步是删旧表,这一步骤也不能掉以轻心。
这里我分享一个"硬链接"技巧,很多 DBA 习惯用它来消除 DROP TABLE 对磁盘的 IO 冲击:
- 先通过系统层硬链接把旧表的
.ibd文件连一份到别的路径,比如/tmp/order_log_del.ibd。 - 然后执行
DROP TABLE order_log_del;。此时 InnoDB 删除的是硬链接,物理文件还有另一个链接指向它,所以磁盘空间不会立即释放,但 DROP TABLE 会非常快,秒级完成。 - 之后再在后台慢慢删除
/tmp/order_log_del.ibd,或者直接放到延迟清理目录,磁盘空间慢慢回收。这个过程中,数据库完全无感。
具体命令(以 Linux 为例):
bash复制ln order_log_del.ibd /tmp/order_log_del.ibd
然后执行 SQL 层的 DROP,再:
bash复制rm /tmp/order_log_del.ibd
注意 rm 一个几百 GB 的文件在普通机械盘上也要跑一会儿,所以放后台 rm 并观察磁盘 IO 即可。如果磁盘空间告急,想立即释放,也可以不玩硬链接,直接 DROP,但要接受一段时间的 IO 压力和从库延迟。
4. 思路三:归档之后直接截断——适合"留少删多"
4.1 操作步骤
还有一种特殊情况:表里 99% 都是历史数据,只需要保留最近一小部分,比如只留今天的,或者只留下某些特殊标记的记录。这时候最干净利落的思路是:先归档要保留的数据,然后 TRUNCATE TABLE 整表清空,再把保留数据插回去。
TRUNCATE 是 DDL,它的实现方式是直接重建表、删除所有数据文件,不逐行删除,速度极快,而且会立即释放表空间。这是 DELETE 完全比不了的。
操作步骤:
第一步,创建一张临时表,结构和旧表一致:
sql复制CREATE TABLE order_log_tmp LIKE order_log;
第二步,把要保留的数据挪过去:
sql复制INSERT INTO order_log_tmp SELECT * FROM order_log WHERE today = CURDATE();
第三步,确认数据行数无误后:
sql复制TRUNCATE TABLE order_log;
第四步,把保留数据放回来:
sql复制INSERT INTO order_log SELECT * FROM order_log_tmp;
DROP TABLE order_log_tmp;
整套动作必须在业务停写窗口内完成,因为 TRUNCATE 会获取表的排他锁,期间任何 DML 都会被阻塞。所以这个方案特别吃停机窗口,适合凌晨两三点那种连告警都没人看的时段。
4.2 适用场景与风险
这个方案我在两类场景里用过,效果都很好:
一是"滚动清空"型业务表。比如临时计算表、接口请求埋点表,每天写入大量数据,次日只留汇总结果。直接 TRUNCATE + 重灌汇总数据,比 DELETE 快几个数量级。
二是日志归档后的源表。例如我们曾经有一个访问日志表,数据先被任务归档到 HDFS,源表只保留最近 3 天。每天凌晨跑一个归档任务,归档完成后 TRUNCATE,然后从备份恢复最近 3 天数据。听起来有点绕,但实际执行时间非常短,凌晨业务影响可以忽略。
这个方案最大的风险是操作失误会全表数据丢失。所以实操上有几条铁律:
- TRUNCATE 前必须验证保留数据的行数和关键字段,最好导出一份到备份库或文件。
- TRUNCATE 操作前建议先
FLUSH TABLES WITH READ LOCK;确认没有会话在写,再执行。 - 临时表里的数据要确认完整。INSERT SELECT 之后,对比
COUNT(*)和SUM(关键字段)是否一致。 - 如果主从复制存在,注意 TRUNCATE 会导致从库立即执行同样的清空,磁盘 IO 和延迟会瞬间升高,需要评估。
5. 三种思路怎么选:一张对比表
很多读者可能已经看出来了,三种思路没有绝对优劣,关键是匹配自己的数据量、停机窗口和改造空间。我根据自己的实战经验做了一张对比表,你可以直接对着选。
| 方案 | 核心操作 | 删除速度 | 业务影响 | 空间释放 | 适用场景 | 前置条件 |
|---|---|---|---|---|---|---|
| 直接 DELETE | 逐行加锁删除 | 极慢 | 高,极易锁阻塞 | 不释放 | 数据量小(百万内) | 无 |
| 分批 DELETE | 循环小批量删除 | 慢 | 中,需要低峰 | 不释放 | 中等数据量 & 无法停写 | 无 |
| 分区表 DROP PARTITION | DDL 删分区 | 秒级 | 极低 | 立即释放 | 长期时间序列数据 | 建表时就分区或能停机补分区 |
| 影子表重建 | 新表迁数据 + RENAME | 分钟级+秒级 | 极低 | DROP 旧表后释放 | 大表且保留数据量较大,无法补分区 | 需要有迁数窗口 + 增量同步方案 |
| 归档 + TRUNCATE | 备份保留数据 + 清空 | 秒级 | 高,严格要求停机 | 立即释放 | 留少删多、非核心表 | 有停机窗口 |
我把这三种方案都排在同一张表里,意图是让你先看前置条件,再看性能数字。很多新手一上来就问"哪个最快",但实际生产里,能停机的选 TRUNCATE,不能停机但能分区就选 DROP PARTITION,既不能停机又不能分区的才用影子表。
另外,如果你的数据库是云厂商的 RDS,DBA 权限没那么全,某些 DDL 可能受限,这时候只能在 SQL 层面做文章。影子表方案在云上兼容性最好,因为只用了建表、导数和 RENAME。
6. 实操踩坑实录与排查经验
6.1 问题速查表
这几类问题在实施清理历史数据的场景里最容易翻车,我整理成一个排查速查表。
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
ALTER TABLE ... DROP PARTITION 执行很慢 |
全局二级索引 / 大分区定义复杂 | 查看 SHOW PROCESSLIST,检查是否在重建索引 |
拆分为先 EXCHANGE 再 DROP,或先删二级索引再删分区 |
| RENAME TABLE 时报外键错误 | 旧表被其他表外键引用 | 查 information_schema.KEY_COLUMN_USAGE |
先删外键,或使用 SET FOREIGN_KEY_CHECKS=0(需谨慎) |
| 分区查询走了全分区扫描 | 查询条件没有包含分区键 | 用 EXPLAIN PARTITIONS 查看 |
修改查询条件,或重建分区键 |
| DROP 大表后主从延迟暴涨 | 从库要处理删除文件 + 副本应用 | 查看 Seconds_Behind_Master |
在从库上使用硬链接方式删文件,分时段限速 |
| INSERT SELECT 比预期慢很多 | 源表有更新锁 / IO 高 | 排查 performance_schema 锁等待 |
改成按主键范围分批插入,每批之间 sleep |
| TRUNCATE 后磁盘空间没释放 | 存在多版本或快照 | 检查 Duplicate / 快照 | 确认无活动事务后执行 PURGE 或等待 |
| 切表后出现主键冲突 | 自增值小于现有最大 id | 插入新数据报 duplicate key | 重建表时手动设置 AUTO_INCREMENT |
这表里我想要强调两个点:第一,任何大操作前都先备份一份表结构、权限和触发器清单,回滚有据;第二,操作过程中要时刻看 SHOW PROCESSLIST 和 information_schema.INNODB_TRX,别等出事故了再查。
6.2 几条实战心得
最后分享几条在真实环境里摸索出来的经验,不一定写在文档里,但遇到问题时非常管用。
第一,凡是涉及大表的在凌晨操作,一定要建"操作检查单"。我在运维团队里强制要求过:任何 DDL 或大批量 DML 前,必须列出影响行数、预计耗时、磁盘余量、从库延迟容忍度、回滚方案五要素。没有这五要素,操作不给上。这套流程救过我很多次,尤其是在某个凌晨三点准备删 3 亿行数据的时候,提前发现磁盘余量只剩 40GB,binlog 可能写不下,果断改成影子表方案,避免了一场事故。
第二,尽量把"删除"动作放到业务架构之外。如果历史数据确实要长期保留,那就把它迁到归档库、数据仓库、OSS 冷存储这些地方,让在线库的表保持瘦身。删不掉的表不要硬删,很多时候是"归档链路没设计好"才需要在线删。把思路拔高一层,很多问题的解法会完全不同。
第三,一定要验证可以回滚。不管选哪种思路,你都得问自己:如果删错了,怎么恢复?分区方案至少保留一个 EXCHANGE 出来的备份表;影子表方案里旧表在 DROP 前可以保留 24~48 小时;TRUNCATE 方案必导备份。这个冗余的代价很小,但能让你睡个安稳觉。
以我个人的体会来说,处理大数据量历史数据从来不是"写一条 SQL"的问题,而是一个数据生命周期管理的工程问题。提前做好分区规划,比任何事后的大删除技巧都靠谱。如果你现在正面临清理压力,我建议先评估表的结构和数据分布,再决定用分区、影子表还是截断重建。别急着执行,先把方案选对,后面就顺畅了。
