分类:数据库内核 · 标签:后端 存储引擎 性能优化 数据库
适用读者:删掉一半数据后发现表文件没变小的人;想知道 B+ 树删除为什么比插入复杂的人;在 InnoDB 与 SQLite 之间做选型、需要理解两者空间回收差异的人
结论先行:B+ 树删除的第一动作永远是「在叶子页里移除一个键」,真正麻烦的是移除之后页快空了怎么办。标准做法只有两种:向兄弟页借几个键(redistribute),或者把两个页合并成一个(merge)。合并会让父页少一个孩子,父页也可能因此下溢,于是这个过程可能一路递归到根。InnoDB 还把「标记删除」和「物理移除」拆成了两步,所以 DELETE 之后空间往往不会立刻回收。
阅读时间:约 16 分钟 | 难度:进阶 | 收获:删除的完整路径、借位与合并的判定、父页分隔键与根收缩、InnoDB 与 SQLite 的两套实现对照、以及碎片观测与回收办法
目录
DELETE 掉一半行,文件大小可能几乎不变。很多人第一反应是「删除没生效」,查一遍 COUNT(*) 又确实少了。
原因有两层:
DELETE 只是给记录打删除标记,旧版本还要留给可能存在的快照读;DELETE 不是立刻从页里抹掉记录,而是分两步:
DELETE 语句的耗时,和「页合并」的耗时不一定是同一时刻发生的。
这带来两个直接后果:
DELETE 之后立刻看表大小,往往没变化;SHOW ENGINE INNODB STATUS 的 History list length 持续上涨,通常意味着 purge 落后,删除的物理回收也被拖住了。
ceil(m/2) 个孩子;ceil(m/2) - 1 个键。m 是 B+ 树的阶。换成更直观的说法:非根页大约要保持半满。
删除一个键后,如果页里的键数低于这个下限,就叫「下溢」(underflow)。下溢本身不是错误,页里仍然能读,只是空间利用率变差;但 B+ 树必须通过重平衡来维持「树高不会因为删除而虚高」的性质。
这里要区分两个概念:
sqlite3BtreeDelete 最终会调用 btreeDeleteRow,删掉单元格后再调用 balance()。
balance() 的核心是 balance_nonroot:
balance_deeper 创建一个新的根;如果根页只剩一个孩子,用 balance_shallower 把子页内容搬回根页并降低树高。
SQLite 的页头里有三个关键计数:
nCell:当前单元格数量;nFree:空闲字节数;nFrag:碎片字节数。nFrag 就是「页内碎片」的直接度量,balance_nonroot 会把它一起整理掉。
| 维度 | InnoDB | SQLite |
|---|---|---|
| 删除时机 | 先标记删除,purge 时物理移除 | 删除语句内直接移除单元格 |
| 重平衡范围 | 通常与一个兄弟页借位或合并 | 最多三个兄弟页一起重排 |
| 合并条件 | 两个页的记录能装进一个页 | 重排后按容量重新分布 |
| 父页更新 | 改分隔键或删一项 | 改分隔键或删一项 |
| 根收缩 | 根只剩一个孩子时提升孩子 | balance_shallower |
| 空间回收 | 依赖 purge + OPTIMIZE TABLE |
依赖 VACUUM |
| 页内碎片 | PAGE_GARBAGE 等计数 |
nFrag 计数 |
OPTIMIZE TABLE 对 InnoDB 来说,本质是重建表:
OPTIMIZE TABLE events;
innodb_fill_factor 控制「建索引时每页填多满」,默认 100。它作用于索引构建(CREATE INDEX、OPTIMIZE TABLE 重建),不作用于运行时的页分裂。把它调低会留下更多空间给后续插入,但索引会变大。
innodb_page_size 决定页大小,默认 16 KB。页越大,单页能装的键越多,树越矮;但页内碎片和单页 IO 的代价也更大。
-- 表的空闲空间(不是页内碎片)
SELECT table_name, data_length, index_length, data_free
FROM information_schema.TABLES
WHERE table_schema = 'your_db';
data_free 是「已分配但未使用的空间」,它不等于「页内碎片」。要区分:
data_free 大:可能有整页空闲,或者表空间没回收;data_free 不大:更可能是页内碎片或页间未合并。innodb_ruby 导出索引页,观察:
PAGE_N_RECS:页内记录数;PAGE_GARBAGE:已删除记录占用的字节;PAGE_FREE:空闲链表;nCell、nFree、nFrag,或者用 PRAGMA page_count、PRAGMA freelist_count 看页数与空闲页数。
DELETE 之后空间会立刻回收」。InnoDB 不会,要等 purge,页合并也不保证发生。
误区二:「页低于半满就一定会合并」。不一定,合并要求两个页能装进一个页;装不下就只能借位。
误区三:「OPTIMIZE TABLE 只是整理碎片,不影响业务」。它要重建表,会占额外磁盘、可能锁表,大表要安排窗口。
误区四:「树高只会在插入时增加」。删除也会改变树高,只是方向相反:根收缩时树高减一。
误区五:「data_free 就是页内碎片」。不是。data_free 是表空间层面的空闲,页内碎片要看 PAGE_GARBAGE 或 SQLite 的 nFrag。
| 问题 | 答案 |
|---|---|
| 删除第一步做什么 | 在叶子页里移除键 |
| 页快空了怎么办 | 借位或合并;合并可能递归到根 |
| 父页要改什么 | 分隔键或孩子项 |
| 树高什么时候降 | 根只剩一个孩子时 |
| InnoDB 为什么空间不立刻回收 | 先标记删除,purge 才物理移除;合并也保守 |
| 怎么真正回收 | OPTIMIZE TABLE 重建;SQLite 用 VACUUM |
专题:数据库内核 — B+ 树、WAL、MVCC 与存储引擎的实现细节