红黑树、AVL 树、B 树、B+ 树简介

如果面试官比较认真,常常会让你现场推导插入/删除。

可能的形式

  • 给你一组数字,手动画出红黑树插入过程
  • 插入某个节点后,哪些情况需要变色?哪些情况需要旋转?
  • 删除红黑树某个黑节点后怎么修复?
  • 画一个跳表,插入 8、12、15、19 后长什么样
  • 给一个 B+ 树阶数,让你模拟插入导致分裂
  • 删除 B+ 树某个 key,看看是否触发借位和合并

3. 红黑树 vs AVL 树

红黑树(Red-Black Tree)是一种自平衡的二叉搜索树,它在插入和删除操作后能够通过旋转和重新着色来保持树的平衡。

红黑树的特点如下:(根叶黑;不红红;黑路同;)

  1. 根节点和叶子节点(NULL节点)都是黑色的。
  2. 每个节点都有一个颜色,红色或黑色。
  3. 如果一个节点是红色的,则它的两个子节点都是黑色的。
  4. 从根节点到叶子节点或空子节点的每条路径上,黑色节点的数量是相同的。

红黑树通过这些特性来保持树的平衡,确保最长路径不超过最短路径的两倍,从而保证了在最坏情况下的搜索、插入和删除操作的时间复杂度都为 O(logN)。

image-20240725233526166

查询性能的对比:

  • AVL 树:AVL 树是严格的平衡二叉搜索树,它要求每个节点的左右子树的高度差(平衡因子)不超过 1。这种严格的平衡特性使得 AVL 树的高度始终保持在 O(log n),其中 n 是树中节点的数量。在进行查询操作时,由于树的高度相对较低且较为均匀,所以查找任意节点的时间复杂度稳定为 O(log n)。这意味着在理想情况下,AVL 树的查询效率非常高,能快速定位到目标节点。
  • 红黑树:红黑树是一种弱平衡的二叉搜索树,它通过颜色标记和特定的规则(如每个节点要么是红色,要么是黑色;根节点是黑色;每个叶子节点(NULL 节点,空节点)是黑色;如果一个节点是红色的,则它的两个子节点都是黑色的;对每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数目的黑色节点来维持大致的平衡。红黑树的高度通常比 AVL 树略高,其高度上限为 2log(n + 1),因此查询操作的时间复杂度同样为 (log n),但在实际应用中,由于树的高度相对较高,其查询性能可能会略逊于 AVL 树。

在查询性能上,AVL 树由于其严格的平衡特性,表现会稍好于红黑树,但差距通常不大。

插入性能的对比:

  • AVL 树:在插入新节点后,AVL 树可能会破坏原有的平衡结构,需要通过旋转操作(单旋转或双旋转)来重新平衡树。由于 AVL 树对平衡的要求非常严格,插入操作后可能需要进行多次旋转来恢复平衡,特别是在树的高度较高时,插入操作可能会引发较多的旋转操作,导致插入性能受到一定影响。插入操作的平均时间复杂度虽然也是 O(log n),但由于旋转操作的开销,实际插入效率相对较低。
  • 红黑树:红黑树在插入新节点后,同样可能会破坏树的平衡,但它只需要进行少量的颜色调整和最多两次旋转操作就能恢复平衡。红黑树的平衡规则相对宽松,使得在插入操作时不需要像 AVL 树那样频繁地进行旋转操作,因此插入性能相对较好。插入操作的平均时间复杂度同样为 O(log n),但由于减少了旋转操作的次数,实际插入效率更高。

在插入性能上,红黑树由于其弱平衡特性,表现优于 AVL 树。

在实际应用中,如果查询操作频繁,对查询性能要求较高,且插入和删除操作相对较少,可以选择 AVL 树;如果插入和删除操作较为频繁,对插入性能有较高要求,同时查询性能也能接受一定的损耗,则红黑树是更好的选择。

3.1 为什么红黑树能保证近似平衡?

因为红黑树通过:

  • 不允许连续红节点
  • 任意路径黑高相同

限制了树的不平衡程度。

直观理解:

  • 每条路径上的黑节点数一样;
  • 红节点最多只能夹在黑节点之间,不能连续出现;

所以最长路径长度不会超过最短路径长度的 2 倍,因此它不是严格平衡,但能保持近似平衡

3.2 红黑树的高度为什么是 O(log n)

红黑树的高度能严格保证在 \(O(\log n)\),根本原因在于它的五条基本性质相互制约,特别是其中两条性质的结合,直接从结构上限制了树的极度不平衡。

我们可以通过一个相对直观的数学推导来理解这背后的逻辑。

决定高度的两条核心性质

  • 无连续红节点: 如果一个节点是红色的,那么它的两个子节点必须是黑色的。这意味着在从根节点到叶子节点的任何一条路径上,绝对不可能出现两个相邻的红色节点。
  • 黑高平衡: 从任意节点到其各个叶子节点的所有简单路径上,包含的黑色节点数量必须完全相同。这个固定的黑色节点数量被称为该节点的黑高,记为 \(B=bh(x)\)

数学推导过程

  • 假设有一棵包含 \(n\) 个内部节点的红黑树,根节点的黑高为 \(B\),整棵树的实际高度(即最长路径长度)为 \(h\)

  • 步骤一:实际高度 \(h\) 与黑高 \(B\) 的关系

    由于性质 4 的限制(不能有连续的红节点),在最极端的“拉长”情况下,一条路径上的节点颜色只能是红黑交替的(黑-红-黑-红...)。

    这意味着,任何一条路径上的红色节点数量,最多等于黑色节点的数量。因此,树的最大总高度 \(h\) 最多是黑高 \(B\) 的两倍:\(h \le 2B\)

  • 步骤二:节点总数 \(n\) 与黑高 \(B\) 的关系

    通过数学归纳法可以严格证明一个引理:以任意节点 \(x\) 为根的子树,至少包含 \(2^B - 1\) 个内部节点

    应用到整棵树的根节点上,也就是节点总数 \(n\) 的下界为:\(n \ge 2^B - 1\)

  • 步骤三:推导最终结论

    现在我们将上面两个不等式结合起来。

    \(h \le 2B\) 可以得出 \(B \ge h/2\)

    将其代入第二个公式:\(n \ge 2^{h/2} - 1\)

    不等式两边同时加 1,并以 2 为底取对数:\(\log_2(n+1) \ge h/2\)

    最终得到:\(h \le 2\log_2(n+1)\)

通过最终的不等式 \(h \le 2\log_2(n+1)\) 可以清晰地看到,无论红黑树怎么变动,它的高度 \(h\) 永远被限制在节点数 \(n\) 的对数级别范围内。这就是为什么它的高度界限被证明为 \(O(\log n)\)

这种巧妙的规则设计使得红黑树在最坏情况下的查找、插入和删除操作都能保持 \(O(\log n)\) 的时间复杂度。它不像 AVL 树那样要求极度严苛的绝对平衡(AVL 树高度约为 \(1.44 \log_2 n\)),因此在进行插入和删除时引发的结构调整(旋转)更少,整体性能更加均衡。

3.3 红黑树插入和删除操作

插入操作

红黑树的插入处理可以概括为两个核心阶段:标准二叉搜索树插入插入后的颜色与结构修复

为了将对树平衡的破坏降到最低,红黑树采用了一个极为聪明的核心策略:新插入的节点默认总是红色。这是因为插入红色节点最多只会引发“连续红节点”的冲突,而如果插入黑色节点,则会立刻破坏“所有路径黑高相同”的绝对铁律,后者的修复难度极大。

插入红色新节点后,我们需要根据其父节点和叔叔节点(父节点的兄弟节点)的状态进行修复。

以下是插入修复的完整分类处理逻辑:

(1)初始简单情况

  • 情景 A:树是空的。 新节点成为根节点。根据红黑树规则,直接将它染成黑色即可。
  • 情景 B:父节点是黑色。 插入红色子节点没有违反任何红黑树性质,不需要任何额外修复,直接结束。

(2)复杂情况:父节点是红色

  • 不存在 uncle 节点
  • 存在 uncle 节点

删除操作

红黑树的删除比插入复杂很多,整体分 3 步:

  1. 找到删除节点
  2. 找到一个替换节点接替删除节点的位置
    • 删除节点有两个非空子节点:在这种情况下,通常会找到该节点的后继或前驱(中序遍历下的下一个节点或上一个节点),这将是该节点右子树中的最小值(或左子树的最大值)。然后,将后继或前驱的值复制到要删除的节点中,并删除后继或前驱节点。因为后继节点(或前驱节点)至多只有一个非空子节点,所以这步骤将问题简化为删除有一个或没有子节点的节点。
    • 删除节点最多有一个非空子节点:任意找到一个空子节点,然后将另一个子节点(可能也是空的, 也可能是非空), 替代当前节点。
  3. 修复红黑树高度

按照二叉搜索树删除节点的方式,找到要删除的节点,假设要被删除的节点是 z。那么根据 z 的节点颜色可以分出下面两种情况

  1. z 是红色节点,删除红色节点是比较容易的,因为不会打破红黑树的性质,就是正常的二叉搜索树删除节点的操作,这里不展开
  2. z 是黑色节点,删除黑色节点可能打破红黑树的性质——从任一节点到其每个叶子节点的路径都包含相同数量的的黑色节点,在红黑树中,遇到这种情况仍然是需要用树的旋转和节点重新上色🎨解决问题。下面的讨论主要考虑的是这种情况

具体的参考 这里,我不想看了。

参考资料

3.4 删除为什么比插入更麻烦?

因为删除尤其是删除黑节点时,可能会破坏:

  • 黑高一致性
  • 红黑约束

插入通常只会造成“连续红”问题,而删除黑节点会让某条路径少一个黑节点,这就是所谓的“黑色缺失”,修复过程更复杂。

本质原因:

  • 插入是“局部多了红”
  • 删除是“路径黑高可能少了 1”

后者比前者更难处理。

3.5 左旋和右旋分别在干什么?会不会手写?

树的旋转操作,本质上是在不破坏二叉查找树性质(左节点 < 根节点 < 右节点)的前提下,通过局部改变节点间的指针连接关系,来调整树的局部高度,从而维持整棵树的平衡。

简单来说,就是把“太长”的那一边的节点往上提,“太短”的那一边往下压。

左旋 (Left Rotation)

  • 核心动作: 逆时针旋转。把目标节点 \(x\) 向左下压,让它的右孩子 \(y\) “上位”成为新的父节点。
  • 节点交接: 因为 \(y\) 上位后,它的左孩子位置空出来了,而 \(x\) 被压到了左下角,原本 \(y\) 的左子树(值介于 \(x\)\(y\) 之间)就顺理成章地变成了 \(x\) 的右子树。

右旋 (Right Rotation)

  • 核心动作: 顺时针旋转。把目标节点 \(y\) 向右下压,让它的左孩子 \(x\) “上位”成为新的父节点。
  • 节点交接: 因为 \(x\) 上位后,它的右孩子位置空出来了,而 \(y\) 被压到了右下角,原本 \(x\) 的右子树(值介于 \(x\)\(y\) 之间)就变成了 \(y\) 的左子树。

旋转操作考察的纯粹是指针操作的严谨性。每一个旋转都涉及三对指针的断开与重连:子树的交接、父节点的交接、以及根节点的交接。

以下是标准 C++ 的实现逻辑:

// 定义红黑树节点
struct Node {
    int data;
    Node* left;
    Node* right;
    Node* parent;
    bool isRed; // 颜色标识位
};

class RedBlackTree {
private:
    Node* root;

public:
    // 左旋 (以节点 x 为支点)
    void leftRotate(Node* x) {
        if (!x || !x->right) return;

        Node* y = x->right; // y 是 x 的右孩子,即将上位

        // 1. 交接 y 的左子树给 x 的右边
        x->right = y->left;
        if (y->left != nullptr) {
            y->left->parent = x;
        }

        // 2. 交接 y 的父节点(y 取代 x 的位置)
        y->parent = x->parent;
        if (x->parent == nullptr) {
            root = y; // x 原本是根节点,现在 y 成了新的根
        } else if (x == x->parent->left) {
            x->parent->left = y;
        } else {
            x->parent->right = y;
        }

        // 3. 把 x 变成 y 的左孩子
        y->left = x;
        x->parent = y;
    }

    // 右旋 (以节点 y 为支点)
    void rightRotate(Node* y) {
        if (!y || !y->left) return;

        Node* x = y->left; // x 是 y 的左孩子,即将上位

        // 1. 交接 x 的右子树给 y 的左边
        y->left = x->right;
        if (x->right != nullptr) {
            x->right->parent = y;
        }

        // 2. 交接 x 的父节点(x 取代 y 的位置)
        x->parent = y->parent;
        if (y->parent == nullptr) {
            root = x; // y 原本是根节点,现在 x 成了新的根
        } else if (y == y->parent->right) {
            y->parent->right = x;
        } else {
            y->parent->left = x;
        }

        // 3. 把 y 变成 x 的右孩子
        x->right = y;
        y->parent = x;
    }
};

避坑指南:

  • 手写旋转时最容易出错的地方是忘记更新 parent 指针,或者在处理根节点时发生空指针异常(Segmentation Fault)。所以在写的时候,每次改变一条向下的指针(比如 x->right = ...),都要立刻条件反射地去更新对应节点的向上的指针(...->parent = x)。

3.6 Redis 的 zset 为什么选择跳表而不是红黑树?

(1)范围查询

  • 红黑树做范围查询需要中序遍历,比较复杂;而跳表的底层本质是一个双向链表,找到起点后,直接顺着指针遍历即可,极其高效(应对 ZRANGE 等命令)。

(2)内存占用的精妙控制

  • 很多人直觉上认为跳表需要维护多层级的指针,会比红黑树更占内存。但在 Redis 的精妙实现下,事实恰好相反。
  • 红黑树由于需要固定维护两个指针和一个颜色位,在 64 位系统中开销很大;
  • 而跳表虽然需要维护多层级的指针,但节点层高的生成是通过抛硬币(随机数)决定的。Redis 并没有使用标准的 \(p=0.5\)(即每往上一层概率减半),而是将概率设为了 \(p=0.25\)。这意味着平均每个节点包含的指针数大约是 \(1 / (1 - 0.25) \approx 1.33\) 个。
  • 相比红黑树固定的 2 个指针,Redis 的跳表在内存占用上其实更加紧凑和灵活。同时,可以通过调整这个概率参数,在内存消耗和查询速度之间做定制化平滑折中(Trade-off)。

(3)实现复杂度与调试

  • 红黑树的左旋、右旋逻辑复杂,容易写出 Bug,而跳表的逻辑直观得多,代码实现和维护成本低。

(4)并发操作与锁粒度

  • 尽管 Redis 是单线程处理命令,但在多线程或并发环境下,修改红黑树可能导致树的结构整体变动,需要锁住较大的范围;
  • 而跳表的节点修改通常只影响局部指针,更适合实现细粒度锁或无锁并发(尽管 Redis 没用到这点,但在 JUC 的ConcurrentSkipListMap 中是关键)。

3.7 Linux 内核里哪些地方用过红黑树?

内核里凡是需要 动态有序集合、同时要求查找/插删稳定 \(O(log^n)\) 的地方,红黑树都很合适。

1. 进程调度:完全公平调度器 (CFS - Completely Fair Scheduler)

这是内核中红黑树最著名、也最核心的应用之一。CFS 不使用传统的多级优先队列,而是用红黑树来管理所有处于可运行状态的进程。

  • 如何运作红黑树以进程的虚拟运行时间(vruntime作为排序的 Key。内核每次调度时,根本不需要遍历,直接摘取红黑树最左侧的节点(即 vruntime 最小、最渴望得到 CPU 时间的进程)投入运行即可。
  • 优势:进程状态切换(睡眠、唤醒)极其频繁,红黑树保证了调度的插入、删除操作始终稳定在 \(O(\log n)\)

2. 多路复用:Epoll 核心结构

在服务端高并发网络编程中,epoll 相比 select/poll 实现了性能的飞跃,其底层核心数据结构就是一棵红黑树 + 一个双向链表

  • 如何运作内核中的 eventpoll 对象使用红黑树来存储所有被监控的 socket 文件描述符(FD)。红黑树的 Key 是 FD。
  • 优势:当开发者调用 epoll_ctl 去添加、修改或删除海量并发连接中的某一个 FD 时,红黑树能提供稳定的极速检索,并防止同一个 FD 被重复添加。

3. 内存管理:虚拟内存区域 (VMA)

每个用户进程的虚拟地址空间会被划分为多个不重叠的、连续的内存区域(vm_area_struct,例如代码段、数据段、堆、栈等),内核长期使用红黑树来管理这些 VMA。

  • 如何运作以内存块的起始地址作为 Key。当发生缺页中断(Page Fault)时,内核必须根据触发异常的内存地址,极快地查找到它属于哪个 VMA,从而决定是分配物理内存还是抛出段错误(Segmentation Fault)。
  • 注:随着系统内存越来越大,为了更好地适应现代 CPU 的缓存行(Cache Line),从 Linux 6.1 开始,内核开始逐步用一种新的 B树变种(Maple Tree)来替换 VMA 的红黑树,但红黑树在这里依然统治了长达二十多年。

4. 高精度定时器 (hrtimers)

Linux 需要处理海量的定时任务(如超时重传、睡眠唤醒等),高精度定时器子系统使用红黑树来管理这些定时器。

  • 如何运作以定时器的到期时间(绝对时间)作为 Key。红黑树最左侧的节点就是马上要触发的下一个定时事件。
  • 优势:随着时间推移,不断有新定时器加入和旧定时器取消,红黑树的高效变更是保证定时器精度的基础。

4. B 树、B+ 树

跳表在内存中(比如 Redis)如鱼得水,但如果把场景切换到 磁盘存储(比如 MySQL),跳表就不适用了,因为跳表的节点在内存中是分散的,如果放在磁盘上会导致极其随机的物理 I/O。

为了解决 磁盘 I/O 瓶颈,计算机科学家们设计了 B 树系列。我们来深度剖析一下 B Tree 和 B+ Tree,以及 InnoDB 的最终抉择。


4.1 B Tree

很多时候大家会看到“B-Tree”这个词,中间的 连字符 经常被误读为“B减树”,其实它就是 B 树(多路平衡查找树)。不存在 B 减树😀。

B Tree

如上图所示,A、B、C 这些字母在这里是 “关键字”,每个指针节点是 “孩子”。显然的,我们可以看出,关键字个数比孩子个数少一个,因此,对于 m 阶 B 树:

  • 它有 m 个孩子,m-1 个关键字
  • 除根节点外,最少有 ceil(m/2) 个孩子,ceil(m/2) - 1 个关键字
  • 根节点特殊:若根不是叶子,至少有 2 个孩子;若整棵树只有一个根,则根可以少于上述下限

1. 核心结构与特征:

  • B 树是一种多路(不仅有两个子节点,可以有 \(M\) 个子节点)的自平衡查找树。
  • 最致命的特征: 在 B 树中,所有节点(包括根节点、内部节点和叶子节点)都同时存储 Key(索引键值)和 Data(完整的行数据或指向行数据的指针)
  • 任何一个普通的查找操作,只要在沿途的节点中匹配到了 Key,就会直接返回该节点里的 Data,搜索提前结束。

2. 存在的痛点:

虽然它的时间复杂度也是 \(O(\log N)\),但由于内部节点里塞满了臃肿的 Data,导致一个固定大小的磁盘页(Page)能装下的 Key 的数量极其有限。


4.2 B+ Tree

B+ 树是 B 树的一种变体,它是专门为磁盘等外存储设备设计的一种平衡查找树。

1. 核心结构与特征:

  • 非叶子节点(内部节点)“减负”: 内部节点绝对不存储 Data,只存储 Key 作为向下寻找的路由向导。
  • 数据全在底层: 所有的 Data(真实的完整行数据)全部、且仅仅存储在最底层的叶子节点中。
  • 叶子节点手拉手: 所有的叶子节点被一个双向链表串联了起来,形成了一个天然的、按主键严格排序的单链。

4.3 MySQL 为什么坚决选择 B+ 树而不是 B 树?

这是面试中最关键的考点。MySQL 选用 B+ 树,完全是为了向磁盘的物理特性妥协并做到极致优化。核心原因有以下三点:

(1)更少的磁盘 I/O 开销

  • 磁盘读取原理: 操作系统读取磁盘不是按字节读的,而是按“页(Page)”读的(InnoDB 默认一页是 16KB)。
  • B 树的劣势: 如果用 B 树,因为非叶子节点里存了巨大的行数据,16KB 的一页可能只能存下 10 几条记录。当数据量上千万时,B 树会变得非常“高瘦”,树的高度 \(h\) 可能达到 5 甚至更高。这意味着一次查询需要进行 5 次随机磁盘 I/O,这在数据库层面是不可接受的延迟。
  • B+ 树的优势: 因为非叶子节点只存极小的 Key(比如一个 BIGINT 占 8 字节,加一个指针 6 字节),16KB 的一页可以轻松塞下 1000 多个 Key。
  • 数学奇迹: 一棵高度仅仅为 3 的 B+ 树,就能支撑 \(1000 \times 1000 \times 1000 = 10\) 亿级别的数据量!这意味着在 InnoDB 中,查询上亿级别表里的一条数据,最多只需要 3 次磁盘 I/O(而且通常根节点常驻内存,实际只需要 2 次)。B+ 树极其“矮胖”,完美击中了磁盘 I/O 的痛点。

(2)碾压级的“范围查询 (Range Query)”能力

  • 数据库中充斥着大量的范围查询需求:SELECT * FROM user WHERE age BETWEEN 20 AND 30;
  • 如果在 B 树中: 找到 20 之后,为了找后续的值,必须进行繁琐的树的中序遍历。这需要在不同的磁盘页之间疯狂跳转,产生大量的随机 I/O,磁头会剧烈寻道,性能极差。
  • 如果在 B+ 树中: 由于所有数据都在叶子节点,并且叶子节点用双向链表连成了单轨火车。定位到 20 这个节点(时间复杂度 \(O(\log N)\))后,根本不需要再回溯整棵树,直接顺着叶子节点的指针往后遍历链表即可。这是极其高效的磁盘顺序 I/O操作。

(3)查询性能极其稳定(企业级应用的偏好)

  • 在 B 树中,如果你运气好,查询的数据正好在根节点,1 次 I/O 就搞定了;如果运气差,数据在叶子节点,可能要 5 次 I/O。查询耗时忽高忽低(抖动)。
  • 在 B+ 树中,所有的数据都在叶子节点,没有任何捷径。这意味着所有的查询都必须从根走到叶子,所经历的磁盘 I/O 次数是恒定的。在企业级高并发系统中,稳定、可预期的查询延迟往往比偶尔的“运气好”更为重要。

4.4 为什么数据库索引页大小常设计成和磁盘页/缓存页对齐?

因为数据库读写的基本单位通常是“页”,操作系统和磁盘缓存也以页为单位管理。

对齐后有几个好处:

  1. 一次 I/O 正好读一个完整节点
  2. 减少页内碎片和跨页访问
  3. 更好利用缓存局部性
  4. 简化页管理和缓冲池实现

所以 B+ 树节点通常设计成“一个节点对应一个页”。

4.5 B+ 树一个节点能存多少 key 取决于什么?

B+ 树一个节点能存多少个 Key,本质上是一个“在固定的空间里能塞下多少个数据单元”的物理内存或磁盘空间分配问题。

在数据库(如 MySQL、PostgreSQL)或底层存储引擎的实现中,这个数量主要取决于以下 4 个核心因素

1. 节点(页/块)的总大小 (Page/Block Size)

B+ 树的节点通常直接对应操作系统或数据库的一次 I/O 读取单位,称为“页”或“块”。

  • 节点越大,能存的 Key 就越多。
  • 比如在 MySQL 的 InnoDB 引擎中,默认的页大小是 16KB(16384 字节)。而操作系统默认的内存页通常是 4KB。

2. Key 自身的数据类型大小 (Key Size)

你选择的索引列的数据类型直接决定了 Key 的大小。

  • 如果以 INT 类型作为主键,一个 Key 占 4 字节
  • 如果以 BIGINT 类型作为主键,一个 Key 占 8 字节
  • 如果用 VARCHAR(36) 存 UUID 字符串,那一个 Key 就会占用更多空间。Key 越大,一个节点能存的数量就越少。

3. 子节点指针的大小 (Pointer Size)

B+ 树的非叶子节点(内部节点)只起到导航作用,除了存 Key,还要存指向下一层子节点的指针(通常是子节点所在的“页号”或物理偏移量)。

  • 在 InnoDB 中,这个指针(称为指向子页的 Page Number)固定占用 6 字节

4. 节点内部的元数据开销 (Header/Metadata Overhead)

每个节点不能把 100% 的空间全拿来存 Key,它需要维护自身的管理信息。

  • 比如页头(Page Header)、页尾检查和(Checksum)、事务 ID 等。在 InnoDB 中,这些杂项大概会占据一两百个字节。

具体能存多少?(以 MySQL InnoDB 为例的计算)

我们可以通过一个简单的公式来估算非叶子节点的 Key 容量:

\[\text{节点容量} \approx \frac{\text{节点总大小} - \text{元数据开销}}{\text{Key 大小} + \text{指针大小}} \]

假设我们使用默认的 16KB 页大小,以 BIGINT(8 字节) 为主键:

  1. 一对 (Key + 指针) 的大小是:\(8 + 6 = 14\) 字节。
  2. 忽略掉极小的元数据开销,16KB 大约是 16384 字节。
  3. 容量计算:\(16384 \div 14 \approx 1170\)

结论:在上述条件下,一个非叶子节点大约能存 1170 个 Key 和 1170 个指针。这就是为什么 B+ 树被称为“矮胖型”数据结构——它的扇出(分支因子)非常大,这使得它能够以极低的层数(通常 3 到 4 层)管理千万级别的数据,从而把缓慢的磁盘 I/O 次数降到最低。

注意:叶子节点的容量计算略有不同

B+ 树的叶子节点不存指针,而是存具体的数据(Payload)。如果是聚簇索引(比如 InnoDB 的主键索引),叶子节点存的是完整的行数据(可能高达几千字节),这时候一个叶子节点可能只能存十几个 Key;如果是非聚簇索引,存的则是主键的值。

4.6 为什么自增主键对 B+ 树更友好,而随机 UUID 可能不好?

因为 B+ 树叶子节点按 key 有序存储。

自增主键:新记录通常插到最右侧,属于顺序追加

  • 页分裂少
  • 写入局部性好
  • 缓存命中高
  • 索引维护成本低

随机 UUID:新记录会随机插入到各个位置:

  • 更容易触发页分裂
  • 页空间利用率下降
  • 写放大更明显
  • 缓存局部性差

所以数据库里自增主键通常更“友好”。

posted @ 2026-09-08 19:44  光風霽月  阅读(7)  评论(0)    收藏  举报