1. 问题现场:为什么表文件没有变小
先还原一下最常见的踩坑场景。我做过不少次数据库巡检,很多同事第一次遇到这个问题时都是一脸懵:一张核心订单表,数据量大概有 800GB,业务侧说历史数据不需要保留了,于是干脆利落地执行了DELETE FROM orders WHERE create_time < '2023-01-01',删掉了大概 400GB 的数据。删完一看,好,逻辑上数据确实没了,SELECT COUNT(*)结果只剩一半,业务正常跑,磁盘占用却纹丝不动——ls -lh看 ibd 文件,还是 800GB。有同学这时候甚至怀疑是不是删错库了,反复确认 WHERE 条件没问题,数据也确实查不到了,但文件大小一个字都不带变的。
这个现象在 MySQL 的 InnoDB 存储引擎下非常典型,不是 bug,而是设计使然。InnoDB 管理数据的最小物理单位是数据页(Page),默认 16KB。删数据时,InnoDB 做的事情远比我们想象得“保守”得多:它不会直接把物理文件里对应的字节抹掉,而是把记录标记为已删除,在记录的删除标记位上打个标,然后把这条记录从正常的链表里摘除。真正占用的磁盘空间还在原地躺着,等待后续复用。
一句话总结就是:delete 操作是逻辑删除,不是物理删除。表文件的大小几乎不会因为 delete 掉几百万行就立刻缩小,除非你触发了某些特殊机制。这里牵扯出来的,是 InnoDB 的 B+ 树索引结构、数据页管理方式、MVCC 多版本控制、purge 线程回收机制,以及表空间文件的扩展与收缩策略,整整一长串知识点。这篇文章就把这条链路彻底盘一遍,看完你不仅能回答“为什么没减小”,还能清楚知道“到底什么时候会减小”“怎么才能让它减小”“日常维护该怎么看待碎片问题”。
这篇内容适合谁看?刚接手 MySQL 运维、被磁盘告警逼疯的新人 DBA,写业务代码但想搞明白数据库底层行为的后端开发,以及所有被“删了数据空间没释放”这个问题困扰过的朋友。我会从原理讲到实操,把排查手段和解决方案全部摆出来,按我的经验把能避的坑都给你标出来。
2. InnoDB 删数据的完整链路:从标记删除到页内空洞
2.1 数据页到底长什么样
要搞清楚 delete 之后的连锁反应,先得知道数据落在哪、长什么样。InnoDB 的数据是按 B+ 树组织的,表里的每一行记录最终都存在叶子节点上,而叶子节点本身就是一个一个的数据页。一个数据页默认 16KB,内部大致分成三块区域:页头(Page Header)、页体(User Records + Free Space + Page Directory)、页尾(Page Trailer)。
页体里真正存业务数据的是一串单向链表,每条记录都通过 next 指针指向下一条物理位置上相邻的记录。页目录(Page Directory)则是为了加速查找用的,它把页内的记录按组划分,每组选出几条“代表”记录,把它们的地址存到页尾方向的 Slot 里,查找时走二分搜索,不用从头遍历整个链表。
页里还有一个重要的概念叫“空闲空间”(Free Space),它夹在已用记录区和页目录之间,新记录插入时就从这里分配空间。每页的记录并不是排得严丝合缝的,中间可能存在一些已经打上删除标记、却还没被清理的旧记录。这就是空洞的雏形。
2.2 DELETE 的瞬间:标记删除与链表调整
当你执行 DELETE 时,InnoDB 在索引层面做的事情可以分为几步:
第一步,通过 B+ 树定位到目标记录所在的叶子页,再把具体记录找出来。第二步,在这条记录的记录头信息里,有一个专门的“delete_mask”标记位,把它从 0 置为 1,表示这条记录已删除。第三步,调整页内链表的指针。已删除的记录会从正常的用户记录链表中摘除,同时加入一个叫“垃圾链表”(Garbage List,也叫 Free List)的链表里。第四步,更新页头里的相关统计信息,比如已删除记录数、空闲空间大小等。
特别注意:这些操作只发生在内存中的缓冲池(Buffer Pool)里,然后把脏页通过后台线程异步刷到磁盘上的 .ibd 文件。也就是说,哪怕你立刻去磁盘上看文件内容,可能里面的数据都还没真正改写,更别说文件大小了。
所以从这一刻起,页内的布局就是:正常的业务记录还占着一些空间,已删除的记录被打上标记、连进了垃圾链表,页尾的目录 Slot 里那条被删记录所在的组也可能要做相应调整。但是页的总大小不会变,文件的总大小当然也不会变。
2.3 页内空洞是怎么产生的
页内出现空洞,本质上是记录被删除后,原本的位置没有被立即腾出来。没有 delete 操作,页面里的记录紧密排列,空间利用率高。一旦开始高频删除,情况就变成了这样:
假设一个 16KB 的页里原本排了 100 条记录,你删了 50 条,这 50 条记录还占着物理位置,只是被标记为删除。在 InnoDB 眼里,这个页实际能再容纳多少新记录呢?它不会把新插入的记录直接物理覆盖到那些被标记删除的记录上面,而是倾向于从页的空闲空间(Free Space)里分配。只有当 Free Space 不够用,并且垃圾链表里的空间累计到一定程度时,InnoDB 才可能触发页内的空间重整。
这个过程很像我们住的老房子。你把旧家具搬走了,但新家具不是直接放进旧家具原来的位置——如果新家具尺寸和旧的不匹配,硬塞进去反而浪费。于是新家具放在空地,旧位置继续闲置。时间一长,屋里到处都是闲置角落,但你还是觉得空间不够用。表文件就是这样被各种碎片撑大的。
如果删除操作分散发生在多个页里,那几乎所有页都会出现这种小空洞。更麻烦的是,update 操作也会产生类似问题——update 可能先把旧记录标记删除、再插入新记录。MySQL 的 InnoDB 默认采用“先删后插”的更新方式,所以很多你以为只是 update 的表,内部碎片也在慢慢积累。
2.4 为什么 InnoDB 不当下就把空间还给操作系统
不少人会接着问:页里有空洞没关系,但 InnoDB 为什么不干脆把整个文件缩回去?这里就要讲到 InnoDB 对表空间的区(Extent)和页(Page)的管理了。
InnoDB 的表空间文件(.ibd)在物理上按区来扩展,一个区默认 1MB,也就是 64 个连续的页。无论表的实际数据量多大,文件都会被事先划分成很多页,而且 InnoDB 倾向于一次性向操作系统申请更大范围的空间,而不是每次插入一条记录就去申请一点点。这是为了减少系统调用次数,提升写入性能。
当你删除数据时,释放出来的页或者页内的空间,在 InnoDB 内部是被标记为“可复用”的,这些空间会进入表空间的空闲页列表(Free List)。后续如果有新数据插入,InnoDB 优先复用这些已经属于本表空间、但暂时空闲的页。问题在于:InnoDB 的页和区分配机制非常喜欢复用,而不喜欢归还。归还意味着要把文件尾部的区段整体释放,再调用操作系统的 ftruncate 或 fallocate 去收缩文件,这在高并发环境下是个昂贵的操作,而且容易引发性能抖动。
更重要的是,InnoDB 根本无法判断“未来到底还会不会用到这么多空间”。表可能马上又要大量插入数据,如果刚把文件缩小,紧接着又要扩展,那才是真正的性能灾难。所以 InnoDB 的策略很务实:空间复用优先,文件收缩留给管理员手动决定。
3. 卡住空间回收的几道关键锁链
3.1 表空间文件与文件系统的关系
先明确一个认知:InnoDB 的 .ibd 文件在操作系统看来就是一个普通文件。文件当前的大小,代表着它曾经扩展到的最大的“水位线”。只要没有做收缩操作,即使文件内部已经有了大量空闲页,这个水位线也不会下降——就像一条河流涨过水,河岸留下了最高水痕,水退了,水痕却还在原地。
这里有一个重要参数:innodb_file_per_table。在 MySQL 5.6 及之后的版本默认开启,每张表的数据和索引单独存储在自己的 .ibd 文件中,删除数据后,如果有回收机制触发,也只影响这一张表的文件。如果这个参数是关闭的,那么所有表的数据都存放在共享表空间 ibdata1 里,文件回收会更麻烦,VACUUM/OPTIMIZE 对整个共享表空间的效果也大打折扣。
所以如果你看到有人问“我删了数据,共享表空间为什么没变小”,答案的一部分就藏在innodb_file_per_table的配置上。独立表空间是相对可控的,共享表空间基本只能靠导出导入全库来收缩,那工程量就大了。
3.2 MVCC 与 undo log:被删的行暂时还不能物理消失
这是很多人容易忽略的另一个关键原因。InnoDB 要实现多版本并发控制(MVCC),一个事务在读取数据时,需要看到某个时间点的快照。如果有人在你 delete 某行之前开启了一个长事务,还没提交,那么这个事务理论上还能看到这行数据——虽然它已经被打了删除标记。
为了支持这种机制,被删除的记录不能立即从物理文件里抹掉,必须等到所有可能还需要看到它的旧事务都提交之后,才能进行真正的清理。InnoDB 会把被删除行的旧版本信息写入 undo log(回滚日志),同时通过版本链(roll pointer)把各个版本串起来。当其他事务通过一致性读来访问这行数据时,会沿着版本链找到符合自己可见性规则的那个版本。
这里有个恶性循环:如果系统里长期存在大事务、长事务,或者干脆就是有人开启了事务不提交,那么 undo log 会持续膨胀,history list 长度居高不下,purge 线程想清理也清理不动,被删除的记录就一直躺着。这时候你去查SHOW ENGINE INNODB STATUS,会看到 history list length 数值很大,而且 purge 一直追不上。表空间自然就不会有任何缩小的可能,因为连页内的垃圾记录都没法清走。
3.3 purge 线程:真正的空间搬运工
InnoDB 的后台线程里有一个专门的 purge 线程,负责清理那些已经不再被任何事务需要的旧版本数据。它的工作逻辑大概是:定期检查 undo log 里的事务状态,判断某些被标记删除的记录是否已经过了所有活跃事务的可见范围,如果确认安全,就把记录从垃圾链表里真正移除,让页内的空间变为可分配的 Free Space。
值得注意的是,purge 线程只是在页内部做空间整理,它不会主动把空闲页归还给操作系统。它的职责是把“不可用的记录”变成“可复用的空间”,而不是把“可复用的空间”变成“减小的文件”。很多人以为只要 purge 跑完,文件就会变小,这是个重大误解。
这就好比一间仓库里堆了很多废品(已删除记录),purge 线程是清洁工,它负责把废品装进垃圾桶(释放页内空间),但运走垃圾桶、缩小仓库面积的是另一拨人——也就是我们要手动执行的 OPTIMIZE TABLE,或者通过重建表的方式来实现。
3.4 页合并机制:唯一“自动”减少文件的机会
InnoDB 也有会在某些场景下自动收缩文件的机会吗?严格说,有一种情况接近“自动”:当某个页的数据被删得差不多时,如果相邻页的记录总数可以合并到一个页里,InnoDB 的 B+ 树会自动执行页合并(Page Merge)操作。比如页 A 里只剩 10 条记录,页 B 里只剩 20 条记录,两页加起来不到一个页的容量,InnoDB 可能把 B 的记录合并进 A,然后释放 B 这个空闲页。释放出的空闲页会进入表空间的 Free List,后续插入数据时优先复用。
但问题在于:页合并只能减少“页的总数”,如果释放出来的页位于表文件的中间区域,文件还是不能缩小。只有当大量数据集中在文件尾部被删除,InnoDB 才可能在某个时机把尾部的空闲区段(Extent)整体标记为“可释放”,并且通过truncate类操作把文件缩小。这个操作在 InnoDB 里通常不是自动的——即便在页合并触发时,也主要针对叶子节点进行整理,文件整体收缩仍然缺乏自动化机制。
综上,你用力删除数据,InnoDB 内部确实做了很多工作,但从外部看,文件一动不动,非常正常。
4. 怎么检测表文件的碎片与可回收空间
问题聊到这里,原理已经通透。接下来是实操环节:如果你怀疑一张表的文件里有大量碎片,或者想判断它到底能不能收缩、能收缩多少,怎么查?
4.1 用 information_schema 查表空间状态
最直观的入口是information_schema.TABLES表,里面有 DATA_LENGTH、INDEX_LENGTH、DATA_FREE 几个字段。其中 DATA_FREE 代表该表的存储引擎已经分配、但尚未使用的空间字节数。对于 InnoDB 来说,这个值可以粗略表示“可复用但没复用的碎片空间”。
举一个实际查询:
SELECT table_name, ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb, ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb, ROUND(DATA_FREE / 1024 / 1024, 2) AS free_mb, ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb FROM information_schema.TABLES WHERE table_schema = 'your_db' ORDER BY DATA_FREE DESC LIMIT 10;如果 free_mb 相对 total_mb 的比例很高,说明这张表内部有不少空隙。但要注意:DATA_FREE 统计的是“已分配的页中空闲的部分”,它不仅包括 delete 留下的空洞,还可能包括新建表时预先分配但尚未写入数据的页。所以 DATA_FREE 高,不一定意味着你“浪费”了很多空间,但通常可以作为碎片化程度的参考指标。
有一种更直观的判断方法:查看表的实际数据量,然后对比文件物理大小。比如你用SELECT COUNT(*)和AVG_ROW_LENGTH估算出实际数据大约 200MB,但 .ibd 文件已经膨胀到 1GB,那显然有 800MB 左右的空间处于“已分配但未有效使用”的状态。在重建表之前,可以先做这个估算,做到心里有数。
4.2 通过 SHOW TABLE STATUS 快速验证
也可以用SHOW TABLE STATUS命令,输出结果里同样包含 Data_length、Index_length、Data_free 字段。不过需要注意,这些值是估算值,不是精确值,而且对于 InnoDB 来说,它们来自表空间的元数据,精度尚可,但别用它做精确的容量规划,只做趋势判断和横向对比。
我在实际排查中习惯把两种方式结合:先跑 information_schema 的全库扫描,找出 Data_free 异常大的表;再用 SHOW TABLE STATUS 看具体某张表的数据量和碎片情况;最后如果要动手整理,再结合业务窗口决定用哪种方案。
4.3 利用 Performance_Schema 和系统视图进一步定位
如果你用的是 MySQL 5.7 及以上版本,performance_schema里也有不少关于表 I/O、等待事件的数据,可以帮助你判断碎片化是否已经影响到了查询性能。比如同等数据量的表,如果物理读的 avg latency 明显偏高,页内空洞多导致扫描的行数变多,都可能是碎片化严重的信号。
但说实话,对于绝大多数业务而言,不需要把碎片检测搞得太复杂。日常巡检只需关注 Data_free 和文件大小的比例,如果超过 30% 或者文件大小明显超出实际数据量,就可以考虑空间整理了。性能问题反而通常不会单独由碎片引发,更像是碎片加上不合适的索引共同作用的结果。
5. 让表文件真正变小的几种实操方案
既然 delete 不会自动收缩文件,那我们就得自己动手。业界常用的方案大概有五类,各有优劣,我按推荐程度和适用场景逐个说清楚。
5.1 方案一:OPTIMIZE TABLE(最直接,适合中小型表)
OPTIMIZE TABLE 的表面语义是“优化表”,实际上对于 InnoDB 来说,它会重建整张表。执行时,InnoDB 会新建一份临时表,把原表的数据按主键顺序重新插入,插入过程中只保留有效记录,那些已删除的、标记为可复用的空间直接丢弃。临时表构建完成后,在原表的表空间上做一次原子替换,最后删除旧文件。
效果立竿见影:表文件变小,索引更紧凑,碎片小时,DATA_FREE基本归零。但要注意,OPTIMIZE TABLE 在 MySQL 5.7 之前对 InnoDB 是锁表的,5.7 及之后允许 ONLINE DDL 进行 DML 并发操作,但重建过程中仍然会占用大量磁盘空间——临时表需要与原表大小相当的空间。如果你的磁盘可用空间不足,执行 OPTIMIZE TABLE 可能会直接报错,比如ERROR 1114 (HY000): The table '/tmp/#sql...' is full。所以用这个方案前,先 df -h 看看磁盘空余量。
另外,OPTIMIZE TABLE 在数据量大的时候耗时很长。我有一次优化一张 200GB 的表,跑了一个多小时。期间虽然允许读写,但 DDL 本身会对性能产生影响,建议放到业务低峰期执行,最好配合主从切换或者只读副本先行操作。
对于 5.6 及以下版本的用户,OPTIMIZE TABLE 会锁表,生产环境基本没法直接操作,务必评估后再使用。
5.2 方案二:ALTER TABLE ENGINE = InnoDB(重建表的等价手段)
这个命令的本质和 OPTIMIZE TABLE 几乎一样:让 InnoDB 重建表。ALTER TABLE table_name ENGINE = InnoDB;会把表的数据复制到新表空间,然后切换。区别在于,它还能顺便修改其他表选项,比如行格式(ROW_FORMAT)、压缩等。如果你有这种需求,用这个方案可以一举两得。
不过它的时间成本和磁盘需求跟 OPTIMIZE TABLE 一样,没有本质差别。在 MySQL 8.0 里,官方文档甚至建议直接用ALTER TABLE ... ENGINE = InnoDB替代 OPTIMIZE 来做空间回收,因为 OPTIMIZE 底层本质上也是重建。具体用哪个,看你习惯。
5.3 方案三:pt-online-schema-change(工具党首选,适合大表)
如果你面对的是几百 GB 甚至上 TB 的表,OPTIMIZE TABLE 可能从晚间十点跑到第二天早上六点还没跑完,业务影响难以接受。这时候就该请出pt-online-schema-change(缩写 pt-osc)了。它是 Percona Toolkit 里的明星工具,专门用于在线表结构变更。
pt-osc 的原理很有意思:它不会直接改原表,而是创建一张结构相同的新表,然后在原表上建立三个触发器(INSERT、UPDATE、DELETE),把在线发生的增量数据实时同步到新表,同时分批把原表的历史数据拷贝到新表。等数据拷贝得差不多了,通过一个原子性的 RENAME 操作完成新旧切换。
这个方案的最大优势是:业务可以持续读写,对线上影响小。过程可以控制:你可以通过--max-lag参数控制复制延迟,比如要求主从延迟超过 5 秒就暂停拷贝;通过--chunk-size控制每批拷贝的行数;通过--critical-load控制数据库负载过高时自动暂停。
使用 pt-osc 的基本命令示例:
pt-online-schema-change \ --alter "ENGINE=InnoDB" \ D=your_db,t=your_table \ --host=127.0.0.1 \ --user=root \ --password=yourpass \ --max-lag=5 \ --chunk-size=500 \ --critical-load Threads_running=100 \ --execute特别提醒:pt-osc 的--alter参数如果是ENGINE=InnoDB,它只会在重建表时刷新表空间,完成空间回收。如果你需要顺手增加字段、改索引,也可以把需要执行的 DDL 语句写在 --alter 里,一次性完成。
缺点也有:需要额外安装 Percona Toolkit,且在三层主从架构上使用时要小心触发器带来的额外主从复制延迟。另外,如果原表已经没有主键或唯一键,pt-osc 会默认用PRIMARY KEY,没有的话它可能拒绝执行——这是它的安全机制,避免数据重放时无法定位记录。这种表建议先补主键。
5.4 方案四:gh-ost(无触发器在线变更方案)
gh-ost 是 GitHub 开源的在线表变更工具,它的核心思路比 pt-osc 更进一步:不创建触发器,而是通过解析 binlog 的方式,把原表上的增量变更实时应用到影子表。这消除了触发器带来的额外开销,也减少了对主库性能的影响。
使用 gh-ost 回收表空间的思路跟 pt-osc 类似:先创建影子表,全量拷贝数据,追 binlog,最后切换。它的部署和配置稍微复杂,但如果你管理的数据库规模很大,主库负载敏感,gh-ost 往往比 pt-osc 更合适。
gh-ost \ --host=127.0.0.1 \ --user=root \ --password=yourpass \ --database=your_db \ --table=your_table \ --alter="ENGINE=InnoDB" \ --executegh-ost 在执行时要求在 binlog_format=ROW 的模式下运行(它依赖行级 binlog 解析),如果你的库不是 ROW 格式,需要先调整参数并重启,这个成本要提前评估。
5.5 方案五:逻辑导出导入(最笨但最彻底)
如果上面几种方案都因为各种原因不能执行,还有一个兜底办法:把表导出,然后导入到一个新库,再切换。
mysqldump --single-transaction --set-gtid-purged=OFF your_db your_table > table.sql然后把 table.sql 导入到一个全新的库里,确认数据完整后,再通过 RENAME TABLE 或直接在应用层切换连接。这个方案不仅能把表文件缩小到极致,还能顺带整理所有索引,甚至可以做一次从 MyISAM 到 InnoDB 的迁移。
缺点很明显:在数据量大的场景下,导出导入耗时极长,而且为了保持一致性,mysqldump 的 --single-transaction 依赖 MVCC,长事务期间 undo log 会膨胀,可能影响整个实例。所以这个方案更适合数据量中等的表,或者做全库迁移时顺带完成碎片整理。
6. 碎片整理实战:一次完整操作记录与演练
光讲方案不给实战记录,总觉得少了点说服力。这里分享一个我实际做过的操作过程,从排查到优化完的完整链路,你可以直接顺着思路在自己环境里演练一遍。
背景:某业务系统有一张日志表 log_record,平时写入量大,业务定期删除超过 90 天的数据。表文件 45GB,但实际数据只有约 18GB。业务反馈这表查询越来越慢,查看执行计划之后发现全表扫描的频率变高,怀疑碎片太多导致扫描页数过多,同时磁盘空间告警也需要释放空间。
第一步:确认现状。
SELECT ROUND(DATA_LENGTH / 1024 / 1024 / 1024, 2) AS data_gb, ROUND(INDEX_LENGTH / 1024 / 1024 / 1024, 2) AS index_gb, ROUND(DATA_FREE / 1024 / 1024 / 1024, 2) AS free_gb FROM information_schema.TABLES WHERE table_schema = 'app_db' AND table_name = 'log_record';结果:data 大约 14GB,index 4GB,free 占了 27GB 左右。文件 45GB,free 占比夸张到 60%,已经非常需要整理了。
第二步:确认业务窗口和主从状态。
这个表是日志表,凌晨 2 点到 5 点写入量相对较低,可以短暂接受额外负载。同时确认只读副本的延迟在正常范围,方便切流量。因为没有在主库执行 DDL 的强烈需求,最终选定用OPTIMIZE TABLE直接处理,原因是表大小 45GB,虽然不小,但磁盘还有 100GB 可用空间,足够支撑重建过程中的临时文件需求。
第三步:执行优化。
OPTIMIZE TABLE app_db.log_record;执行期间我持续观察了三个指标:
- 主库的线程数有没有明显飙升
- 磁盘空间是否足够
- redo log 的写入量是否异常
整个过程大约花了 26 分钟。优化完成后,再次查询 information_schema:
free_gb 归零,整个表文件从 45GB 降到约 18.5GB,DATA_LENGTH 也有小幅减少,因为紧凑重建后行存储更规整,部分变长字段的存储效率也提升了。业务反馈全表扫描类查询的耗时平均降低了将近 40%。
这个案例印证了一点:对于日志类、流水类的周期性删除表,碎片化是常态,优化一次能管相当一段时间。但如果业务写入删除非常频繁,优化完过几周碎片可能又积累起来了,需要纳入常态化的空间巡检。
7. 日常运维视角:怎样对待表碎片与空间回收
7.1 什么时候必须要整理碎片
不是所有碎片都需要立刻处理。我建议按场景分类对待:
- 磁盘空间告警,需要立刻释放空间,这没有商量余地,能整理立整理。
- 表文件大小和实际数据量差距悬殊(超过 1.5 倍到 2 倍),但磁盘还有余量,可以放在低峰期做一次整理。
- 查询性能明显下降,且能定位到碎片导致扫描大量无用页时,需要整理。
- 表长期只有 delete 和 insert,没有 update 操作(比如流水日志),碎片会以较快的速度积累,可以规划周期性整理。
反过来,如果表数据量不大、碎片不多,就不必费劲去做 OPTIMIZE TABLE。每次重建表都有代价,没必要为了“优化”而优化。
7.2 共享表空间的使用者要特别小心
如果你的实例里还存在使用共享表空间(ibdata1)的表,整理起来要格外谨慎。共享表空间一旦膨胀,基本只能通过导出全库、删除 ibdata1、重新导入来收缩,复杂度极高。所以强烈建议确保innodb_file_per_table=ON,从源头保证每张表的空间独立可管理。这个参数虽然是默认开启的,但仍值得在初始化实例时显式确认,防患未然。
另外注意,即使开启了独立表空间,ibdata1 里还存放着 undo log、数据字典等信息,它本身也可能膨胀。如果你发现 ibdata1 很大,单纯删除业务表数据不会让它缩小。需要结合innodb_undo_tablespaces参数(MySQL 5.7 及以上)把 undo 独立到单独的表空间文件里,从机制上缓解共享表空间的膨胀问题。
7.3 硬链接法:快速释放表文件空间的另类技巧
有一个冷门但实用的技巧值得分享:通过硬链接 + 删除原文件的方式,绕过 OPTIMIZE TABLE 的长时间锁表问题。
思路是这样的:当你确定一张表要做空间回收,但无法忍受在线重建的耗时,可以先用ln命令给 .ibd 文件创建一个硬链接。硬链接可以理解为给文件多起了一个名字,但底层 inode 是同一个,磁盘空间不会重复占用。然后你在数据库里执行DROP TABLE(或先改名再 DROP),数据库会删除它对应的那一个链接,但文件数据仍然存在,因为硬链接还指着它。这时候磁盘空间并没有真正释放,文件还被硬链接占着。
第三步,在业务低峰期把硬链接文件删除(比如用rm命令),磁盘空间会真正归还给操作系统。同时因为 DROP TABLE 操作本身很快,业务不可用窗口很短。这个技巧适合对可用性要求极高、又不方便跑在线 DDL 的场景。
但必须强调:这个方法有一定风险。如果你对硬链接机制不熟悉,误删了唯一的硬链接,数据就真的丢了。实施前一定要做好备份,并且演练一遍。数据安全永远是第一位的。
7.4 用分区表从根源上规避 delete 带来的空间问题
如果你在设计表结构时就有清理历史数据的需求,比频繁 delete 更高明的方式是使用分区表。按时间字段做 RANGE 分区,比如按月分区,清理一个月的数据时直接DROP PARTITION,这个操作是纯元数据级的,速度极快,而且被删除分区占用的空间会立即被 InnoDB 释放,表文件不会像 delete 那样残留空洞。
举例来说:
ALTER TABLE log_record DROP PARTITION p202301;执行完,p202301 分区的所有数据连同空间一起被释放,不需要重建表,也不需要 OPTIMIZE。这是目前处理超大规模历史数据清理的主流方案。当然,分区表也有自己的坑,比如分区数量过多会产生大量文件句柄,查询没有带分区键时会扫描全部分区导致性能下降。用之前要充分测试。
7.5 设置合理的 innodb_page_cleaners 和 purge 参数
如果碎片问题经常出现,还是建议顺便检查一下 InnoDB 的清理相关参数配置。比如 MySQL 5.7 及以上的版本,innodb_purge_threads用于控制 purge 线程数量,适当调大(比如从默认的 1 调到 4)可以加快清理速度,减少 history list 积压。innodb_max_purge_lag可以设置触发 purge 延迟的阈值,防止大量事务并发时影响性能。
不过参数调整要适度,过多线程在某些负载下反而会争抢内部锁。我一般建议先观察 SHOW ENGINE INNODB STATUS 里的 history list length 变化趋势,如果持续增长,再考虑调参。
8. 常见问题排查与避坑指南
根据这些年踩过的坑和群里同行们的常见问题,整理了一份速查表,基本覆盖了 delete 后空间不释放的各种场景和辅助判断:
| 场景 | 现象 | 核心原因 | 解决方向 |
|---|---|---|---|
| 删了一半数据,文件没变小 | 表文件大小不变 | InnoDB 标记删除 + 文件高水位不降 | 重建表(OPTIMIZE / ALTER TABLE ENGINE) |
| 文件变大了,但数据没增多 | 表空间膨胀 | 高频 delete/update 导致页内碎片,文件持续扩展 | 重建表整理碎片,调整写入模式 |
| 文件一直不回收,即使重建了也没效果 | 操作之后大小不变 | 可能有长事务持有 undo,purge 无法推进 | 检查长事务,等待提交或 kill 后重试 |
| 存在历史大事务,删除后空间一直不释放 | history list 持续增长 | MVCC 拦截,旧版本数据无法清除 | 排查并处理长事务,观察 purge 指标 |
| 主从库同步延迟,删除操作同步慢 | 从库文件更大 | 主库删除后脏页刷盘和 binlog 应用有延迟 | 等待追平,或优化大事务拆分删除 |
| 共享表空间 ibdata1 很大 | ibdata1 无法缩小 | 系统表空间文件不支持在线收缩 | 转独立表空间,导出导入迁移 |
| 使用 mysqldump 导出导入后,新库文件更小 | 文件变小且查询更快 | 逻辑导出只包含有效数据,重建了所有索引 | 如果逻辑一致性要求高,这是最稳妥方案 |
8.1 注意:删除数据时也要关注主从延迟
如果你是在主库上执行大范围的 delete,一定要分批或者限速。我见过不止一次因为一条大 delete 导致主从延迟十几分钟、甚至小时级的案例。大事务产生的 binlog 量大,从库应用这些 binlog 时是串行执行的,很容易落后。而且大 delete 本身会持有大量行锁,影响并发写入。更推荐的做法是分批次删除,比如每次删除一万行,配合 sleep 控制节奏。
DELETE FROM big_table WHERE create_time < '2023-01-01' LIMIT 10000;循环执行,直到没有数据可删。这样既能保证主从延迟可控,也能减少 undo log 的暴增,对后续 purge 的压力也小得多。
8.2 注意:重建表期间不要随便 kill 会话
OPTIMIZE TABLE 或者 ALTER TABLE 执行过程中,如果由于误操作(比如磁盘满了)导致 DDL 失败,MySQL 会自动清理临时文件,但有些场景下清理不干净,留下一些后缀为#sql-*.ibd的临时文件,它们也会占用磁盘空间,而且不会自动删除。遇到这种情况,需要手动到数据目录里查找并处理——但前提是确认没有正在运行的 DDL 使用这些文件。
还有一个细节:在 MySQL 8.0 中,ALTER TABLE 支持ALGORITHM=INPLACE, LOCK=NONE,但在底层还是会触发重建。执行前建议用EXPLAIN查看 DDL 的算法选择策略(MySQL 8.0 支持EXPLAIN FORMAT=TREE查看 DDL 计划),提前预判是否会导致锁表或者重建。如果你不太确定,就先在测试环境变更一次,计量时间和空间开销,再上生产。
8.3 注意:监控别只看 table 大小
日常巡检时,除了看表文件的逻辑大小和 DATA_FREE,还要关注文件系统的 inode 使用情况和磁盘块分配情况。如果文件系统在创建大文件时用了稀疏文件特性,ls -lh看到的大小可能和实际占用的磁盘块不一致。这时候可以用du -h 表名.ibd看看真实占用。我遇到过一次奇怪的现象:ls显示 20GB,du显示只有 5GB,这说明文件在文件系统层面是稀疏的,实际块没占那么多。这种情况下空间并不会告急,也不需要特意做碎片整理。
但反过来更要警惕:如果你的表文件已经 800GB,而磁盘分区只有 1TB,那即便你只删了一半数据,也不要立刻乐观,因为 InnoDB 内部仍可能认为需要保留空间。建议删除前和删除后都做一次du和df记录,通过实际数据变化做出判断,而不是只盯着ls的结果。
8.4 注意:binlog 格式和 delete 的空间回收
MySQL 的 binlog 格式也会影响 delete 对空间的影响。在 ROW 格式下,binlog 会记录每一行被删除前的完整镜像,这会让 binlog 文件在删除大批量数据时急剧膨胀。如果你有定期备份和 binlog 清理策略,大量 delete 后要注意 binlog 的磁盘占用,否则可能出现“数据删了,但磁盘反而更满”的情况。
举个例子,一张大表删除一亿行,在 ROW 格式下产生的 binlog 可能比原表还要大,因为每行数据都完整记录。这是运维中很容易被忽视的一个坑。有效的处理方式是:在批量删除前先把 binlog 切一个新文件(FLUSH BINARY LOGS),删除完成后及时清理过期 binlog,避免磁盘被 binlog 撑爆。如果你用的是 STATEMENT 格式,binlog 会小很多,但主从同步的准确性在某些场景下需要更小心。
9. 留给后续的几个扩展思路
空间回收这件事,做到 OPTIMIZE TABLE 基本就够解决日常问题了。但如果你管理的数据库规模很大、删数场景很频繁,有几个方向可以继续深入:
- 冷热数据分离。历史数据定期迁移到归档库或者数据湖,在线库只保留热数据,从源头上减少大表的存在感。
- 使用 MySQL 8.0 的即时 DDL 能力。8.0 对部分 DDL 做了优化,比如
INSTANT ADD COLUMN,虽然它不直接解决空间回收,但可以减少日常表结构变更对重建表的依赖。 - 考虑使用 ClickHouse 等列式存储承载日志类数据。如果你对 MySQL 的碎片清理已经不胜其烦,而业务场景主要是写入大、更新小、分析多,列式存储可能更合适。这不代表 MySQL 不行,而是每个引擎有自己最擅长的领域。
按我个人的经验来说,数据库的很多“小问题”,往深里挖都能通到底层设计哲学。delete 后空间不释放这件事,本质上就是 InnoDB 在性能、MVCC 并发控制和文件管理之间做出的权衡。理解了它为什么这么做,你再遇到类似问题就不会慌,也能在容量规划、表结构设计、清理策略上做出更合理的决策。先把这些基础打牢,后面再遇到更复杂的存储引擎调优,也就有了底气。