1. 先厘清一件事:这两个命令到底差在哪
DELETE和TRUNCATE的区别,几乎每本入门书都会写上一句“一个删行、一个清表”,然后就翻篇了。可真正在生产上出过事的人都清楚,这句话的信息量约等于零。我在实际工作里见过的两类典型工单,几乎都源于没弄清这两个命令:一类是业务方要清空一张日志表,开发图省事写了DELETE FROM log_table,跑了四十分钟没跑完,从库延迟涨到两小时,报警响了一整夜;另一类更省事,直接上TRUNCATE,可这张表被别的表外键引用着,语句报错,运维临时改脚本又踩了锁。这两件事的共同点是:大家对“删除”这件小事的心理预期,远远低于它在数据库内部的实际成本。
我把话说直白一点:DELETE是 DML,它做的事情叫“把某几行标记成不可见,并把旧值抄一份留档”,本质上是可撤销的数据修改;TRUNCATE是 DDL,它做的事情叫“把整张表的物理载体换掉”,本质上是结构层面的重置。前者像你在账本上一笔一笔划掉记录,每划一笔都要留底稿;后者像你直接把整本账本换个新的,旧的那本丢进碎纸机。同样叫删除,走的根本不是同一条路。
这篇文章想解决的,就是“知道区别”到“敢在生产上动手”之间那一段。它适合三类人:写业务代码的后端同学,你写的每一行DELETE都会在某个深夜变成 DBA 的工单;刚接手数据库运维的同学,你需要一套能直接抄的执行流程;还有做数据清理脚本、归档任务的同学,你们面对的往往是千万级、亿级的大表,删法选错,代价是按天算的。我会把底层机制、影响范围、分批策略、参数计算、踩坑排查这几块拆开讲,涉及具体参数的地方都会给出估算过程,方便你按自己的表结构换算。
提醒:下文所有示例都是通用写法,不同数据库版本的行为存在差异,MySQL 与 PostgreSQL 的差别我在关键处会单独标注。上线前一定要在预发环境用同量级数据验证,别拿生产库当试验田。
1.1 三条暗线:日志、锁、空间
想彻底理解这两个命令,盯着语法没用,得看它们各自触发了哪几条“暗线”。我把这三条线列出来,后面所有分析都围绕它们展开。
第一条是日志线。DELETE每删一行,都要写 undo 记录(旧值留档,供回滚和一致性读使用)、写 redo 日志(保证崩溃可恢复),如果开了行格式的二进制日志,还要把每一行的前镜像和后镜像都记进去。一张五千万行的表,一次DELETE就是五千万份这样的记录。TRUNCATE呢?它只产生一条 DDL 语句的日志,无论表里是一行还是一百亿行,日志量几乎是常数。这个差异,直接决定了 binlog 体积、磁盘水位和主从同步的负担。
第二条是锁线。DELETE加的是行级锁,锁的是它碰到的那些行(具体锁范围还跟索引、隔离级别有关)。好处是并发友好,坏处是删得越多、锁越多,锁等待和死锁概率呈线性上升。TRUNCATE加的是元数据层面的排他锁,也就是整张表的“施工围挡”。它不会一行一行地锁,但它需要等所有正在访问这张表的事务结束,而且一旦它开始排队,后面所有想访问这张表的请求都会被堵在它后面,这叫元数据锁队列堆积,很典型的一类雪崩。
第三条是空间线。这是最容易被忽略、也最容易让人产生“删了怎么没变小”困惑的一条。DELETE删掉的行,在 InnoDB 里只是被标记成删除,页还在,空间还在表空间文件里,只是变成了可复用的空闲页。所以你会看到表数据只剩一千万行,文件还是 50GB。TRUNCATE则直接把表空间换新,文件立刻回到初始大小。
1.2 影响范围为什么在大表上被放大
单看这三条线,小表上几乎无感:删一千行,毫秒级,谁也不会在意。但成本是行数乘以每行开销,这个乘法在大表上会变得非常残酷。
举个具体的量级感受:一张两亿行的表,平均行长 250 字节,数据大约 50GB,加上两三个二级索引可能到 70GB。用DELETE全删,会产生数十 GB 的 undo、上百 GB 的 binlog(行格式),执行时间从几十分钟到几小时不等,期间主库 IO 打满、从库延迟持续增长、磁盘水位告警。用TRUNCATE,元数据操作本身可能几百毫秒就完成了,但背后释放几十 GB 文件、清理缓冲池里对应的页,是另一笔账,我在第 3 章会细说。
所以“大表删除影响”这个说法其实不准确——影响不是删除这个动作带来的,是删除方式与表的体量不匹配带来的。选对方式,大表删除可以很平稳;选错方式,一张几千万行的表就足以让整个集群抖三抖。
2. 机制拆解:同样叫删除,内部走了两条完全不同的路
2.1 DELETE 的逐行旅程
一条DELETE FROM t WHERE id = 100在 InnoDB 里要走完这么一串动作:
- 通过聚簇索引定位到目标行,如果
WHERE条件没走索引,那就是全表扫描,这是第一个性能陷阱; - 对定位到的记录加排他锁,如果条件列没索引,在可重复读隔离级别下,可能退化成锁住扫描过的所有记录甚至间隙;
- 把这一行的旧值写进 undo 日志,这份 undo 既是回滚的依据,也是一致性读(MVCC)时其他事务看到旧版本的来源;
- 在聚簇索引的记录头上打一个删除标记位,记录并没有立刻物理消失;
- 写 redo 日志,保证崩溃后能重放;
- 事务提交时,配合两阶段提交把变更写进 binlog。
注意第 4 步:打标记不等于真删除。真正的物理清理是后台 purge 线程异步做的,它要判断这行还有没有更早的读视图在引用。如果此时有一个长事务一直不提交,purge 就会被卡住,undo 只增不减,历史版本链越拉越长,这就是所谓的 purge 滞后。information_schema.INNODB_TRX里的trx_started和SHOW ENGINE INNODB STATUS里的 History list length,是观察这个问题的两个关键指标。
删得越多,这条链路上的每一环都被放大:undo 表空间膨胀、purge 线程追不上、二级索引的清理滞后、binlog 暴涨。而且DELETE是逐行处理并持锁的,一个包含几百万行删除的大事务,会长时间持有大量行锁,其他业务的更新请求全部排队。
还有一个常被忽视的点:DELETE会触发BEFORE DELETE/AFTER DELETE触发器,也会触发外键的级联删除动作。这两样东西在大批量删除时是纯负担,尤其级联删除,它会把成本扩散到你没预料到的其他表上。
2.2 TRUNCATE 的换页逻辑
TRUNCATE TABLE t在很多数据库里的实现思路是“丢弃旧载体,造一个新的”。以 MySQL 的 InnoDB 为例,它的本质接近DROP TABLE加CREATE TABLE:清理掉原来的段和表空间文件,按原表定义重建一个空表,自增值重置回起点,统计信息重新生成。语句返回的影响行数通常是 0,因为引擎根本没去数删了多少行——它压根没删行。
这里有几个必须记住的副作用:
- 不可回滚。它是 DDL,执行即刻隐式提交,事务包不住它。你在事务里写
TRUNCATE,等于在事务中间强制提交了一次,前面的操作也一起落地了。 - 不触发 DELETE 触发器。依赖触发器做审计、做同步的逻辑会静默失效,这类坑非常隐蔽。
- 外键限制。MySQL 里被外键引用的表无法
TRUNCATE,必须先处理引用关系,这是它和DELETE最硬的行为差异之一。 - 权限要求更高。需要 DROP 级别的权限,很多只给了增删改权限的业务账号根本执行不了,这其实是一道有价值的保护。
- 锁的形态不同。它要拿元数据排他锁,必须等待所有引用该表的活跃事务结束。一旦有慢查询正在扫这张表,
TRUNCATE就会无限期排队,而它后面排着的所有请求也一起被堵住。
PostgreSQL 的行为又有区别,这点必须单独说。PostgreSQL 的TRUNCATE是可以放进事务里回滚的,这常让从 MySQL 转过来的同学很意外;它拿的是ACCESS EXCLUSIVE锁,同样会阻塞所有访问;它支持CASCADE级联清理被外键引用的子表,也支持RESTART IDENTITY重置序列;它不触发ON DELETE触发器,但会触发ON TRUNCATE触发器;物理实现上是给表换一个新的 relfilenode,旧文件由后台清理,空间立刻从表本身释放。
2.3 一张表看清全部差异
| 维度 | DELETE | TRUNCATE |
|---|---|---|
| 语句类型 | DML | DDL |
| 是否可回滚 | 可以(MySQL/PG 均可) | MySQL 不可;PostgreSQL 可以 |
| 是否支持条件 | 支持 WHERE | 只能整表 |
| 日志量 | 与行数成正比 | 近乎常数 |
| 锁形态 | 行锁(范围受索引与隔离级别影响) | 元数据排他锁 / 表级排他锁 |
| 触发器 | 触发 DELETE 触发器 | 不触发 DELETE 触发器 |
| 自增值 | 保留 | 重置 |
| 空间释放 | 不释放,仅标记可复用 | 立即释放 |
| 外键引用表 | 可执行(可能触发级联) | MySQL 不可,PG 需 CASCADE |
| 执行速度 | 与行数、索引数成正比 | 与行数基本无关 |
| 主从影响 | binlog 按行记录,体积巨大 | 一条 DDL,体积很小 |
看这张表能得出一个很实用的判断:当你需要“条件”、需要“可回滚”、需要“触发器联动”时,只能选DELETE;当你需要“整表清空且快”时,选TRUNCATE,但要接受它所有的副作用。真正困难的不是这两种情况,而是“按条件删掉表里 80% 的数据”这种中间态——DELETE太慢,TRUNCATE又不满足条件,这才是生产上最常见的难题。
3. 大表删除的真实影响:慢只是最轻的那个问题
3.1 主从延迟是被什么拉长的
主从延迟的根源在于“从库只能串行重放同一个事务”。行格式的 binlog 里,DELETE影响多少行,就有多少条行变更事件。一个事务删一千万行,从库就得把这一个事务完整重放完才算数,中间不能并行。多线程复制虽然能按库或按逻辑时钟并行,但对单个大事务无能为力。于是主库几十分钟跑完,从库可能要跑更久——因为从库往往还承担着大量读请求,IO 资源本就不富裕。
延迟的后果不只是报表数据旧。读写分离的架构里,刚删掉的数据在从库还能读到,业务方会以为删除失败,反复重试;延迟超过阈值触发报警,运维介入排查,又是一轮沟通成本。更极端的是,如果删除期间 binlog 把磁盘写满,整个实例会进入只读状态,那就不是延迟的问题了,是在线故障。
TRUNCATE在这一点上优势明显:binlog 里就是一条语句,从库重放极快。但要注意,从库执行 DDL 时同样需要拿排他锁,如果从库上有长查询,这个 DDL 也会排队,延迟照样会涨——只是涨的原因从“重放慢”变成了“等锁”。
3.2 锁等待与连接池雪崩
批量DELETE期间,如果删除条件没有走索引,事情会变得非常难看。没有索引意味着全表扫描,而扫描过程中碰到的每一行都可能被加锁(可重复读隔离级别下,扫过的记录都会加锁,包括不满足条件的),效果接近锁表。这时候其他业务对这个表的任何写操作都会阻塞,连接池里的连接一个个被占住,超时、重试、再占住,很快就满了。应用层的表现是接口大面积超时,而数据库层面只是“有个删除任务在跑”。
死锁也是这个阶段的高发问题。当两个不同方向的批量删除脚本同时运行,或者删除脚本与业务更新语句交叉持锁,间隙锁的存在会让死锁概率显著上升。我在实际中见过的最典型场景,是两个清理任务按不同顺序扫同一张表,互相等待,数据库选一个回滚,脚本没做重试就直接挂了,留下删了一半的数据。
一条硬经验:任何在生产上跑的批量删除,
WHERE条件必须命中索引,且执行前必须用EXPLAIN确认。这一条能挡掉八成的锁问题。
3.3 空间不释放与表膨胀
“我把一亿行删了,为什么磁盘没变?”这个问题我被问过太多次。答案是:DELETE释放的是页内的行槽位,这些页变成空闲页留在表空间里,供后续插入复用。表文件不会缩小,甚至可能因为页分裂产生碎片而略微变大。
MySQL 想真正回收空间,得重建表:OPTIMIZE TABLE或者ALTER TABLE ... ENGINE=InnoDB,本质都是把数据导一遍再换回去。这个操作需要额外的磁盘空间(大约等于表大小)、需要拿排他锁或走在线 DDL 流程,执行时间也不短。生产上更稳妥的做法是用第三方在线变更工具,或者干脆在低峰期做,并预留足够的磁盘。PostgreSQL 类似,普通VACUUM只把空间标记为可复用,VACUUM FULL才真正重写文件,而它会持排他锁,大表上基本等于停服,所以更多人改用第三方重打包工具。
还有一个连带影响:表膨胀之后,全表扫描要读的页数没减少,反而可能因为碎片变多而变慢。也就是说,删除并没有让查询变快,反而可能变慢。这一点在做容量规划时必须提前算进去。
3.4 undo、临时空间与磁盘水位
MySQL 里大事务删除最危险的连带效应是 undo 表空间膨胀。undo 是为了支持回滚和一致性读,一个删除一亿行的事务,undo 里就要留一亿行的旧值,几十 GB 起步。如果磁盘余量不足,直接写满,实例进入只读。哪怕没写满,purge 线程在事务提交后还要花很长时间清理,这段时间 history list length 居高不下,所有查询的一致性读都要沿着版本链往回找,读性能整体下滑。
磁盘水位还要算上 binlog。行格式下,删除一亿行的 binlog 体积可能和表数据本身一个量级。如果 binlog 保留策略是七天,这一波删除的影响会持续一周。
所以在动手之前,我会把这几项算清楚:
- 本次删除涉及多少行、平均行长多少,粗略估算 undo 与 binlog 的量级;
- 当前磁盘余量是否大于“预估增量 × 2”;
- binlog 保留天数与日均增量是否匹配;
- 从库延迟阈值是多少,超过后脚本能否自动暂停。
这四项里任何一项没底,就不要在生产上开跑。
4. 实操方案:四种场景,四套打法
4.1 场景一:整表清空,TRUNCATE 的正确打开方式
整表清空看起来最简单,但恰恰是最需要前置检查的。我通常会按这个顺序走一遍:
-- 1. 确认表的基本情况:行数、大小、索引 SELECT table_rows, data_length, index_length FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'your_table'; -- 2. 确认外键引用关系(MySQL) SELECT table_name, column_name, constraint_name FROM information_schema.key_column_usage WHERE referenced_table_schema = 'your_db' AND referenced_table_name = 'your_table'; -- 3. 确认没有长事务在跑 SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec FROM information_schema.innodb_trx ORDER BY trx_started; -- 4. 确认没有慢查询占着这张表 SELECT id, user, time, state, LEFT(info, 80) AS sql_head FROM information_schema.processlist WHERE info LIKE '%your_table%';四步都干净了再执行。执行时建议先设一个锁等待超时,避免无限期排队把后面的请求全堵住:
SET SESSION lock_wait_timeout = 10; TRUNCATE TABLE your_table;lock_wait_timeout的单位是秒,设成 10 意味着如果十秒内拿不到元数据锁就放弃。这个值比默认的几万秒友好得多——宁可报错重试,也不要把整张表的访问入口堵死。
还需要提醒三点。第一,TRUNCATE之后自增值重置,如果业务依赖自增 ID 的单调性做排序或分页,行为会变。第二,如果这张表上有审计触发器,TRUNCATE不会触发,需要单独补记录。第三,如果 MySQL 版本较老,TRUNCATE在表上有外键引用时会直接报错,这个报错信息有时不够直白,容易被误判成权限问题。
4.2 场景二:按条件删掉大部分数据,分批 + 主键游标
这是最难的场景,也是实际工作中遇到最多的。表里一亿行,要删掉八千万,WHERE create_time < '2024-01-01'。这时候直接DELETE是自找麻烦,TRUNCATE又不满足条件。标准解法是分批,并且用主键游标而不是LIMIT偏移。
为什么不用LIMIT offset, size?因为偏移量越大,数据库要跳过的行越多,越到后面越慢,而且跳过的过程中还可能持有不必要的锁。主键游标法的思路是“记住上次处理到哪个主键”,每次从那个位置往后取一批,索引定位,成本恒定。
MySQL 可以直接用DELETE ... LIMIT:
-- 每批 2000 行,循环执行直到影响行数为 0 DELETE FROM your_table WHERE create_time < '2024-01-01' ORDER BY id LIMIT 2000;注意这里加了ORDER BY id,目的是让删除顺序与主键索引一致,减少随机 IO。如果create_time上有索引,优化器可能选择走create_time索引,效果也可以,但一定要EXPLAIN确认,最怕的是它退化成全表扫描加LIMIT——那就变成每批都扫一遍全表。
PostgreSQL 不支持DELETE ... LIMIT,要用 CTE 包一层:
WITH batch AS ( SELECT ctid FROM your_table WHERE create_time < '2024-01-01' LIMIT 2000 ) DELETE FROM your_table t USING batch b WHERE t.ctid = b.ctid;ctid是物理行位置,用它可以避免对主键的额外依赖,但要注意ctid会因为更新或 vacuum 而变化,所以这批和下一批之间不能有并发的更新操作,否则可能漏删或重复处理。更保险的写法是用主键范围:
DELETE FROM your_table WHERE id IN ( SELECT id FROM your_table WHERE create_time < '2024-01-01' ORDER BY id LIMIT 2000 );外层脚本控制循环节奏,这是决定成败的关键部分。用 Python 写个骨架,把延迟监控和自动刹车都放进去:
import time import pymysql BATCH = 2000 MAX_LAG = 30 # 从库延迟上限,秒 SLEEP_BETWEEN = 0.05 # 每批之间喘口气 conn = pymysql.connect(host="primary", user="cleaner", password="***", database="your_db", autocommit=True) cur = conn.cursor() while True: affected = cur.execute( "DELETE FROM your_table " "WHERE create_time < '2024-01-01' " "ORDER BY id LIMIT %s", (BATCH,) ) if affected == 0: break # 每 10 批检查一次从库延迟 lag = cur.execute("SHOW REPLICA STATUS") # 实际要取字段值 if lag and lag > MAX_LAG: print("延迟过高,暂停 60 秒") time.sleep(60) else: time.sleep(SLEEP_BETWEEN)这个骨架的核心思想是自我限速:数据库不紧张就快跑,从库跟不上了就主动暂停。生产上跑清理任务,宁可慢两小时,也不要让监控响一晚上。
心得:分批删除的脚本一定要做成幂等且可断点续跑的。因为绝大多数情况下你都会中途停下来(延迟高了、业务高峰到了、要发版了),如果脚本不能从中断处接着跑,你会被迫重来。
4.3 场景三:只删一小部分数据,把索引这件事做对
如果只删几千行,通常不需要分批,但索引必须确认。几个典型问题:
WHERE里对索引列用了函数,比如WHERE DATE(create_time) = '2024-01-01',索引直接失效,变成全表扫。正确写法是范围查询:WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。
隐式类型转换同理。字符类型的列用数字去比较,或者反过来,都可能让索引失效。这种情况在大表上尤其致命,因为一次全表扫可能要几十秒,期间扫过的行都被加锁。
还有一种容易被忽略的:WHERE命中的行数占比很高时,优化器可能主动放弃索引走全表扫。比如你要删掉表里 40% 的行,即使用了索引,优化器算下来也觉得全表扫更便宜。这种情况下,分批反而能让优化器稳定走索引——因为每批的LIMIT很小,走索引的成本明显更低。这也是“大比例删除必须分批”的另一个理由。
执行前统一做这三件事:
-- 1. 看执行计划:type 不能是 ALL,key 不能是 NULL EXPLAIN DELETE FROM your_table WHERE create_time < '2024-01-01'; -- 2. 先数一遍要删多少,心里有底 SELECT COUNT(*) FROM your_table WHERE create_time < '2024-01-01'; -- 3. 事务里先跑一遍,确认无误再提交(小批量适用) START TRANSACTION; DELETE FROM your_table WHERE id = 12345; -- 核对影响行数和数据状态后 COMMIT;4.4 场景四:分区表,把删除变成元数据操作
如果一张表需要长期反复做“按时间清理”,最值得投入的优化是改造成分区表。分区表的DROP PARTITION是纯元数据操作,无论分区里有多少行,耗时都在毫秒级,不产生行级日志,不触发逐行删除,对主从的影响也极小。这是大表清理的最优解,没有之一。
建表示例(按范围分区):
CREATE TABLE log_table ( id BIGINT NOT NULL AUTO_INCREMENT, biz_id BIGINT NOT NULL, create_time DATETIME NOT NULL, content VARCHAR(512), PRIMARY KEY (id, create_time), KEY idx_create_time (create_time) ) ENGINE=InnoDB PARTITION BY RANGE (MONTH(create_time)) ( PARTITION p202401 VALUES LESS THAN (2), PARTITION p202402 VALUES LESS THAN (3), PARTITION p202403 VALUES LESS THAN (4), PARTITION pmax VALUES LESS THAN MAXVALUE );清理时直接丢掉整个分区:
ALTER TABLE log_table DROP PARTITION p202401;这里有个硬性约束必须提前说清楚:分区键必须包含在主键或唯一键里,否则建表就报错。上面示例里主键写成(id, create_time)就是这个原因。这个约束会带来的代价是:主键不再是单列,基于id的单条查询依然没问题,但如果有其他唯一约束,需要一并调整。
另外一个坑是MAXVALUE分区。如果建表时留了pmax兜底,新数据都会落进去,你需要定期把它REORGANIZE成具体分区,否则清理脚本删不到它。我一般建议在定时任务里加一步,每月检查pmax里有没有数据,有就拆分。
分区裁剪是另一件要验证的事。加完分区后,业务侧的查询必须能命中裁剪,也就是WHERE里带上分区键,否则会扫全部分区,性能反而比不分区的表更差。改造分区表之前,务必把业务侧所有涉及这张表的 SQL 捞出来看一遍。
4.5 参数怎么定:批次大小与耗时估算
批次大小不是拍脑袋定的,我给一个可复现的算法。
设表有 N 行,平均行长 B 字节,删除比例 r,目标单批耗时不超过 T(建议 0.2 秒到 0.5 秒)。
第一步,估算单行删除的实际开销。DELETE的成本主要在三块:索引定位、undo 写入、binlog 写入。经验上,一行删除的实际 IO 开销大约是行长的 2 到 3 倍(含索引)。假设 B = 250 字节,单行开销约 600 字节。
第二步,用实测校准。拿预发环境同结构的表,跑DELETE ... LIMIT 1000,看耗时。假设实测 1000 行耗时 80 毫秒,即单行 0.08 毫秒。
第三步,反推批次。目标是单批 T = 0.3 秒,则批次大小约为300 / 0.08 ≈ 3750,取整到 2000 或 3000 更稳妥,留出抖动余量。
第四步,估算总耗时。总行数N × r = 8000 万,批次 2000,共 4 万批。每批 0.2 秒,纯执行时间约 2.2 小时,加上每批之间的 sleep(0.05 秒)和延迟触发的暂停,实际按 3 到 4 小时规划。这个量级必须安排在业务低峰期,并且提前和业务方对齐。
第五步,估算资源占用。undo 的峰值取决于单批事务大小,2000 行 × 600 字节 ≈ 1.2MB,很小,这是分批的另一个好处——把大事务拆成小事务,undo 压力几乎消失。binlog 增量约等于总行数 × B ≈ 20GB,需要提前确认 binlog 保留策略和磁盘余量。
这张估算表我一般会写进变更单,不是为了走流程,而是为了在出问题时能快速判断“是不是超预期了”:
| 项目 | 估算值 | 实测值 | 备注 |
|---|---|---|---|
| 涉及行数 | 8000 万 | 待填 | 先 COUNT 确认 |
| 批次大小 | 2000 | 待填 | 预发实测校准 |
| 单批耗时 | 0.2 秒 | 待填 | 超 0.5 秒要降批次 |
| 总耗时 | 3 到 4 小时 | 待填 | 含暂停时间 |
| binlog 增量 | 约 20GB | 待填 | 看磁盘余量 |
| 从库延迟峰值 | < 30 秒 | 待填 | 超阈值自动暂停 |
5. 常见问题与排查实录
5.1 删完了磁盘为什么没降
这是最高频的问题。MySQL 里先确认三件事:表空间是不是独立表(innodb_file_per_table)、有没有正在跑的长事务卡住 purge、以及是不是只删了部分行。如果确认是空闲页未回收,那就得重建表:
-- 查看表的空闲空间(近似值) SELECT table_name, data_length / 1024 / 1024 AS data_mb, data_free / 1024 / 1024 AS free_mb FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'your_table'; -- 重建表回收空间,执行期间会占用额外磁盘并影响写入 ALTER TABLE your_table ENGINE=InnoDB;重建表前必须确认磁盘余量大于表大小,并且安排在低峰期。大表上这个操作可能跑几小时,中途失败会留下临时文件,需要手工清理。
PostgreSQL 里,普通VACUUM不会缩小文件,只标记可复用。要真正缩小得用VACUUM FULL,但它持排他锁;实操中更常见的方案是用第三方在线重打包工具,或者干脆接受“文件不缩、空间复用”的事实,把监控指标从“磁盘占用”改成“可用空间比例”。
5.2 TRUNCATE 卡住不动了
TRUNCATE挂住几乎只有一个原因:拿不到元数据锁。它需要等所有持有该表元数据锁读锁的事务结束,而这其中可能包括一个跑了半小时的报表查询,或者一个忘了提交的会话。
排查路径很固定:
-- MySQL 8.0:直接看元数据锁等待 SELECT * FROM performance_schema.metadata_locks WHERE OBJECT_NAME = 'your_table'; -- 配合进程列表找出阻塞源 SELECT id, user, host, db, command, time, state, LEFT(info, 100) FROM information_schema.processlist WHERE time > 60 ORDER BY time DESC;找到阻塞的长事务后,要么等它结束,要么联系对应业务方确认能否终止。切忌直接KILL掉不认识的会话——那可能是一个正在做数据迁移的任务,杀掉会造成数据不一致。
还有一个隐蔽情况:TRUNCATE本身执行完了,但客户端还在等。这是因为大表空间释放涉及缓冲池里对应页的清理,元数据层面已完成,物理清理在后台继续。此时观察到的“卡住”其实是文件系统和 IO 在忙,不用干预,等着就好。
5.3 误删之后怎么办
先说结论:DELETE误删还有救,TRUNCATE误删基本只能靠备份。
DELETE的救援路径有两条。第一条是 binlog 反向解析,把删除语句对应的行变更事件反转成插入,生成回滚脚本。前提是 binlog 格式为行格式且保留期覆盖了误删时间点,这也是我坚持所有生产实例必须用行格式的原因之一。第二条是备份加时间点恢复,恢复到误删前的某个时刻,再把缺的数据导回原库。
TRUNCATE没有第二条路之外的选项,因为它是 DDL,不产生行级变更记录,binlog 里只有一条语句,没有“被删掉的数据长什么样”这个信息。所以我的做法是:凡是允许执行TRUNCATE的账号,必须和业务账号分离,且执行前强制走一次备份确认。这个流程听起来繁琐,但它拦住的是那种“本来只想删测试数据,结果连到了生产库”的事故。
5.4 问题速查表
| 现象 | 可能原因 | 快速验证 | 处理方向 |
|---|---|---|---|
| 删除跑得极慢 | WHERE 未命中索引,全表扫 | EXPLAIN看 type | 加索引或改条件写法 |
| 从库延迟持续增长 | 大事务 binlog 无法并行重放 | 看延迟曲线与事务大小 | 改分批,缩小事务 |
| 业务写入大面积超时 | 行锁等待或元数据锁排队 | 看innodb_trx与锁等待 | 停脚本,降批次,错峰 |
| 删除后磁盘未降 | 空间仅标记可复用 | 看data_free字段 | 低峰期重建表 |
| 查询变慢 | 表碎片增多,页密度下降 | 对比删除前后扫描行数 | 重建表或重新统计 |
| TRUNCATE 无响应 | 元数据锁被长事务占用 | 看metadata_locks | 定位并处理长事务 |
| 自增 ID 突然跳变 | TRUNCATE 重置或批量删除后重启 | 看表状态 | 业务侧不要依赖连续性 |
| undo 表空间暴涨 | 大事务 + purge 滞后 | 看 history list length | 拆事务,检查长事务 |
6. 上线前的检查清单和几点个人体会
6.1 一份可以直接抄的检查清单
我把这些年踩过的坑浓缩成一张清单,任何一次生产删除都过一遍,能挡掉绝大多数事故:
- 删除方式是否与数据量匹配:全清用
TRUNCATE,大比例有条件删除用分批,小比例确认走索引; WHERE条件是否命中索引,EXPLAIN的 type 是否为ALL;- 是否已
COUNT确认影响行数,并与业务方核对数量级; - 是否存在外键引用,是否需要处理级联;
- 是否有触发器依赖,
TRUNCATE会不会让它静默失效; - 当前是否有长事务、慢查询占用目标表;
- 磁盘余量是否大于预估增量两倍,binlog 保留期是否覆盖影响窗口;
- 从库延迟阈值是否明确,脚本是否有自动暂停;
- 是否已备份,恢复路径是否验证过(不只是“有备份”,而是“演练过恢复”);
- 执行账号是否为专用账号,权限是否最小化;
- 是否安排在低峰期,是否有回滚或中止方案;
- 执行过程中谁在盯着,异常时找谁。
这张清单里,我认为最容易被跳过、也最该保留的是第九条。很多团队有备份,但从来没验证过备份能不能恢复,真出事的时候才发现备份文件不完整或者恢复流程跑不通。
6.2 我自己的几条经验
第一条,能不动数据就别动数据。清理历史数据这件事,最优雅的方案永远是分区表加DROP PARTITION,其次是归档到冷存储再从主表删。如果业务上只是“不查了”,加个状态位过滤往往比物理删除划算得多——虽然这会把技术债推给查询侧,但至少不会在某个深夜炸掉。
第二条,任何删除脚本都要先在小表上跑通全流程。我会在一个几百万行的影子表上完整跑一遍,看耗时曲线、看延迟变化、看资源占用,然后把批次大小和 sleep 参数照搬到生产。直接在生产上调参,等于拿真实业务做实验。
第三条,把“删除”当成一次发布来管理。有变更单、有预演、有监控、有回滚方案、有明确的结束标志。我见过太多事故是因为“我就删一下,很快的”这种心态——恰恰是这种轻敌,让人忘了检查外键、忘了确认连的是哪个库。
第四条,关于TRUNCATE的权限,我的建议是业务账号一律不给。这不是不信任同事,而是这个命令的破坏力和它的执行速度完全不匹配:一秒执行完,几小时恢复不了。把权限收归到少数几个人手里,加上一条“执行前必须先备份”的硬规矩,成本极低,收益极高。
最后分享一个排查小技巧:当你怀疑某个删除操作影响了性能,但又不确定是哪一条 SQL,去看performance_schema里按行数排序的语句统计,或者直接对比操作前后的Com_delete计数差。定位到具体语句之后,再去看它的执行计划和锁等待,比盲目猜测高效得多。