上周帮人处理了一个数据库清理需求:一张历史流水表,数据量已经积累到二十多亿行,需要把三年前的历史数据清掉,这部分大概占全表数据量的70%。刚开始同事的第一反应是写一条 DELETE FROM order_record WHERE create_time < '2023-01-01' 然后盯着屏幕等,这在我看来是典型的大数据量删除姿势。如果数据量只有几百万行,这么写没毛病;但到了几十亿行的量级,直接 DELETE 就是在给数据库上刑。
这里我把“删除数据量比较大的历史数据”的另一种思路完整梳理一遍,所有方案都在生产环境验证过。我的结论放在最前面:能走分区裁剪就走分区裁剪,走不了就做影子表重建,实在不行才用分批 DELETE 硬扛。这个顺序,基本能覆盖绝大多数历史数据清理场景。
1. 为什么大批量 DELETE 是“重灾区”?
1.1 DELETE 的真实执行机制
先说一个很多人忽略的事实:在 InnoDB 这类主流存储引擎里,DELETE 并不是真的把数据“扔”掉,而是先在行上打个删除标记,真正的物理清理要等后台 purge 线程异步完成。这意味着 DELETE 的数量越大,后台欠账就越多,表空间短时间不会缩小,查询性能还会因为脏页、碎片、扫描范围扩大而受到影响。
更关键的是,DELETE 在执行期间要为每一行生成 undo 日志。这个 undo 日志是给事务回滚和 MVCC 多版本并发控制用的,删除两千万行,undo 可能轻松膨胀到几个 GB 甚至几十个 GB。如果删除发生在核心业务库上,undo 表空间被撑爆、磁盘告警的情况我见过不止一次。
还有一个容易被忽略的点:如果删除条件带范围,比如 WHERE create_time < '2023-01-01',在 MySQL 默认的 RR 隔离级别下,InnoDB 会加间隙锁或 next-key lock。你以为你在删历史数据,实际上把相邻索引区间的写入也堵住了。业务侧的 INSERT、UPDATE 会排队,直到删除事务提交。
1.2 大批量 DELETE 的连锁反应
把这些机制放到一起,大批量 DELETE 至少会带来四个连锁问题:
-
锁影响范围大。DELETE 扫描过程中触碰到的索引范围都会被锁住,尤其是按时间字段范围删除,几乎必然和在线写入产生冲突。凌晨低峰期还好,白天执行基本等于让业务局部不可用。
-
主从延迟必然飙升。在 binlog 为 ROW 格式的复制架构里,主库删除多少行,从库就重放多少行。删除一亿行数据,从库就老老实实执行一亿行的删除操作,延迟几十分钟甚至几小时都很正常。如果从库还承担读流量,业务读侧会明显感知到延迟。
-
空间不降反升。DELETE 产生的 undo 和 redo 日志会短时间占用大量磁盘;删除完成后,表文件的高水位也不会降,原来 500GB 的表删掉 70% 数据后,文件可能还是 500GB。想真正缩表,还得 OPTIMIZE TABLE 或重建表,又是一轮大操作。
-
回滚成本极高。长事务跑了一个小时,期间任何一步出现锁等待超时、会话断开、实例重启,事务都会整体回滚。回滚也要重新执行一遍 undo,等于把刚才一个小时的活又干了一遍,最后表数据没变,资源和时间全赔进去了。
1.3 该换思路的信号
什么样的需求需要认真考虑换方案?我一般看四个信号:
- 单次删除行数超过千万;
- 删除数据量占总数据量 30% 以上;
- 操作的是核心业务表,在线读写不能长时间中断;
- 数据库没有维护窗口,或者维护窗口短到撑不住 DELETE 执行。
只要命中两个以上,我就会停下来想:这条路走不通,一定有别的路。
这里的核心思维转变是:不要绞尽脑汁优化 DELETE,而是想办法绕过 DELETE。 数据清理的最终目标是让业务“看不到”这些历史数据,而不是非要用 DELETE 把行逐条干掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 思路一:分区表 + DROP PARTITION,把删除变成“丢垃圾”
2.1 分区表怎么建才合理
分区表的思路,是把一张大表从物理上拆成若干独立的小分区。对时间维度的历史数据来说,这是最优雅的清理方式。每个分区独立存储、独立管理,删除历史数据时直接丢分区,根本不需要逐行 DELETE。
但这里有个前提:分区策略必须在表设计阶段就规划好,或者在数据量还没失控前改造完成。 我在实践中见过太多表,建表时图省事,没做分区,等数据涨到几十亿行了才想起来用分区清理,改造本身又变成一次大工程。
合理的 RANGE 分区建表语句大概长这样:
sql复制CREATE TABLE `order_record` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL,
`user_id` bigint NOT NULL,
`status` tinyint 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 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')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
这里有两个关键细节:
- 主键必须包含分区键。MySQL 分区表要求分区键必须是主键或唯一索引的一部分,所以主键从
id改成了(id, created_at)。 - 用
TO_DAYS函数把时间转成天数,边界清爽,后续按月份维护也很直观。
分区粒度怎么定,取决于单分区数据量。我的经验是单分区控制在千万行级别左右,别超过两三千万行。按年分一个区看着省事,但一年可能积累几亿行,清理这个分区时依然要处理很久,会退化成一个重操作。按月、按周甚至按天,以写入速度来定。比如每天写入 100 万行,就可以按天分区;每天写入 10 万行,按月分区就够。
2.2 清理历史数据的完整操作
分区表建好后,清理历史数据就变成一条 DDL 语句:
sql复制ALTER TABLE order_record DROP PARTITION p202401, p202402;
执行完,这两个分区的数据整体消失,磁盘空间立即释放,不会有逐行 DELETE 的 undo、redo 日志膨胀问题。
如果你只想清空分区数据但保留分区定义,用:
sql复制ALTER TABLE order_record TRUNCATE PARTITION p202401;
DROP PARTITION 和 TRUNCATE PARTITION 的核心区别是:前者把分区定义和数据一起删掉,后者只清空数据。历史数据一般不会再写入同一个月,用 DROP 更干净。
这里还要强调一个维护习惯:因为表里带了 pmax 这个 MAXVALUE 分区,后续要想在月末新增下个月分区,不能直接 ADD PARTITION,得先拆分 pmax,比较麻烦。所以我在生产环境更推荐用定时任务提前创建未来 N 个分区,把 pmax 只留作兜底,避免关键时刻卡在分区管理上。
2.3 分区方案的边界与坑
分区表不是万能的,我在使用中踩过的坑主要这几个:
-
查询不带分区键,性能反而更差。 如果业务查询习惯是
WHERE user_id = ...,但分区键是created_at,MySQL 会扫描全部分区,效果比普通单表还差。所以分区键一定要和核心查询条件对齐。 -
全局唯一索引受限。 MySQL 分区表不支持全局唯一索引,唯一键必须包含分区键。比如订单号
order_no本来应该全局唯一,但如果分区键是created_at,唯一索引就得是(order_no, created_at),这跟业务预期往往不一致,需要从设计层面规避。 -
分区数量别贪多。 分区数过多会导致表打开成本上升,日常查询也会受影响。单表分区数最好控制在几百个以内,别搞出几千个分区。
-
DROP PARTITION 不是完全无锁。 它还是 DDL,会拿元数据锁,也会在主从复制中传给从库执行。单分区数据量太大时,主从延迟和元数据锁等待依然可能出现。这也是为什么我强调控制单分区大小。
3. 思路二:影子表重建,用“保留”代替“删除”
3.1 影子表方案的核心逻辑
如果存量表当初没做分区,又没办法立即改造,或者数据虽然没分区但删除量占比极大,那我会用“影子表重建”这个思路。
简单说就是:不删旧表,新建一张结构相同的表,把要保留的数据拷贝过去,再通过原子性的 RENAME TABLE 把新旧表切换。 对业务来说,切换完成的瞬间,历史数据就“消失”了。
为什么这样比 DELETE 友好?核心原因是:你不需要对十几亿行逐条加锁、写 undo、写 binlog。删除动作被转换成了“读取保留数据 + 写新表”,读多写少的操作对在线业务的影响要小得多,而且整个过程可以分批控制。
这个方案天然适合“删大留小”的场景。比如 18 亿行数据,要保留最近三年约 5 亿行,删除 13 亿行,影子表只需搬运 5 亿行;保留数据越少,方案收益越大。反过来,如果整表只删 10%,重建效率反而不如分批 DELETE。
3.2 完整操作步骤与批量拷贝
具体操作分五步:
第一步:创建影子表
sql复制CREATE TABLE order_record_new LIKE order_record;
LIKE 方式会复制原表的列定义、索引、自增属性,但不会复制外键和触发器,后面要重点检查。
第二步:分批拷贝需要保留的数据
千万不要一条 INSERT INTO ... SELECT 直接灌完。大数据量表上这样操作会产生巨大事务,undo、redo、锁都会爆炸。正确做法是使用自增主键作为游标,按批次循环拷贝。
每批大小的经验值:单行 1KB 左右时,每批 2 万到 5 万行比较合适;单行更大就调小批次。批次过小,循环次数多、效率低;批次过大,单事务太重,主从压力大。
下面是核心循环逻辑,用 Python 伪代码表达:
python复制import pymysql
conn = pymysql.connect(host='...', user='...', password='...', database='...')
last_id = 0
batch_size = 20000
retain_time = '2024-01-01'
while True:
with conn.cursor() as cur:
sql = """
INSERT INTO order_record_new
SELECT * FROM order_record
WHERE created_at >= %s
AND id > %s
ORDER BY id
LIMIT %s
"""
affected = cur.execute(sql, (retain_time, last_id, batch_size))
conn.commit()
if affected == 0:
break
# 游标移动到本批次最大 id
cur.execute("SELECT id FROM order_record_new ORDER BY id DESC LIMIT 1")
last_id = cur.fetchone()[0]
这段脚本里最关键的是 WHERE id > last_id ORDER BY id LIMIT batch_size。由于主键是递增的,每次只扫描主键后面的区间,效率很高,不会因为表大而越跑越慢。
第三步:校验数据
切换前必须做数据校验,至少要对比总数和抽样:
sql复制SELECT COUNT(*) FROM order_record_new;
SELECT COUNT(*) FROM order_record WHERE created_at >= '2024-01-01';
如果两张表数据量不一致,绝对不能切换。接着抽样比对最近一周、最近一天的明细,验证没有缺行、错行。
第四步:RENAME 切换
sql复制RENAME TABLE order_record TO order_record_old_bak, order_record_new TO order_record;
RENAME TABLE 是原子操作,执行瞬间完成,应用几乎无感知。这条语句一次性完成两件事:旧表改名暂存,新表改名上线。即使业务正在写入,这条语句期间也能通过元数据锁保证一致性,不会出现中间态。
第五步:确认无误后清理旧表
切换后先别急着删旧表。我的习惯是保留备份表至少 3 天,确认业务无异常、无数据问题后,再执行 DROP TABLE order_record_old_bak;。
3.3 切换、校验与回滚设计
影子表方案最大的好处是天然带“回滚开关”。只要旧表还没 DROP,发现问题随时可以反向切换:
sql复制RENAME TABLE order_record TO order_record_broken, order_record_old_bak TO order_record;
这种回滚能力是直接 DELETE 做不到的。DELETE 执行完,数据就没了,想恢复只能靠备份,过程漫长且有丢失窗口。
还必须注意一个隐藏细节:影子表拷贝期间业务还在写旧表,等全量拷贝完成时,旧表里可能比影子表多出不少新数据。所以在正式切换前,需要再跑一次增量补充,把最后一段时间内新增的数据同步到新表。做法很简单:再执行一遍和上面相同的分批拷贝逻辑,不过这次因为大部分数据已经同步过,增量数据量很小,通常几分钟就能追平。
如果业务写入量非常大,连增量追平的时间都必须控制在几秒内,那就需要配合短停写窗口,或者在应用层做双写。但从我经历的项目看,绝大多数场景利用凌晨低峰期 + 增量补一次,就已经足够平滑。
3.4 要注意哪些细节
影子表方案看着不复杂,但细节决定成败:
- 自增值必须重置。
CREATE TABLE ... LIKE虽然保留了AUTO_INCREMENT属性,但新表当前自增值很可能只是初始值。如果切换后新写入的id和旧数据冲突,主键直接报错。所以切换前执行:
sql复制SELECT MAX(id) + 1 FROM order_record_new;
ALTER TABLE order_record_new AUTO_INCREMENT = 上面查到的值;
-
外键和触发器不会自动复制。
LIKE复制表结构时不带外键和触发器,而这东西最容易在切换后爆发问题。我建议切换前用SHOW CREATE TABLE order_record完整比对一遍,把外键、触发器、生成列等全部核对清楚。 -
磁盘空间要准备够。影子表重建期间,新表和旧表同时存在,需要额外一份表空间。如果磁盘只剩 20% 可用空间,说明这个方案暂时不具备条件,先扩容或清理其他文件再说。
-
插入期间新表索引也在同步维护。数据量大时,新表的二级索引会显著增加写入耗时。如果旧表有多个大索引,可以考虑先建主键和必要索引,拷贝完成后再补二级索引,效率更高。但这个做法对操作顺序要求更高,新手不太建议,容易漏索引。
4. 三种方案的选型框架与实测对比
4.1 直接比较:三种方案的优劣势
为了更直观,我把三种方案放在一张表里对比:
| 维度 | 直接分批 DELETE | 分区 DROP PARTITION | 影子表重建 |
|---|---|---|---|
| 适合场景 | 删除占比小、数据量小 | 有时间分区且分区合理 | 删大留小、无分区存量表 |
| 执行耗时 | 与删除行数成正比 | 秒级到分钟级 | 与保留数据量成正比 |
| 锁影响 | 高,长事务+间隙锁 | 短暂元数据锁 | 拷贝期读旧表,切换短锁 |
| 空间释放 | 不释放,需额外 OPTIMIZE | 分区文件直接释放 | 旧表 DROP 后释放 |
| 主从压力 | 很高 | 低 | 中等,可分批控制 |
| 对存量表要求 | 无 | 需要已有分区或可改造 | 无 |
| 回滚能力 | 弱,只能靠备份恢复 | 分区定义销毁后难恢复 | 强,旧表保留可快速切回 |
| 工程复杂度 | 最低 | 低 | 较高 |
这张表基本能回答“哪种方案最好”的疑问。没有绝对最优,只有场景最匹配。
4.2 我的选型经验
结合这些年处理过的线上清理需求,我总结了一套选型判断:
- 如果表已经按时间分区,且业务查询也带时间条件,优先走
DROP PARTITION,这是所有方案里对在线影响最小、执行最快的。 - 如果表没有分区,要删除的数据量又特别大,直接考虑影子表重建,不要先试 DELETE。
- 如果删除量占总数据量 20% 以下,分批 DELETE 完全够用,不用过度设计。
- 如果业务完全不能停写、无法接受任何锁冲突,影子表重建配合双写或 CDC 增量同步会更稳,但工程复杂度明显上升。
我特别想强调一点:“能正常跑”和“适合生产环境”是两回事。 一条 DELETE 在小表上没问题,不代表在几十亿行的表上也该这么做。做历史数据清理,先看数据规模和占比,再谈具体 SQL 怎么写。
5. 实战中遇到的五个高频问题
5.1 分区 DROP 之后为什么还有锁等待
有人遇到过 DROP PARTITION 执行后,业务侧依然出现短时间阻塞。原因通常是:单个分区数据量太大,DDL 执行时间过长,元数据锁释放太慢。另一个常见原因是分区键和当前写入数据时间分布不均衡,导致某分区异常膨胀。
解决办法有两个方向:一是把分区粒度调小,让单分区数据量稳定在可控范围;二是提前在主从库都准备低峰窗口,避免 DDL 和业务高峰重叠。
5.2 影子表切换后,触发器为什么不见了
刚才提过,CREATE TABLE ... LIKE 不复制触发器,也不复制外键。这不是 MySQL 的 bug,是它的定义如此。所以凡是业务依赖触发器做审计、同步、软删除标记的场景,切换后这些能力会悄悄丢失,而且不一定会立刻报错。
我现在的做法是:在切换前的操作清单里固定加一条 SHOW TRIGGERS WHERE \Table` = 'order_record';`,把触发器定义都保存出来,在新表上重新创建。外键则要评估是否真的需要,大多数互联网业务反而更倾向去掉外键。
5.3 切换窗口里的“缝隙”依然可能丢数据
即使做了增量补充,如果业务写入量极大,从增量结束到 RENAME 执行之间仍可能有一个极小的时间窗口,这期间的新增数据会写在旧表上,切换后新表里没有它们。
这是影子表方案最需要设计的地方。我的处理方式有两种:
- 选择业务写入量最低的凌晨执行,增量追平后立刻 RENAME,缝隙通常只有几秒。
- 如果缝隙容忍度为零,就要在切换瞬间加写锁,比如执行
LOCK TABLES order_record WRITE,增量补完立刻 RENAME,然后UNLOCK TABLES。这个锁窗口通常在秒级,对业务影响很小。
5.4 磁盘空间不够时怎么办
影子表方案需要额外空间,如果磁盘吃紧,可以先做一轮“空间腾挪”。
一种做法是先把最老的几个分区或分批删除一部分历史数据,腾出空间后再做影子表。另一种办法是先把旧表 ALTER TABLE ... ENGINE=InnoDB 做一次整理,代价也不小。更实用的做法是把影子表建到另一块磁盘或另一个实例上,跑完校验后通过一定方式导入。这样物理空间不打架,但网络和导入成本会上升。
总之,磁盘不足不意味着影子表方案不能做,关键早发现早规划。
5.5 主从延迟在清理过程中飙升怎么控制
无论是分批 DELETE 还是影子表拷贝,都会在从库产生重放压力。分批 DELETE 本身就在制造大量 binlog,从库当然要同步执行;影子表拷贝生成的 INSERT 语句同样要走复制链路。所以不能说“我先跑起来,延迟后面再说”。
控制手段是老三样:
- 每批执行之间增加
sleep,比如 0.1 秒到 0.5 秒,给从库追赶空间; - 实时监控从库
Seconds_Behind_Master,超过阈值就自动拉长 sleep; - 选择业务低峰期执行,给复制链路留整体缓冲。
实际操作中我会按“批处理耗时是纯 SQL 耗时的 1.3 倍”这个节奏来调,延迟稳不下来就继续加间隔,不急于求成。
写到这里,再分享一个我自己的习惯:任何大数据量清理操作,我都会先在测试库跑一遍完整流程,记录每一步耗时和资源占用,然后在生产环境严格按测试结论执行。分区方案要测 DROP 分区耗时,影子表方案要测拷贝速率和切换耗时。别看这些操作看起来简单,真的在生产环境翻过车、把表锁死过之后,才会明白预案比一腔热血重要得多。数据清理不是炫技场,能把影响面控制在最小,就是最好的方案。
