删除了几百万行,空间没还回来:空洞是怎么形成的|洹拓科技

一、删除是四段动作,每段有自己的可观测信号
先把这条链拆开。在这种存储引擎里,删除不是一件事,而是四件事,节拍互不相同。
第一段是标记。删除语句执行时,引擎并不搬走数据,而是在行的开头贴一个“已删除”的标记。关键在范围:聚簇索引要贴,每一个二级索引也要各自贴一遍。也就是说,一张表上挂了几个二级索引,这条删除就要做几份标记工作。删除代价与索引数量近似成正比,这件事在建表期很少被计入成本,等到在大表上做归档删除时才暴露。
第二段是物理清理。贴完标记之后,真正的记录移除与索引项删除由后台的清理线程异步完成,它按回滚段与事务历史列表往后推进。这一段是整条链里最容易滞后的:只要有旧事务没有提交,它还要读到的历史版本就不能被回收;清理线程数量不足,历史列表就会积压,而统计出来的空闲也会跟着失真。
第三段是页复用。腾出来的位置不会消失,它留在原来那一页里等后续插入来填。注意“原来那一页”这个限定条件:空洞只被同页的后续插入复用,插入分布一不均匀,某些页就会长期空着。按主键范围顺序写入、删除的又是历史区段的业务,最容易留下这种填不上的空洞。
第四段才是合并与归还。页要满足两个条件才动:一是空闲占比超过合并阈值,默认值是 50%,对一个 16KB 的页来说就是空闲要超过一半、约 8KB;二是与相邻页合计能够装下一整页。两个条件同时成立才触发合并。而合并只是在表内部腾挪空间,页不会因为变空就归还给操作系统。
| 段位 | 这段在做什么 | 正常时的信号 | 卡住时的信号 |
|---|---|---|---|
| 标记 | 行首贴已删除标记,聚簇索引与每个二级索引各贴一次 | 删除行数与事务历史长度同步上升 | 二级索引偏多,单行删除代价被成倍放大 |
| 清理 | 后台线程按回滚段与历史列表推进,真正腾出空间 | 可分配空间随删除量上升 | 历史列表长度持续增长,长事务存活时间拉得很长 |
| 复用 | 空洞留给同页的后续插入 | 空闲比冲高之后缓慢回落 | 顺序写入时段落在冷区,空闲比不回落 |
| 合并与归还 | 达阈值且能与邻页凑出整页才合并,不归还操作系统 | 少量页被合并,分布右尾变薄 | 大量页卡在空闲四成到近五成的区间不动 |
二、表文件没变小是正常态,该紧张的是另外三条线
公开材料里记录过一次实测:删除三百余万行之后,可分配空间的统计涨了将近 20GB,而表文件长度没有变化。这个数字只用来佐证机制,不构成我们的判断依据,真正能作为依据的是下面这几条线。
刚删完就去看文件大小,看到的大概率还是原样,这是设计使然,不是故障。该紧张的只有三种情形:一是连续多日空闲比在上升,而这段时间几乎没有插入,说明空洞既没被复用也没被合并,是净增长;二是单表空闲比超过自己设定的阈值,巡检口径里给的告警线是 30%;三是写入放大明显上升,同一批数据要写更多的页、耗时变长,说明碎片已经开始影响路径而不是只影响统计。
反过来,两条常见现象不该紧张:删除后统计上涨而文件不变,这是标记与清理的节奏差;空闲比在删除后冲高又慢慢回落,这是同页复用在工作。把这两种当成故障去重启、去重建,是白付一次风险。
三、巡检只看四个指标,重点是别看平均值
第一个是空闲比。算法很直白:取表信息视图里的“可分配空间”,除以“数据长度”加上“可分配空间”,按表算,按表大小排序,超过 30% 告警。排序这一步不能省,因为百分比要乘上表大小才是真正浪费的字节。
第二个是清理滞后。看事务历史列表的长度或者延迟量,它决定上面那个指标是不是可信。历史列表在涨,空闲统计就虚低,此时做扩容决策会把尚未回收的空间当成不存在。
第三个是长事务存活时长的分布,要分布而不是最大值,看的是中位、九分位与最长一档分别落在多少分钟,用来定位是某个任务的连接没回收,还是整个事务边界画得太宽。
第四个是逐页空闲率的分布直方图,而不是这张表的平均空闲率。平均值一定会骗人:如果一半页有九成空闲、另一半页是满的,平均下来四成五,看起来“整体挺空”,而那批恰好落在四成五附近的页,正是永远凑不齐合并条件的部分。换个方向说,平均五成既可能是所有页都卡在四成九,也可能是所有页都刚好过线,前者一次合并都不会发生,后者一动就能合。分布的形状决定能不能回收,平均值把形状抹平了。建议按一成宽一档分桶,专门盯四成到四成九这一档的占比,它是“删了但收不回来”最集中的地方。
四、合并阈值不是越高越好,按写入形态选
阈值调高,合并动作变少,空间浪费变多;阈值调低,合并更积极,代价是页之间反复搬动带来的写放大。所以它不是一个性能开关,而是一个空间与写放大之间的分配器,要按业务写入形态来选。
- 顺序写入加历史区段批量删除的归档类业务:空洞本来就填不上,合并几乎不会自然发生,阈值应当调激进,或者直接走重建。
- 随机更新、页内空闲会被新插入自然复用的业务:保持默认的 50%,让复用先工作。
- 高频等值更新、热点页集中的业务:把阈值调低等于把合并压力做在热路径上,写放大直接反映到响应时间,这一类应保持默认甚至更保守。
- 磁盘余量已经紧张、或者按存储容量计费的业务:优先调低阈值,明确接受写放大。
改完之后只验证三件事就够了:单位时间内的页写量、四成到四成九这一档的页占比、这张表的平均响应时间。三样里有两样明显恶化就回退,不要拿“看起来更空了”当结论。
五、回收只有两条路,动手前先把三项准备做完
第一条路是直接重建类操作。它能把空洞真正压掉,代价是三样:需要额外空间容纳重建出来的新对象,全程存在锁与等待,耗时与表大小直接相关。大表上这条路等于一次不可中断的长事务。
第二条路是在线重建类操作。它把锁的冲击降下来,但把风险挪到了别处:过程中产生的增量要在副本上回放,主从延迟会被拉大,回滚段压力上升,中途失败还要考虑怎么退回去。对有读副本或者对外承诺延迟的业务,这条路要先和副本一起评估。
无论走哪条,三项准备要提前做完。一是空间余量核算,把表文件大小、重建期间的临时占用与日志增量分开列,至少按一份表文件的大小起步核,核不出来就不该开始。二是时间窗与回退点,写清楚在哪个阶段可以中止、中止之后系统处于什么状态、由谁拍板停止。三是备份与一致性校验,备份要验证过可用,事后要比对行数与关键校验字段,而不是只看操作有没有报错。
六、三条治理纪律,以及容量规划的两套口径
治本的部分只有三条。第一,监控并限制长事务的存活时长:应用侧设连接与事务超时,事务只包住必要的那几次写,绝不在事务里做远程调用。第二,按实例负载确定清理线程数量,而不是看别人设多少就设多少,调大了会和业务并发抢资源,要和历史列表长度一起看。第三,把大批删除改成小批多次,每批之间留出间隔并观察历史长度是否回落,让清理线程跟得上标记的速度。这三条比任何回收操作都便宜。
最后一条给做容量规划的人。删除之后腾出的空间不会自动变成磁盘余量,所以采购和告警必须按两套口径分别设阈值:一套是表文件在磁盘上的实际大小,采购、告警、扩容排期按它;另一套是内部空闲比,决定什么时候该重写、该归档、该找业务方砍索引。只看前者,你会在空闲比已经很难看的时候还以为磁盘很健康,错过成本最低的处置窗口;只看后者,你会算出一个磁盘上并不存在的余量。洹拓这边做交付评审时,这两套数字我们要求并列出现在同一页巡检报告上,缺一个就不算完成核对,这也是我们把上面四个指标做成脚本而不是靠人记的原因。