☰
MySQL大表删除工程实践:DELETE、TRUNCATE与分批方案避坑指南
2026/10/28 3:45:01 网站建设 项目流程

凌晨两点被电话叫醒,原因是一条DELETE FROM t_order_log WHERE created_at < '2023-01-01'直接怼在了 4 亿行的日志表上,要清掉其中 3.2 亿行。语句发出去不到二十分钟,监控面板开始集体变红:从库延迟从 1 秒飙到 1800 秒,主库 history list length 一路往上冲,原本 5ms 的点查开始排队超时,磁盘使用率不降反升。后面处理的三个小时里,我们做的事情不是"把数据删掉",而是"把已经发出去的删除动作造成的伤害控制住"。这件事之后,我把 DELETE 与 TRUNCATE 的区别、大表删除的影响面、以及生产上真正能落地的删除方案重新梳理了一遍,也就是下面这些内容。

这篇文章讲的是数据库里"删数据"这件事的完整工程实践:DELETE 和 TRUNCATE 在实现机制、锁行为、日志开销、空间回收、复制影响上的本质差异,大表删除会波及哪些环节,以及分批删除、分区裁剪、影子表重建、TRUNCATE 这几种方案分别适合什么场景、参数怎么算、坑在哪里。刚接触数据库的同学可以把它当成一份操作手册,已经踩过坑的同行可以对照着看看自己的方案有没有遗漏。所有结论以 MySQL/InnoDB 为主要语境,必要处会补充 PostgreSQL 的差异,因为这两种数据库在"删数据"上的行为差别很大,混着理解很容易出事。

1. 别急着敲 DELETE:先把三种方式的边界钉死

1.1 DELETE、TRUNCATE、DROP 各自到底做了什么

很多人对这三者的理解停留在"DELETE 删行、TRUNCATE 清表、DROP 删表"这个层面,这个理解不算错,但完全不够用。真正的差异在存储引擎内部:DELETE 是一条 DML 语句,InnoDB 会逐行定位、逐行加锁、逐行写 undo 和 redo,每一行的删除都是一次真实的页修改,事务没提交之前这些修改都可以回滚。也就是说,删 100 万行就是 100 万次行级操作,代价是线性增长的。

TRUNCATE 是 DDL,MySQL 里它的实现方式跟很多人想的不一样:它并不是"快速版的 DELETE",而是把整张表的表空间丢掉再重新建一个空表。所以它不产生逐行的 undo,不能回滚,隐式提交当前事务,不触发 DELETE 触发器,自增计数器归零,被外键引用的表还会直接拒绝执行。这个机制决定了它快,但也决定了它不可逆、粒度粗、对并发不友好。

DROP 则是连表结构一起干掉,删的是表定义加数据文件。三者里 DROP 最彻底也最危险,恢复成本最高。生产上误操作 DROP 之后能不能救回来,完全取决于你有没有备份、有没有开 binlog、binlog 保留多久,这个我们后面在排查章节会展开。

理解这三者的关键不是背差异表,而是建立一个判断框架:你要删的是"部分行"还是"全部行"?删完还要不要保留表结构和索引?能不能接受不可回滚?表上有没有并发读写?这四个问题的答案基本就决定了你该用哪条语句。

1.2 关键差异对照表

把差异摊开成表格,比逐条背要直观得多。下面这张表是我自己整理的版本,补充了一些文档里不太强调但对生产影响很大的点:

维度DELETETRUNCATEDROP
语句类型DMLDDLDDL
是否可回滚可以(事务内)不可以不可以
是否隐式提交否是是
锁粒度行锁 + 间隙锁(RR 下)MDL 排他锁MDL 排他锁
是否逐行写 undo/redo是否否
触发器触发 DELETE 触发器不触发不触发
自增计数器不重置重置表都没了
空间回收不还给操作系统重建表空间,通常释放释放
支持 WHERE 条件支持不支持不支持
所需权限DELETEDROPDROP
外键引用受约束限制被引用时直接报错受约束限制
大表耗时与行数线性相关与表空间大小相关,通常秒级通常很快

表格里有两行值得单独说。一行是"空间回收":很多人删完数据发现磁盘没降,第一反应是"是不是没删干净",其实 DELETE 在 InnoDB 里只是把数据页标记为可复用,.ibd 文件大小一点没变,这些空洞要等后续插入复用,或者靠重建表才能真正归还给文件系统。另一行是"锁粒度":TRUNCATE 拿的是 MDL 排他锁,它不仅要等当前所有事务结束,还会阻塞后续所有对这张表的访问请求,包括 SELECT。这一点是 TRUNCATE 引发线上事故的最主要原因,比"不可回滚"更常见。

1.3 为什么生产上对 TRUNCATE 又爱又怕

爱它的理由很直接:一张 200GB 的表,DELETE 全表可能要跑十几个小时,期间 undo 膨胀、从库延迟、purge 线程追不上;TRUNCATE 可能几十秒就完事,日志量小,空间立刻回收。对于"整表数据作废、表结构保留"的场景,比如临时表、日志表按月轮转、测试环境重置,TRUNCATE 是最优解。

怕它的理由同样直接:不可回滚、MDL 等待、外键报错、误操作代价高。我见过最典型的一次事故是,某业务在晚上做数据清理,对一张核心配置表执行 TRUNCATE,语句本身执行很快,但在它之前有一个未提交的长事务挂在那张表上,TRUNCATE 一直等 MDL 锁,而 MDL 等待会阻塞后面所有请求,几十秒内整条链路的连接池被打满。真正的问题不是 TRUNCATE 本身慢,而是它在等锁的过程中变成了一个"请求黑洞"。

所以我的判断标准是:能不能用 TRUNCATE,取决于三件事——这张表有没有被外键引用、有没有可能正在被长事务访问、以及你能不能接受数据不可恢复。三个条件里只要有一个不确定,就老老实实走分批 DELETE 或者影子表方案,别图那点时间。

2. 大表删除到底贵在哪:五个影响面逐个拆

2.1 undo、redo 与 purge 的三重压力

DELETE 在 InnoDB 里的完整链路是这样的:定位到行,加行锁,把行的旧版本写进 undo 表空间,把页的修改写进 redo log,标记删除位。事务提交之后,这些被标记删除的记录并不会立刻消失,而是要等 purge 线程来清理。purge 线程清理的速度受限于innodb_purge_threads、IO 能力、以及是否被长事务阻塞。

这就带来三个连锁反应。第一,undo 表空间会急速膨胀,如果用的是共享表空间,ibdata1 会一直长;如果开了独立 undo 表空间,undo 文件也会占满磁盘。第二,history list length 会持续升高,这个指标在SHOW ENGINE INNODB STATUS里能看到,它代表待 purge 的旧版本数量,数值过大意味着 MVCC 版本链很长,所有一致性读都要走更长的链,查询性能整体下滑。第三,purge 追不上会导致 redo log 无法 checkpoint,极端情况下写入会被卡住。

一个具体的量级感受:删除 100 万行、每行 200 字节、表上有 3 个二级索引,产生的 undo 量大概在几百 MB 到 1GB 级别,redo 量取决于页修改的分散程度,如果删除是随机的、几乎每个页都改,redo 量会显著更大。这就是为什么"分批删除"不只是为了避免锁,更是为了给 purge 线程留出追赶的时间窗口。

2.2 锁与 MDL 等待:事故高发区

行锁这块,大多数人已经有意识了:DELETE 带 WHERE 条件时,如果条件没走索引,InnoDB 在 RR 隔离级别下会锁住扫描过的所有行和间隙,等于变相锁表。这个问题的排查方法很直接,用EXPLAIN看执行计划有没有走索引,用SHOW ENGINE INNODB STATUS看TRANSACTIONS段落里有没有大量 lock wait。

真正容易被忽略的是 MDL 锁。MDL 是元数据锁,任何 DDL 都要拿 MDL 排他锁,而普通的 SELECT/DML 会拿 MDL 共享锁。关键在于:MDL 排他锁的等待是优先排队且会阻塞后续所有请求的。也就是说,只要有一个长事务持有 MDL 共享锁,后面的 TRUNCATE 就会一直等,而等它等的时候,新来的 SELECT 也被排在它后面,形成雪崩。

排查 MDL 等待的入口是performance_schema.metadata_locks这张表,MySQL 5.7 以后都有:

SELECT OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_DURATION, LOCK_STATUS, OWNER_THREAD_ID FROM performance_schema.metadata_locks WHERE OBJECT_SCHEMA = 'your_db' ORDER BY OBJECT_NAME;

看到某个对象的LOCK_STATUS = PENDING,基本就能定位到是谁在等、谁在挡。配合information_schema.innodb_trx查长事务的来源和时间,通常五分钟内能锁定问题。

2.3 复制链路与主从延迟

大表 DELETE 对复制的影响,是很多团队真正吃亏的地方。主库执行一条删除 3000 万行的语句,在 ROW 格式下会生成海量 binlog 事件,从库回放时要一条一条应用。MySQL 5.7 之后有基于组提交的并行复制,但并行复制的最小单位是事务,一个大事务只能由单个 worker 回放,并行度再高也用不上。

结果就是从库延迟直线上升。延迟的表现形式有两种:一种是Seconds_Behind_Master缓慢增长,说明从库确实在跑但追不上;另一种是延迟看起来不高,因为主库还在执行事务没提交,Seconds_Behind_Master显示为 0,真正提交后才开始暴涨。第二种情况更危险,因为它会让你误以为一切正常。

应对思路有三个层次。第一层是控制事务大小,把一个大 DELETE 拆成几百上千个小事务,每个事务几十毫秒,并行复制就能用上。第二层是错峰执行,删除任务放在业务低峰期,给从库留出追赶时间。第三层是监控前置,删除任务启动前先确认从库延迟基线、binlog 保留空间、磁盘余量,任务执行中持续观察延迟曲线,超过阈值自动暂停。第三层最关键,因为前两层都是"计划",第三层才是"兜底"。

2.4 磁盘空间不降反升的真相

"删了 200G 数据,磁盘反而涨了"——这个现象听着反直觉,但在 InnoDB 上非常常见,原因有三。

第一,DELETE 不释放 .ibd 文件空间,只标记页可复用,文件大小不变。第二,undo 和 redo 在删除过程中是净增长的,undo 里存的旧版本数据量可能接近被删数据的总量,redo 更是一直写到 checkpoint。所以删除过程中磁盘占用是先涨后平,涨的那部分就是日志。第三,binlog 也在涨,ROW 格式下删除事件的体积跟行宽和索引数量强相关,删 200G 数据产生几十 G 甚至上百 G binlog 完全正常。

想要真正把空间还给文件系统,只有重建表一条路。MySQL 里可以用OPTIMIZE TABLE,它对 InnoDB 来说等价于ALTER TABLE ... ENGINE=InnoDB加重建,支持 Online DDL,但在重建期间需要额外占用大约一张表大小的磁盘空间。如果表 500G 而磁盘只剩 300G,这条路就走不通,只能考虑"新建表 + 数据搬迁 + 改名"的方式,或者在设计阶段就上分区表,从根上避免这个问题。

顺便给一个查碎片的语句,判断要不要重建时可以参考:

SELECT TABLE_NAME, DATA_LENGTH / 1024 / 1024 AS data_mb, DATA_FREE / 1024 / 1024 AS free_mb, ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH + 1) * 100, 2) AS frag_pct FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db' ORDER BY DATA_FREE DESC LIMIT 20;

DATA_FREE是统计信息估算出来的空闲空间,不完全精确,但用来排序找"最该重建的表"足够。

2.5 统计信息与执行计划抖动

大量删除之后,表的行数分布会发生剧变,但 InnoDB 的统计信息是采样估算的,不会自动精确更新。如果删除的恰好是某个索引字段上分布最集中的一段数据,优化器可能还在按旧的选择性估算成本,导致原本该走的索引不走了,或者该走的索引变成了全表扫描。

这个问题在删除当天往往看不出来,过几天业务反馈"某个查询突然变慢了"才暴露。处理办法很简单:大批量删除任务结束后,对涉及的表执行一次ANALYZE TABLE,让统计信息重新采样。如果表特别大、采样误差影响明显,可以调大innodb_stats_persistent_sample_pages再采样一次,代价是采样耗时增加。

3. 四种可落地的删除方案与实操细节

3.1 分批删除:最通用,也最容易写错

分批删除是所有方案里适用面最广的,不依赖表结构设计、不要求停服、可以随时中断。但它的实现细节决定成败,写错的方式有好几种。

第一种错法是DELETE FROM t WHERE cond LIMIT 2000不带 ORDER BY。MySQL 是允许这么写的,但在 statement 格式的 binlog 下,主库和从库删掉的 2000 行可能不是同一批,复制直接不一致。即使在 ROW 格式下没问题,删除顺序的不确定性也会让锁的范围变得不可预测。正确写法是加上确定性排序:

DELETE FROM t_order_log WHERE created_at < '2023-01-01' ORDER BY id LIMIT 2000;

第二种错法是用 OFFSET 分页。LIMIT 2000 OFFSET 20000000这种写法在删除场景下性能会越来越差,因为每一批都要扫描并丢弃前面所有行。正确做法是基于主键游标推进:

-- 每一轮记录上一批的最大 id,下一轮从它之后开始 DELETE FROM t_order_log WHERE id > :last_id AND created_at < '2023-01-01' ORDER BY id LIMIT 2000;

第三种错法是不留喘息时间,循环里没有 sleep,等于变相把一个大事务拆成无数个小事务连续打满,从库照样延迟,purge 照样追不上。合理的节奏是每批之间休眠 100 到 500 毫秒,具体取值看主从延迟曲线,延迟涨了就加大休眠,平稳了再收窄。

一个可以直接抄的脚本骨架,用 bash 驱动,每批打印进度和延迟:

#!/bin/bash BATCH=2000 SLEEP=0.2 LAST_ID=0 while true; do CNT=$(mysql -N -B -e " DELETE FROM t_order_log WHERE id > ${LAST_ID} AND created_at < '2023-01-01' ORDER BY id LIMIT ${BATCH}; SELECT ROW_COUNT();") if [ "$CNT" -eq 0 ]; then echo "done" break fi LAST_ID=$(mysql -N -B -e " SELECT MAX(id) FROM t_order_log WHERE id > ${LAST_ID} AND created_at < '2023-01-01' ORDER BY id LIMIT ${BATCH};") LAG=$(mysql -N -B -e "SHOW REPLICA STATUS\G" | awk '/Seconds_Behind_Source/{print $2}') echo "deleted=${CNT} last_id=${LAST_ID} lag=${LAG}" if [ "${LAG:-0}" -gt 30 ]; then sleep 5 else sleep ${SLEEP} fi done

这段脚本有两个细节值得说。一是LAST_ID的取法,严格来说应该在被删除的范围内取最大值,而不是简单加 BATCH,因为中间可能有空洞。二是延迟判断直接接进了循环,超过 30 秒就自动加大休眠,这是生产脚本该有的自我保护。

3.2 分区表:把删除变成元数据操作

如果你的表本身就是按时间或某个维度分区的,删除历史数据这件事会简单一个数量级。删一个分区的数据,等于删一个分区文件,代价跟分区大小没关系,是 O(1) 级别的元数据操作。

-- 保留分区定义,只清空数据 ALTER TABLE t_order_log TRUNCATE PARTITION p202401; -- 连分区定义一起删掉,更快 ALTER TABLE t_order_log DROP PARTITION p202401;

两者的区别在于DROP PARTITION之后这个分区就不存在了,如果后续还要往这个时间范围写数据,得先ADD PARTITION回来;TRUNCATE PARTITION保留分区结构,数据清空,更适合"分区常驻"的设计。

分区表方案的价值在于它把"删除"这个动作从数据面转移到了元数据面,彻底绕开了 undo、redo、purge、复制延迟这一整套麻烦。代价是设计前置:表必须在建表时就规划好分区键,而且 MySQL 分区表有个硬限制——所有唯一索引必须包含分区列,这意味着如果你的主键是自增 id,就没法按时间分区,只能改成联合主键或者放弃唯一性约束。这个约束在实际项目里经常成为落地障碍,值得在方案设计阶段就评估清楚。

还有一个坑:分区表的分区数不要太多。分区数上千之后,打开表、统计信息、优化器选择都会变慢,一般控制在几百个以内比较稳妥。按天分区一年 365 个还行,按小时分区就得掂量了。

3.3 影子表重建:既要删数据也要瘦身

当需求不仅是"删数据",还要"回收磁盘空间"时,影子表是最稳的方案。思路是:建一张结构相同的新表,把要保留的数据灌进去,然后用 RENAME 原子切换,最后删掉旧表。

-- 1. 建新表,只保留结构 CREATE TABLE t_order_log_new LIKE t_order_log; -- 2. 分批灌入保留数据,这一段同样要控节奏 INSERT INTO t_order_log_new SELECT * FROM t_order_log WHERE id > :last_id AND created_at >= '2023-01-01' ORDER BY id LIMIT 5000; -- 3. 切换,RENAME 是原子的,业务侧几乎无感 RENAME TABLE t_order_log TO t_order_log_bak, t_order_log_new TO t_order_log; -- 4. 确认业务正常后再删备份表 DROP TABLE t_order_log_bak;

这个方案有几个必须注意的点。第一,切换瞬间如果有未提交事务或者长查询持有旧表的 MDL,RENAME 会被阻塞,进而阻塞后续请求,所以切换前要确认没有活跃的长事务。第二,切换后自增计数、触发器、权限都要检查一遍,CREATE TABLE LIKE不复制外键和触发器。第三,如果有其他表通过外键引用这张表,RENAME 之后外键指向会跟着变,需要提前评估。第四,第 2 步的灌数据过程本身也是大事务,同样要分批加 sleep,不要以为换了张表就没问题了。

我倾向于把这个方案用在"表已经严重碎片化、删完还得重建"的场景,一次操作同时解决数据清理和空间回收两件事,比先 DELETE 再 OPTIMIZE 要少一轮风险。

3.4 TRUNCATE 的正确姿势与前置检查

确定要用 TRUNCATE 之后,执行前有一套检查清单,缺一不可。

先查外键引用,被其他表引用时 TRUNCATE 必然失败:

SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA = 'your_db' AND REFERENCED_TABLE_NAME = 'your_table';

再查有没有长事务和长查询挂着,避免 MDL 等待引发雪崩:

SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;

然后确认备份可用。TRUNCATE 不可回滚,出事只能靠备份或者 binlog 回放,执行前必须确认备份是最近的、能恢复的。这一步听起来像废话,但真出事的时候,"备份存在但没验证过能不能恢复"和"没有备份"是一样的结果。

最后是执行时机。避开业务高峰、避开批处理窗口、避开其他 DDL 任务,并且执行时设置一个合理的lock_wait_timeout,让它在拿不到锁的时候快速失败,而不是无限期挂着:

SET SESSION lock_wait_timeout = 10; TRUNCATE TABLE t_temp_data;

设置超时的意义在于,如果 10 秒内拿不到 MDL,语句直接报错退出,不会变成阻塞源。这个细节在核心表上尤其重要。

4. 参数计算:把"拍脑袋"变成"算出来"

4.1 一次删除产生多少日志

估算日志量不需要精确到字节,能判断量级就够了。经验公式是:

undo 总量 ≈ 删除行数 × (行平均宽度 + 索引条目开销) × 1.2 binlog 总量 ≈ 删除行数 × 单行事件开销 × (1 + 索引数量 × 0.3)

行平均宽度可以从information_schema.TABLES里用DATA_LENGTH / TABLE_ROWS粗略估算。举个实际例子:一张日志表平均行宽 300 字节,3 个二级索引,删 3000 万行,undo 大概在 10GB 量级,binlog 在 12 到 20GB 之间。这个量级意味着什么?意味着你得确认磁盘至少有 30GB 以上余量,binlog 保留策略不会因为这次删除把历史 binlog 挤掉,从库的 relay log 空间也要够。

这些数字不需要背,但每次做大表删除前都应该算一遍,因为磁盘打满导致的故障比删除本身慢要严重得多。

4.2 批量大小与总耗时估算

批量大小和总耗时是一对相互制约的参数。批量太小,事务开销占比高,总耗时被拉长;批量太大,单事务日志量大,复制延迟和锁持有时间上升。我的经验区间是单批 1000 到 5000 行,具体取值看单行宽度和索引数量。

总耗时可以用这个公式粗算:

总耗时 ≈ (总行数 / 批量大小) × (单批执行时间 + 休眠时间)

单批执行时间可以实测:拿最小 id 附近的一批跑一次,记录耗时。假设单批 2000 行耗时 40ms,休眠 200ms,那么每秒处理约 4 批 8000 行,1 亿行需要约 12500 秒,也就是 3.5 小时。这个估算可以直接用来跟业务方对齐停机窗口,或者决定要不要改成夜间分多天跑。

需要提醒的是,随着删除推进,如果删除条件是走索引的,单批耗时基本稳定;如果是走全表扫描的条件,越往后扫描代价越大,实际耗时可能远超估算。这种情况下应该考虑换方案,而不是硬跑。

4.3 主从延迟的容忍度换算

从库回放速度可以用一个简单方法测:在主库执行一批删除,记录 binlog 位点变化量,然后在从库观察回放到该位点用了多久。有了回放速度 R(行/秒),延迟增量大致是:

延迟增量 ≈ 删除行数 / R - 主库执行耗时

举例,删除 3000 万行,从库回放速度 5 万行/秒,主库执行用了 600 秒,那么从库需要 600 秒回放,延迟增量接近 0;如果从库回放速度是 1 万行/秒,需要 3000 秒,延迟增量就是 2400 秒,也就是 40 分钟。这个数字直接决定了你会不会被业务方投诉。

降低延迟增量的手段无非两个:减少单事务行数(提高并行度),或者降低删除速率(让主库产生 binlog 的速度慢于从库回放速度)。后者更容易控制,也就是前面脚本里那个根据延迟动态调整 sleep 的逻辑。

5. 常见问题与排查实录

5.1 删完空间没降

这是最高频的问题。先确认删的是不是真的删了:SELECT COUNT(*)看行数,information_schema.TABLES看DATA_LENGTH有没有变化。如果行数降了但文件大小没变,那是正常的 InnoDB 行为,空间只是被标记为可复用,没有还给文件系统。

要归还空间有三种做法:OPTIMIZE TABLE(需要额外磁盘空间,Online DDL,期间可读写但性能有影响)、影子表重建(需要两倍空间但过程可控)、或者干脆不还——如果这张表后续还会持续写入,复用空洞反而比重建更划算。判断标准很简单:看未来的写入量能不能填满这些空洞。日志类表持续增长,通常不需要重建;配置类表删了大量历史数据后基本不再写,那就值得重建。

5.2 删除任务把库拖死

症状是连接数暴涨、大量查询超时、CPU 和 IO 打满。排查顺序是:先看SHOW PROCESSLIST有没有大量Waiting for table metadata lock或者updating状态的连接;再看information_schema.innodb_trx里有没有运行时间很长的删除事务;然后看SHOW ENGINE INNODB STATUS的TRANSACTIONS段落有没有锁等待。

如果确认是删除任务导致的,第一动作是 kill 掉删除会话。注意 kill 大事务本身也需要时间,因为要回滚,回滚的耗时可能跟执行时间相当甚至更长。所以更稳妥的做法是提前给删除会话设置max_execution_time,或者在脚本层面做好速率控制,别让它有机会跑飞。

5.3 从库延迟几十分钟

先分清是"真延迟"还是"假延迟"。如果主库事务还没提交,Seconds_Behind_Source可能显示 0,提交后才开始暴涨。查SHOW REPLICA STATUS里Relay_Log_Pos和Exec_Master_Log_Pos的差值,或者直接看performance_schema.replication_applier_status_by_worker里各 worker 的状态,能看出是卡在某个大事务上还是整体慢。

应对手段:如果延迟还在可接受范围内,让它跑完,同时暂停后续删除批次;如果已经影响到读业务,可以临时把读流量切到主库或者其他从库,等延迟追平再切回来。事后复盘时重点看两件事:删除脚本有没有延迟熔断机制,以及并行复制的配置是否合理。

5.4 TRUNCATE 报错合集

报错信息原因处理方式
Cannot truncate a table referenced in a foreign key constraint表被其他表外键引用先处理外键约束,或改用 DELETE
Lock wait timeout exceeded拿不到 MDL,有长事务或长查询查 innodb_trx 和 metadata_locks,清理阻塞源
Access denied; you need DROP privilege权限不足TRUNCATE 需要 DROP 权限,不是 DELETE 权限
Table doesn't exist表名或库名写错核对当前连接的库
Cannot truncate a table with triggers?部分版本对含触发器的表有额外限制检查触发器定义,必要时先禁用

第二行是最常见的,也是最容易演变成事故的。核心表的 TRUNCATE 一定要配lock_wait_timeout,宁可失败也不要挂住。

5.5 误删后的止血顺序

误删发生了,顺序比速度重要。第一步是立刻停止所有写入,把表或库设为只读,防止新数据覆盖旧数据、防止 binlog 被继续写而追不上恢复点。第二步是确认删除方式和时间点,是 DELETE、TRUNCATE 还是 DROP,大致什么时间执行的。第三步是评估恢复手段:有备份就基于备份加 binlog 做时间点恢复,恢复到删除前一刻;没有备份但有 binlog,可以用工具解析 binlog 反向生成回滚语句;两者都没有,那就只能接受现实。

这里有个容易被忽略的点:TRUNCATE 和 DROP 在 ROW 格式的 binlog 里也是记录了的,只是记录形式是 DDL 语句,不能像 DELETE 那样直接反向生成 INSERT。所以 TRUNCATE 之后的恢复,基本只能靠全量备份加 binlog 重放,时间成本高得多。这也是我一直强调"TRUNCATE 之前必须确认备份可用"的原因。

5.6 大表删除方案速查表

场景推荐方案关键参数主要风险
删部分历史数据,表结构不变分批 DELETE批量 1000~5000,休眠 100~500ms复制延迟、purge 压力
表已按时间分区DROP/TRUNCATE PARTITION分区粒度、分区数上限分区键设计受限
删大部分数据且需回收空间影子表重建灌数据批量、切换窗口需要两倍磁盘、切换锁
清空整表保留结构TRUNCATElock_wait_timeout、备份确认MDL 等待、不可回滚
表已严重碎片化OPTIMIZE TABLE额外磁盘空间重建期间性能下降

这张表我贴在自己团队的运维手册第一页,因为它把"该用哪个方案"这个最容易犹豫的问题压缩成了一次查表。

6. 跨库差异:PostgreSQL 的删除逻辑完全是另一套

6.1 死元组与 VACUUM 机制

如果只把 MySQL 的经验搬到 PostgreSQL,大概率会翻车,因为两者的删除模型根本不同。PG 的 MVCC 是把行的新版本写进新的元组位置,旧版本留在原地标记为死元组(dead tuple),DELETE 本身只是标记,真正清理靠 VACUUM。这意味着 DELETE 之后表文件不会变小,反而可能变大,因为新版本和旧版本同时存在。

普通 VACUUM 把死元组空间标记为可复用,但不归还操作系统;只有 VACUUM FULL 才会重写表文件并归还空间,而 VACUUM FULL 需要 ACCESS EXCLUSIVE 锁,等于锁表,大表上基本不能用。生产上更常用的是pg_repack这类在线重建工具,或者在设计阶段就用分区表。

这个差异带来的直接后果是:PG 里的大表删除,除了关注锁和复制,还要额外关注 autovacuum 能不能跟上。如果一张表被大批量删除后 autovacuum 迟迟不触发,表会持续膨胀,查询性能下降。可以手动VACUUM (ANALYZE) 表名触发一次,同时调低这张表的 autovacuum 触发阈值。

6.2 TRUNCATE 在 PG 里的行为差异

PG 的 TRUNCATE 有几个跟 MySQL 不一样的地方,值得单独记住。

第一,PG 的 TRUNCATE 是事务性的,可以放在事务里回滚。这听起来是好事,但也意味着它会持有锁直到事务结束,如果放在一个长事务里执行,锁的持有时间会被拉得很长。

第二,PG 的 TRUNCATE 默认需要对整条外键链加锁,如果有其他表引用它,不加CASCADE会直接报错,加了CASCADE会连引用表的数据一起清空——这个行为比 MySQL 危险得多,误加 CASCADE 的后果是灾难性的。

第三,PG 里 TRUNCATE 和 DELETE 一样需要 VACUUM 才能回收空间,虽然 TRUNCATE 会创建新的 relfilenode,但对共享缓冲区的影响和 WAL 的写入量仍然存在。

所以跨库做数据清理,方案不能直接平移。MySQL 那边习惯的"分批 DELETE + 定期 OPTIMIZE",到 PG 这边对应的应该是"分批 DELETE + 监控 autovacuum + 必要时分区表",工具链和监控指标都不一样。

7. 我个人沉淀下来的几条操作纪律

7.1 删除前必须回答的四个问题

这几年下来,我把删除操作的前置检查压缩成四个问题,任何一条答不上来就不动手。

第一,删的是什么范围,影响多少行,这个数字是算出来的还是猜的?行数必须实测,用SELECT COUNT(*)或者EXPLAIN估算都行,但不能是"大概几百万吧"。

第二,这个操作能不能回滚,回滚需要多久?能回滚的走 DELETE,不能回滚的必须有备份,并且备份要验证过可恢复。

第三,对复制链路的影响有多大,从库延迟能容忍多久?这个要提前跟业务方对齐,不能自己拍板。

第四,磁盘、binlog、undo 空间够不够?按前面 4.1 的公式粗算一遍,留出至少 50% 的余量。

四个问题里,第三个和第四个最容易被跳过,也最容易在半夜把人叫起来。

7.2 把删除当成一次发布来管理

我现在带团队做数据清理,流程上完全按发布来走:有变更单、有执行脚本、有回滚预案、有监控看板、有执行人以外的第二个人复核。听起来很重,但数据删除这个动作的本质就是不可逆变更,用发布的严谨度去对待它一点都不过分。

具体到执行环节,有几个小习惯值得推广。所有删除脚本必须支持断点续跑,记录已处理到的最大 id,中断后从断点继续而不是从头再来。所有删除任务必须有速率上限和延迟熔断,宁可慢也不能打挂从库。所有涉及 TRUNCATE 的操作必须在低峰期执行,并且提前在测试环境跑一遍完整流程,包括失败路径。

7.3 一个容易被忽略的收尾动作

删除任务跑完,很多人就收工了。实际上还差三步收尾:执行ANALYZE TABLE刷新统计信息,避免执行计划抖动;检查history list length有没有回落到正常水平,确认 purge 追上了;观察一到两天的慢查询日志,确认没有因为数据分布变化导致新的慢查询。

这三步加起来不到十分钟,但能挡掉相当一部分"删完之后过几天突然变慢"的问题。我吃过一次亏,删完一张 2 亿行的表之后没刷统计信息,第三天业务反馈核心查询从 20ms 变成 800ms,查了半天才发现是优化器选错了索引,一个ANALYZE TABLE就解决了。

数据库里的"删除"从来不是一个轻量动作,它牵扯到事务日志、锁、复制、空间管理、统计信息一整套机制。想清楚这层关联,比记住几条语法差异有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询