ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DELETE与TRUNCATE在生产环境中的本质差异及大表删除安全实践

DELETE与TRUNCATE在生产环境中的本质差异及大表删除安全实践

凌晨两点半被告警电话叫醒,这个体验做过数据库的人都不陌生。那次是某张订单流水表,数据量已经涨到8000万行,一条简单的统计查询从几十毫秒一路升到十几秒。业务方给的需求不复杂——把一年前的历史数据清掉。群里有人问了一句:DELETE 还是 TRUNCATE?这两个词在教科书上都叫“删除”,可放到生产环境就会发现,它们完全不是一个量级的东西。今天想把这些事儿一次性聊透,包括底层原理、差异对比、大表删除的翻车现场、以及线上安全的正确解法。这篇文章不聊名词定义,只聊生产里能救命的东西,适合正在做数据开发、日常要优化线上表的人参考。

1. 先从原理拆开:DELETE 和 TRUNCATE 的本质差异

1.1 DELETE 是“一颗一颗解开纽扣”

DELETE 是标准的 DML 操作,它在 InnoDB 里做的事情远不止“把行删掉”。执行一条 DELETE 时,数据库要先根据 WHERE 条件扫描并定位目标行,对每一行加锁,做标记删除,同时把变更前的镜像写入 undo log,把变更操作写入 redo log,再把完整的行变化写入 binlog。尤其 row 格式的 binlog 下面,每删一行都要记录整行变更前后的镜像。

这些步骤叠加意味着什么?被删除的行数越多,产生的日志量越大,执行时间越长,锁范围也越大。拿生活里的事类比:DELETE 像是要把一件外套的扣子一颗一颗解开,每解一颗都要停下来看一眼扣眼和线头,解一百颗扣子的工作量绝不是解一颗的十倍,而是接近一百倍。

还有一个经常被忽略的细节:DELETE 删掉的行不是物理上立即抹除。磁盘上那些数据页里可能留下空洞,空间被标记为可复用,但表文件大小不会立刻变小。不少新人因此产生困惑——明明删了 800 万行,为什么表大小一点没变?因为空间被“保留”给后续插入使用了,这也是 DELETE 之后常见“表大小没变化”的根本原因。

1.2 TRUNCATE 是“直接把整个衣柜换新”

TRUNCATE 属于 DDL,MySQL 实现它的时候,大体走了 drop table 加 create table 的思路。它不会逐行扫描,不会逐行标记删除,不会为每一行记录日志。对 InnoDB 来说,等于把存放数据的整个表文件或数据页作废、重建。原本那一堆数据不需要一条条看,一条 DDL 记录写进 binlog 就够了。这也是 TRUNCATE 在大表上往往以“秒”为单位,而 DELETE 以“分钟甚至小时”为单位的原因。

但“快”不等于“简单”。TRUNCATE 需要拿到表的排他锁,在 MySQL 里还会隐式提交:一旦执行,基本没有后悔药。它不会逐行触发 DELETE 触发器,权限要求也更高。很多人只记住了它快,却忘了它不是 DELETE 的快捷版,而是“推倒重来”的操作。这个认知差,就是不少生产事故的起点。

1.3 日志量、锁范围与可回滚性:三个维度的天壤之别

把 DELETE 和 TRUNCATE 放在一起比,有三个维度最直观:

日志量方面,DELETE 有多少行就有多少行日志,TRUNCATE 只记录结构变更和元数据,日志体量差几个数量级。锁方面,DELETE 是行锁和间隙锁,锁的行数虽多,但不会把整张表锁死;TRUNCATE 要的是整表排他锁,执行期间几乎一切访问都要排队。回滚方面,DELETE 在事务内可以 ROLLBACK,TRUNCATE 在 MySQL 中隐式提交,无法回滚。

这三个维度合在一起,直接给出了一个结论:DELETE 适合有条件的部分删除,TRUNCATE 适合清空整表且能接受不可恢复。如果你今天想删掉大表里“某段时间内”的数据,却打算用 TRUNCATE,那是从起步就选错了工具。

2. 核心差异详解与不同数据库下的实际表现

2.1 一张表看透主要区别

老规矩,先上一张整理好的对比表,方便后续查阅:

对比项DELETETRUNCATE
操作类型DMLDDL
支持 WHERE支持不支持
锁粒度行锁、间隙锁表级排他锁
日志量按行记录日志几乎无行级日志
事务回滚事务内可回滚MySQL 隐式提交,不可回滚
自增列不会重置会重置
触发器会触发MySQL 不触发
权限要求DELETE 权限DROP 权限
空间释放不立即释放独立表空间下通常可释放
典型耗时与行数正相关秒级

这张表和实际生产体感是吻合的。你可以在测试环境跑一张 100 万行的表验证下,DELETE 一批数据可能用几十秒,TRUNCATE 基本一瞬间完成。

2.2 MySQL、PostgreSQL、SQL Server 的细节差异

大家平时说 DELETE 和 TRUNCATE,默认语境往往是 MySQL。换到其他数据库,一些关键行为并不完全一样。

MySQL 里 TRUNCATE 隐式提交,不太可回滚,这一点前面提过。InnoDB 下执行 TRUNCATE 时,还会在线重建表相关的元数据,执行完的一瞬间可能触发一次较大的 checkpoint,虽然整体很快,但如果系统负载已经很高,也会带来瞬时刷脏压力。

PostgreSQL 对 TRUNCATE 的处理要灵活一些。它是真正的表文件级别“扔旧建新”,速度可观,同时支持TRUNCATE ... RESTART IDENTITY重置自增列,也支持CASCADE处理外键引用。更重要的是,PostgreSQL 的 DDL 是事务性的,TRUNCATE 可以在自己的事务内回滚,这一点和 MySQL 形成鲜明对比。

SQL Server 的 TRUNCATE 也是最小日志记录操作,会重置自增列,整体行为跟 MySQL 更接近。但在权限、锁等待、外键处理上的具体表现会有差异,不能照抄 MySQL 的运维习惯。

2.3 分区表场景下的“偷懒”优化

如果一张表本身按时间做了 RANGE 分区,清理历史数据最优雅的做法不是 DELETE,也不是对整个表做 TRUNCATE,而是操作特定分区。比如:

ALTER TABLE orders TRUNCATE PARTITION p_2022; ALTER TABLE orders DROP PARTITION p_2022;

TRUNCATE PARTITION只清空指定分区里的数据,DROP PARTITION更彻底,连分区带数据一起移除。两者都是元数据级别的操作,运行时间可控、日志量小、对其他分区的影响微乎其微。

很多所谓“删除慢”的问题,根源其实是表设计阶段没有考虑数据生命周期。如果你接手了一张没分区的大表,至少应当把“未来数据按分区存储”这件事放进改造清单。不过分区表也不是万能药,查询时如果过滤条件没有带上分区键,一样会扫描所有分区,到时候该慢的还是慢。

3. 大表 DELETE 的“翻车现场”与正确解决姿势

3.1 一次失败生产删除的完整复盘

我复现过这样一个场景:订单流水表有约 6000 万行,其中 2600 万行是一年以前的旧数据。某次任务计划很简单,把旧数据清掉,刚开始是一条 SQL 直接裸跑:

DELETE FROM orders WHERE create_time < '2023-01-01';

前几分钟看影响行数一直在涨,好像一切正常。不到十分钟,主库所在磁盘的剩余空间开始肉眼可见地往下掉,binlog 疯狂增长。紧接着从库延迟报警,从 0 秒一路飙升到几十分钟。最后操作超时,只能 kill 会话。

事后复盘原因非常清晰:

  • binlog 在 row 格式下,每删一行都记录完整的前后镜像,2600 万行 DELETE 会产生几十 GB 的 binlog;
  • 一个大事务挂住了大量 undo 链,其他读操作需要访问旧版本时性能骤降;
  • 从库回放同一大事务时追不上主库,延迟持续拉大;
  • 执行器对符合条件的大范围行加锁,并发写入被大量阻塞。

最容易被低估的是退出成本。因为操作已经跑了很久才 kill,数据库还要回滚已经变更的数据,回滚同样非常耗时。这一来一回,比正常分批删除花的时间还多。

3.2 分批删除的正确打开方式

真正的线上安全删除,核心就两个字:分批。

最简单的做法是缩小每个 DELETE 影响的行数,让它成为许多小事务。MySQL 可以直接加 LIMIT:

DELETE FROM orders WHERE create_time < '2023-01-01' LIMIT 5000;

再配一段循环脚本:

#!/bin/bash while true; do effect=$(mysql -h127.0.0.1 -u$DB_USER -p$DB_PASS appdb -N -e " DELETE FROM orders WHERE create_time < '2023-01-01' LIMIT 5000; SELECT ROW_COUNT();") echo "$(date +'%F %T') deleted rows: $effect" if [ "$effect" -eq 0 ]; then break; fi sleep 1 done

更通用的方式是按主键区间分段。先确定整体 id 范围,再从最小 id 开始按固定窗口滑动:

DELETE FROM orders WHERE id BETWEEN 1000001 AND 1050000 AND create_time < '2023-01-01';

老 DBA 会直接用现成的归档工具 pt-archiver:

pt-archiver --source h=127.0.0.1,D=appdb,t=orders --purge \ --where "create_time < '2023-01-01'" \ --limit 2000 --txn-size 2000 --sleep 1

参数上我的经验是:不是越大越好。第一次先用 5000 行左右试水,观察主库磁盘增长、binlog 增长和从库延迟后再逐步调整。千万记住,不要从网上抄一段“大杀器”就上来 all-in,那是把系统往火坑里推。

3.3 给删除操作装一个“刹车系统”

除了分批,生产环境里最好加一个自动保护逻辑。比如:当从库延迟超过某个阈值,或者磁盘剩余量低于某个百分比,脚本自动 sleep 一段时间,让删除降速。看起来像小题大做,但对大表就是救命逻辑。

删除全部铺开之前,建议先在测试环境把这段时间的 binlog 增长量乘一个系数,大致估算线上总日志量。如果估算结果已经接近磁盘剩余空间的一半,就要缩小每批行数,或者延后操作窗口。低峰期执行永远比高峰期硬扛要稳。

4. 为什么 TRUNCATE 也会“卡住”?最容易忽略的元数据锁与权限细节

4.1 一个未结束的查询,让“秒删”卡成了二十分钟

TRUNCATE 虽然底层不逐行删数,但它需要给表加上排他元数据锁(MDL)。只要有一个长查询或者未提交事务还握着这张表的共享 MDL,TRUNCATE 就必须等着。麻烦的是,它等待时,后续所有想访问这张表的请求也会继续排队,形成连锁阻塞。

有位同学在测试环境清一张旧日志表,觉得 TRUNCATE 肯定是秒过,结果硬生生卡了二十多分钟。一查才发现,后台有一个跑了很久的统计查询一直没结束。这时候想看谁锁了表,MySQL 8.0 里可以直接查:

SELECT * FROM performance_schema.metadata_locks; SELECT * FROM performance_schema.data_locks;

5.7 或更早版本,通常需要查information_schema.innodb_trx结合SHOW PROCESSLIST来人工判断。结论很明确:TRUNCATE 大表之前先看 processlist 和长事务,这个习惯比命令本身更重要。

4.2 权限、外键、触发器:三个“看起来没什么”的细节

权限问题最常见。TRUNCATE 需要 DROP 权限,而不仅仅是 DELETE 权限。很多公司给开发账号只开通 DML 权限,执行 TRUNCATE 时直接报权限不足。听起来麻烦,但这其实是一道保护机制,防止业务账号随手清表。

触发器也要注意。MySQL 下 TRUNCATE 不会触发行级 DELETE 触发器。如果业务在 DELETE 上挂了审计逻辑、缓存清理逻辑,TRUNCATE 会完全绕过这些逻辑,导致数据清理了但关联状态没更新。

外键则是另一个坑。一张表被其他表引用时,TRUNCATE 可能直接报错。禁用外键检查可以强制执行,但风险很高,容易留下孤儿数据,不到万不得已不建议这么干。PostgreSQL 的 TRUNCATE 支持 CASCADE,可以连带清空引用它的表,但同样要小心“误伤”范围。

4.3 TRUNCATE 之后,磁盘空间到底释放了没有

这个问题经常让人迷惑。MySQL 在独立表空间模式下,也就是innodb_file_per_table=ON时,TRUNCATE 通常会把旧表空间作废并重建一个新的表文件,磁盘空间可以还给操作系统,表文件会明显变小。

但如果表放在系统表空间,也就是共享表空间里,TRUNCATE 只是把页标记为空闲,文件尺寸不会缩小,这些空间会留给其他表后续复用。后台一张表文件缩了没缩,先确认表空间模式,再下结论。

如果要用 DELETE 删了大量数据后主动回收空间,通常需要OPTIMIZE TABLE或者ALTER TABLE tbl ENGINE=InnoDB,这会触发整表重建,产生持续较长时间的锁,必须在业务低峰期操作。很多人不看负载直接 OPTIMIZE,又给自己的故事加了一章事故。

5. 业务场景决策:到底该用 DELETE 还是 TRUNCATE

5.1 一份可以直接抄的分场景速查表

业务场景推荐做法理由
按条件删除几万行垃圾数据DELETE 分批TRUNCATE 不支持 WHERE
清空整张临时表TRUNCATE速度快、日志小
清空超大业务表且想减少锁影响RENAME 替换法元数据操作,几乎无行级日志
清理过期历史分区TRUNCATE PARTITION / DROP PARTITION只操作目标分区
事务内删除后可能回滚DELETEDELETE 可回滚
需要重置自增计数TRUNCATETRUNCATE 会重置自增列
想保留自增计数继续增长DELETEDELETE 不会重置自增列
外键关联复杂DELETETRUNCATE 可能失败或产生孤儿数据
大量日志清理且接受不可恢复TRUNCATE 或 RENAME 替换法快、可控

这张表不是万能药,但覆盖了绝大多数线上场景。选择困难的时候,把“能不能回滚”“锁多久”“日志多大”“有没有分区”这四个问题问一遍,答案基本就出来了。

5.2 清空超大表:RENAME 替换法更稳

有些情况下,TRUNCATE 虽然快,但表级排他锁依然让业务窗口变得棘手。比如一张核心大表里已经积累了几年数据,周围还挂着各种报表查询、后台任务。此时与其直接 TRUNCATE,不如用三步替换法:

  1. 创建一张同结构的新表:CREATE TABLE orders_new LIKE orders;
  2. 在业务低峰期申请一个短暂停写窗口;
  3. 执行RENAME TABLE orders TO orders_bak, orders_new TO orders;

执行完以后,业务访问的是干净的新表,旧表变成了 orders_bak。之后可以慢慢备份、归档,最后再删除旧表。整个过程里,RENAME 是元数据操作,通常毫秒级完成,不产生与表数据量成正比的行级 binlog,也不需要在 TRUNCATE 的 MDL 后面干等。

这个方法的代价是:如果表被视图、外键或存储过程引用,RENAME 可能影响对象解析,需要提前处理;如果没有停写窗口,期间落到旧表的新写入也需要补偿。但总比一个人死磕一个几十分钟的 TRUNCATE 要优雅得多。

5.3 删除完成后的最终验收清单

删除没有“做完了”的按钮,只有“验收通过”了才算完成。我的常规检查项一共六个:

  • 剩余行数是否符合预期;
  • 表物理大小和碎片率是否正常;
  • 主从延迟是否已恢复;
  • processlist 里有没有残留锁等待;
  • 自增列当前值是否符合业务预期;
  • 下游依赖任务,比如报表、数仓同步、消息队列,是否还能正常消费。

前四项是数据库层面的体检,后两项经常被忽略,却是最容易出二次事故的地方。比如一张表 TRUNCATE 后自增列归零,刚好下游有个任务拿旧主键去更新数据,结果全部报错。这种情况我见过不止一次。

6. 生产环境摸爬滚打后的几条心得与建议

这些年见过太多删除事故,归根结底不是 DELETE 和 TRUNCATE 很难,而是很多人把“快”和“好”画上了等号。TRUNCATE 确实快,但快不代表没有锁、没有代价;DELETE 确实慢,但慢可以通过分批变成可控。生产环境里真正重要的是“可观测、可控制、可回滚”。

我的习惯总结下来就几句话:操作之前先看数据量,再看表占用空间,再看长事务和复制拓扑;操作过程中盯住磁盘余量、binlog 增长、从库延迟这三个水位线,任何一个接近临界值就立刻刹车;操作完成之后把行数、表大小、锁状态、自增列全部复核一遍,确认没有留下隐患。

最后一条心得送给所有被大表删除折磨过的人:任何线上删除,第一件事不是执行 SQL,而是先确认备份和回滚方案。数据没有第二次机会。能分批的就分批,能做元数据替换的就别硬删,能在设计阶段做分区就不拖到运维阶段抢修。这些原则看起来基础,但每一条都是用真实教训换来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进