
上周排查一个线上慢查询时发现同事给两千万行的表加了索引主键用的还是 UUID 字符串页碎片率已经高到吓人的程度。翻着索引统计信息我意识到一个事实天天写 SQL 的人很多真正见过 B-Tree 这种数据结构长什么样的人却很少。MySQL 的 InnoDB、PostgreSQL 的默认堆表索引、MongoDB 的 WiredTiger、就连 ext4 文件系统的目录索引底层全是 B-Tree 或它的变体。这篇就从这个角度切入把它拆开聊透为什么存储系统集体倒向 B-Tree节点内部到底长什么样分裂和合并是怎么发生的以及我们做表设计和索引时哪些常识其实都源于这颗树的结构。后端开发、DBA、以及准备面试的工程师都适合把这篇当一份 B-Tree 数据存储的实战笔记来读。1. 为什么数据库存储公认 B-Tree一次磁盘 IO 的代价有多贵1.1 磁盘的最小访问单位是页不是字节写业务代码的时候我们习惯了内存里按字节寻址读一个 int 和读一个数组开销差不太多。但数据一旦落到磁盘上局面完全不同。磁盘读写的最小物理单位是扇区传统机械盘是 512 字节现代盘普遍 4KB而文件系统往上是页page数据库里更是一页一页地管理。你就算只想要 8 个字节的 key也必须把整个页从磁盘搬进内存。这里的核心代价在于一次磁盘随机读的开销大头在寻道和旋转延迟而不是数据传输本身。7200 转机械盘的单次随机 IO 大概要 7 到 10 毫秒SSD 好一些但也要几十到上百微秒这跟内存几十纳秒的访问延迟一比差了三到五个数量级。换句话说存储系统优化的头等大事不是减少比较次数而是减少磁盘 IO 次数。1.2 二叉搜索树在磁盘场景下的溃败很多人第一次学数据结构时都背过二叉搜索树的查找复杂度是 O(log₂n)。这个结论在内存里成立但放到磁盘上就非常尴尬。假设一亿条记录用二叉搜索树存储树高大约是 ⌈log₂(1e8)⌉ ≈ 27 层。每个节点的数据可能散布在不同磁盘页里访问一次就要一次随机 IO。按机械盘单次 8 毫秒算查到叶子节点最坏要 216 毫秒。这个数字在 OLTP 场景下完全不可接受更别提如果索引节点本身没被缓存整个查询就是一场磁盘灾难。红黑树这类自平衡二叉树的树高虽然稳定但也只是把 27 层压缩到 2 倍的 log n 级别本质没有改变层数过多导致 IO 过多的问题。二叉结构在内存里是优雅的在磁盘上却是灾难现场。1.3 用计算换 IOB-Tree 的基本策略B-Tree 的思路很直接既然一次磁盘 IO 能搬回一整页数据那我干脆让每个节点都跟一个磁盘页对齐一个节点里放几百上千个 key。这样每个节点就是一个大块头一次 IO 就把大量 key 捞进内存然后在内存里做二分查找或者顺序扫描比较开销几乎可以忽略。于是核心问题从怎么减少树的高度变成了怎么增加每个节点的分叉数。B-Tree 里这个分叉数用阶数 m 表示。每个内部节点最多有 m 个孩子、m-1 个 key。m 越大树越矮需要的磁盘 IO 就越少。一亿条数据如果用 m1000 的 B-Tree树高只有 ⌈log₁₀₀₀(1e8)⌉ ≈ 3 层。根节点常驻内存后查询一条数据真正落盘的 IO 往往只有 1 到 2 次。这个思想翻译成人话就是用 CPU 的廉价比较去换磁盘的昂贵寻道。B-Tree 能够统治数据存储底层不是因为它在任何维度上都最强而是因为它精准命中了磁盘 IO 贵、内存容量有限、数据规模巨大这个现实约束。2. B-Tree 的物理结构节点、阶数与那棵只有三层的巨树2.1 一个 B-Tree 节点里到底存了什么教科书上的定义经常把人绕晕我只说工程实现的视角。一个节点本质上就是一个磁盘页大小的内存块里面按顺序存放若干 key以及指向子节点的指针。如果是内部节点结构大致是指针0、key0、指针1、key1、指针2…… 其中每个指针指向一个子节点key 用来分隔左右子树。假设节点的 key 数量为 k那么它的孩子数量就是 k1。所有孩子节点的 key 范围被这 k 个 key 瓜分成 k1 个区间。叶子节点略微不同它没有子节点指针但会存放对应的行数据地址或者像 InnoDB 的聚簇索引那样直接存放整行记录。无论叶子还是内部节点都必须满足两个硬性条件每个节点最多存 m-1 个 key。除根节点外每个节点至少存 ⌈m/2⌉-1 个 key。第二个条件非常关键它保证了 B-Tree 不会被某些极端插入顺序饿瘦。只要每个节点始终维持半满以上树的宽度就不会塌缩高度也就不可能失控。2.2 阶数 m 的取值逻辑与节点上限阶数 m 不是拍脑袋定的它由磁盘页大小和单个 key 的大小共同决定。最基本的约束是一个节点必须能完整塞进一个磁盘页否则一次 IO 根本读不完。假设页大小是 16KBkey 取 8 字节的 bigint子节点指针也按 8 字节算那么:单个槽位key 指针大约 16 字节一个节点最多容纳 16384 / 16 ≈ 1024 个槽位也就是 m ≈ 1025树高立刻压得很低但实际工程里还有节点头、页校验、空闲空间预留等开销真实的分支数通常在几百到一千之间。这里有个值得注意的取舍m 并不是越大越好。m 大了树矮了 IO 少了但单个节点内的 key 搜索会在内存里多消耗一点时间。不过因为节点数据常驻内存页缓存这个开销微乎其微。存储引擎普遍愿意把 m 往大了调换取更低的树高。2.3 树高计算与三层索引的由来树高的公式不复杂如果一棵 B-Tree 有 N 个 key阶数 m那么树高 h 满足最小树高h ≥ log_m(N1)最大树高h ≤ log_⌈m/2⌉((N1)/2) 1做工程不需要记这些公式记住一个经验数字就够几百万到上亿行数据在典型的 16KB 页、bigint 主键的配置下BTree 只有 2 到 4 层。InnoDB 里二级索引和聚簇索引通常就是 2 到 3 层这就是索引能覆盖查询、避免回表这类话术背后的物理基础——因为树足够矮顺着索引找几次指针就可以定位到目标数据。我第一次亲眼验证这个结论是在一个一亿行的表上主键索引的根节点永远在缓存里第一层节点也基本在缓存里真正落盘读取的只有最靠近叶子的一层。一次精确查询就是 1 到 2 次 IO这才是 B-Tree 系索引能在海量数据下维持毫秒级延迟的真正原因。3. 插入、分裂、删除与合并B-Tree 的自平衡机制3.1 查找顺着节点内部的 key 走查找过程是所有操作的基础。从根节点开始在节点内部找到第一个大于等于目标 key 的位置决定是命中还是落入哪个子节点然后继续向下。因为每个节点内的 key 是有序的可以用二分查找快速定位。步骤跟二叉树差不多区别只在每层要做 k 次比较而不是 1 次但节点数据已经在内存里了比较开销可以忽略。值得注意的是B-Tree 的查找最终一定落在叶子层但内部节点只要能命中也会返回。BTree 则不同所有数据都在叶子内部节点纯粹用来路由。这个差异让 BTree 的查询路径更稳定也让范围扫描变得简单后面会专门说。3.2 插入节点满了就裂开并向上提升插入是 B-Tree 最核心也最容易写错的逻辑。流程是这样的从根开始按查找路径走到叶子节点。如果叶子节点还有空位直接把 key 按顺序插入。如果叶子节点已经满了有 m-1 个 key就必须分裂。分裂的具体做法是把节点里现有的 key 加上新 key一共 m 个 key取中间位置的 key 提升到父节点左右两半分别形成两个新节点。父节点因此多了一个 key 和一个孩子指针如果父节点也因此满了就继续向上分裂。如果一路裂到根节点那么根就产生一个新根树高加 1。这里有个经常被误解的地方B-Tree 在插入时才长高但它的长高是从根部分出来的根部向上长而不是像链表那样底部向下长。所有叶子始终保持在同一个深度这保证了所有查询的 IO 次数大致相等是一种非常公平的结构。3.3 删除兄弟节点借位与节点合并删除比插入更繁琐因为要时刻维护节点至少半满的约束。简单情况是叶子节点删掉一个 key 后仍然满足最小值那直接删即可。但删完低于下限时得先看左右兄弟如果兄弟节点有富余的 key就向兄弟借一个同时调整父节点里的分隔 key这叫旋转borrow。如果兄弟也刚好是半满状态就把两个节点和中间的分隔 key 合并成一个节点父节点少一个 key 少一个孩子然后递归检查父节点是否又低于下限。借位操作里最反直觉的是从兄弟借数据时真正的 key 移动发生在父节点与兄弟节点之间而不是直接两个兄弟之间交换。父节点里的分隔 key 会被拉下来放到当前节点兄弟节点的最大或最小key 会被顶上去成为新的分隔 key。这个细节在日常写 B-Tree 代码时最容易出错。3.4 一个完整的 m5 插入示例用一个小例子看分裂过程。设 m5所以每个节点最多 4 个 key。依次插入 1、3、5、7、9。前 4 个 key 1、3、5、7 都塞进根节点根节点变满根节点[1, 3, 5, 7]插入 9 时根节点满了开始分裂。把 1、3、5、7、9 排好序取中间 key 5 提升为新的根左半边 [1, 3] 和右半边 [7, 9] 成为两个子节点根[5] / \ [1,3] [7,9]再插入 2 和 4根[5] / \ [1,2,3,4] [7,9]插入 6 时右子树节点 [7,9] 还没满直接插入变成 [6,7,9]。但这时候如果继续插入 8左子树也满了右子树也满了就得先分裂右子树把 7 提升到根根[5,7] / | \ [1,2,3,4] [6] [8,9]细看会发现根节点从 [5] 变成 [5,7]树的层级没有增加但每个节点的 key 数量都被控制在合理范围内。如果接下来大量插入根节点再次分裂时树才会往上涨一层。整个过程维护了所有叶子在同一层这个关键不变量。4. 存储引擎为什么普遍用 BTree 而不是 B-Tree4.1 BTree 与 B-Tree 的结构差异严格说起来现代关系型数据库用的绝大多数是 BTree而不是教科书上那个经典 B-Tree。两者最关键的区别有两点BTree 的内部节点只存 key 和子节点指针不存实际数据所有行的指针或整行数据全部放在叶子节点。BTree 的叶子节点通过链表通常是双向链表串联起来按 key 顺序排列。正因为内部节点不存数据同样一个 16KB 的页能容纳更多 key分支数 m 更大树高更低。对于一个大表经典 B-Tree 可能要 4 到 5 层BTree 往往能做到 2 到 3 层IO 次数直接减少。4.2 范围查询的天然优势BTree 选它当存储索引的王道技术还有一个重头戏范围查询。B-Tree 的数据分散在各个层级的节点里想查所有大于 100 且小于 200 的 key你得先按查找路径找到第一个符合条件的 key然后中序遍历整棵树过程中要反复回溯父节点、来回跳转节点。这样做不是不行但每次回溯都可能触发新的磁盘 IO性能很不稳定。BTree 就简单多了。先沿内部节点沉到叶子层找到下界位置然后顺着叶子链表的指针往后扫直到上界为止。整个过程除了最初的下沉需要几次 IO后面的扫描完全靠叶子节点的线性遍历完成预读友好性能非常稳定。像 MySQL 这种 OLTP 引擎SELECT ... WHERE id BETWEEN 100 AND 200能走索引又快又稳靠的就是这条叶子链表。4.3 聚簇索引与二级索引的落盘方式InnoDB 是 BTree 的一个典型实践。它的主键索引叫聚簇索引叶子节点直接保存整行记录。也就是说表本身的数据就是按主键 BTree 组织的不需要额外维护一份索引到行号的映射。二级索引则不同它的叶子节点存的是主键值而不是物理行地址。这样设计有个很实际的好处当数据页因为分裂、合并、碎片整理而移动时二级索引不需要跟着改因为主键值不会变。但代价是所有二级索引的查询都存在一次回表先在二级索引 BTree 里找到主键再回到聚簇索引里取完整行。PostgreSQL 的默认索引也是 BTree但它走的是另一种路线索引叶子存的是行号ctid数据本身在独立的堆表里。这两种方案各有取舍InnoDB 的回表路径简单但二级索引体积大PostgreSQL 的索引体积小但行移动时要更新索引。从 B-Tree 的视角看这些存储引擎的差异其实只是叶子节点到底放了什么这个问题的不同答案。结构骨架是同一套只是挂的数据不同。5. 实战调优与踩坑记录B-Tree 数据存储的工程细节5.1 页大小设置与 IO 对齐B-Tree 的节点必须与磁盘页对齐这是最基础也最容易被忽略的设计约束。InnoDB 默认页大小是 16KB也支持 8KB 和 4KB。页设得越大单个节点能容纳的 key 越多树高越低范围扫描的 IO 效率越高但代价是缓存池里能缓存的页数变少内存命中率可能下降而且写放大更严重——一次小更新也可能重写整个页。对于机械盘环境页大小最好对齐底层扇区或文件系统块大小避免一次 IO 跨两个块。SSD 则更看重 4KB 对齐。我们在实际压测中遇到过诡异现象同样一条批量导入某些机器慢 30%最后查下来是分区没有按 4KB 对齐导致的跨块读写放大而不是 B-Tree 本身的问题。5.2 主键选择如何影响写入性能B-Tree 是排好序的结构所以数据写入的物理顺序决定了它要不要反复做页分裂。这里有个几乎所有 DBA 都会强调的结论自增整数主键是最适合 B-Tree 聚簇索引的写入模式。原因很简单自增主键永远插在 BTree 的最右侧新记录总是追加到当前最右边的叶子节点只有这个节点写满了才会分裂出一个新页旧页很少被碰到。而 UUID 随机主键的插入位置完全随机会频繁触发不同叶子节点的分裂导致页分裂、数据页碎片化、索引膨胀写放大明显增加。实测中一张使用 UUID 主键的表索引体积可能是自增主键表的 1.5 到 2 倍写入吞吐掉 30% 以上。如果业务确实需要全局唯一 ID建议用雪花算法这类趋势递增方案或者用 UUID 存储时去掉横杠、按序排列尽量减少随机性。5.3 页分裂、碎片化与重建索引页分裂不只影响插入性能还会造成长期碎片。一个节点分裂成两个不足半满的节点后如果后续删除又把数据挪走可能出现大量空洞页。这些空洞不会自动回收导致索引实际占用的空间远大于逻辑数据量范围扫描要读取很多空页IO 白白增加。处理这个问题的常规操作是定期重建索引或使用在线整理工具。InnoDB 里ALTER TABLE ... ENGINEInnoDB或OPTIMIZE TABLE可以重建聚簇索引把叶子节点重新压实消除碎片。但要注意这操作会产生大量 IO最好在业务低峰期执行。另一个相关经验删除大量数据后一定要关注索引统计信息和物理文件大小。我碰到过最典型的情况是删掉 60% 的数据后.ibd 文件大小纹丝不动因为 InnoDB 不会顺便归还空间给操作系统但索引的可利用空间比例变大。这时候读性能未必下降但如果你盯着磁盘占用做容量规划很容易被误导。5.4 什么场景下 B-Tree 并不是最优解讲实话把 B-Tree 神话成终极答案也是个常见误区。B-Tree 查询稳定、读性能好但写放大是短板每次更新都要就地修改数据页哪怕只改一行数据也可能要整页写回。在机械盘时代这不算致命但在闪存时代写放大意味着 SSD 寿命消耗加快。所以现在大批面向写密集场景的存储系统选了 LSM-Tree比如 LevelDB、RocksDB、Cassandra。LSM-Tree 把所有写入先缓存到内存的 MemTable 里积累到一定大小再批量刷盘用牺牲一点读性能的方式换取了写性能的大幅提升。它的读路径需要查多级 SSTable最坏情况比 B-Tree 多几倍 IO所以需要在读放大和写放大之间做权衡。从选型角度看读多写少、数据量可控、需要强一致性和范围查询优先 BTree写多读少、吞吐优先、可以容忍读路径稍慢LSM-Tree 更适合。没有银弹只有适不适合。5.5 用 B-Tree 的视角做索引设计最后说点我对索引设计的真实体会。很多性能问题其实不用看执行计划就能猜到七八分关键就是站在 B-Tree 的角度想两个问题这条查询每次访问大概要几次 IO这张表的插入会让 B-Tree 频繁分裂吗联合索引的命中规则最左前缀、覆盖索引优化、回表问题本质上都是 B-Tree 结构的推论。理解了节点和页的对应关系你就能明白为什么索引列上做函数运算会失效——因为 B-Tree 的排序是基于原始列值的你把它包进函数里树的顺序就用不上了也就能理解为什么覆盖索引能省一次回表——因为叶子节点里已经包含你要查的列树走到叶子就可以返回不需要再跳回聚簇索引。我在实际工作里很少背那些索引优化口诀但每次设计索引都会先在脑子里画一棵三层左右的 BTree根节点怎么选路内部节点怎么往下走叶子节点够不够支撑这次查询。画完之后很多答案是自然浮现的。这比套用模板更有用也是理解 B-Tree 数据存储给工程师最大的回报——你看到的不是一张索引表而是一棵有物理边界的树。