数据库按主键查一行,为什么通常只读 3 个页?为什么主键建议短一点?为什么范围查询快,而 LIKE '%关键词' 快不起来?

这些问题的答案,都在 B+ 树的形状里。

本文不背结论,直接从一棵小树开始:先定规则,再走一遍查找、插入、删除、范围查询,最后写出完整实现。全文用 order=4(每个节点最多 4 个孩子、3 个 key),小到能在纸上画完;核心规则和真实数据库一致,真实实现另外还多了并发、锁、页头等工程细节。

二叉搜索树与 B+ 树的树高对比

一、为什么是 B+ 树:先把树压矮

磁盘按页读写,一次随机 I/O 约 100 µs(SSD);内存里一次比较只要几十纳秒。点查一棵树要读多少页,基本等于树有多高。所以数据库索引优化的第一目标不是“比较次数最少”,而是树最矮。

二叉搜索树每个节点只有 2 个孩子。1000 万个 key,树高约 ceil(log2(10^7)) = 24 层,点查最坏要 24 次随机 I/O。

B+ 树把一个节点变成一个 16 KB 的页,页里塞几百上千个 key,树高立刻塌下来:

16 KB 页的扇出与三层容量

算一下就明白。key 8 B 加子页指针 6 B,约 14 B 一路,16 KB 的一页能放约 1170 路;叶子一行 key + 行指针约 14 B,加上行头按 16 B 估算,一页约 1024 行。于是:

  • 2 层:1170 × 1024 ≈ 120 万行
  • 3 层:1170² × 1024 ≈ 14 亿行

这就是“三层 B+ 树能装千万到十亿级数据”的算术来源。主键越短,一页装的路越多,树越矮——这条优化规则从第一性原理直接推出来。

B 树的内部节点也存数据,同样大小的页能放的指针更少;范围查询还要在树里来回走。B+ 树把数据全部赶到叶子,内部页只留 key 和指针,扇出更大;叶子再用链表串起来,范围扫描可以横着走。这是它成为数据库索引默认结构的直接原因。

二、五条规则,定义一棵 B+ 树

B+ 树全景

  1. 每个节点最多 m−1 个 key;内部节点最多 m 个孩子。 非根节点至少 ceil(m/2)−1 个 key。order=4 时:最多 3 个 key,最少 1 个 key。
  2. 内部节点的 keys[i] 是分界值:左孩子所有 key < keys[i] ≤ 右孩子所有 key。它通常等于右子树的最小 key。
  3. 数据只存在叶子;内部节点只存导航用的 key 和子页指针。
  4. 所有叶子在同一层,所以每次点查经过的层数都一样。
  5. 叶子之间用 next 指针按 key 顺序串成链表。

根节点是唯一的例外:树可以是空的;非空树的根可以只有 1 个 key,不受下限约束。其他节点低于下限就叫下溢,插入和删除的复杂逻辑,本质上都在修下溢。

一个实现细节:删除最小 key 后,keys[i] 可能不再正好等于右子树最小值,但“左 < 分界值 ≤ 右”始终成立,查找不受影响。本文不专门回填它。

三、节点长什么样

内部节点与叶子节点的结构

内部节点只有两个数组:keys 和 children。keys.length 永远比 children.length 少 1,key 之间的每个“缝”就是一个子页指针。

叶子节点多两个字段:values 和 next。真实数据库里,叶子存整行或“主键 + 行指针”;内部页只存 key,不存数据。

// B+ 树教学实现:order = 每个节点最多有多少个子节点
// 约定:内部节点的 keys[i] 是分界值:左孩子所有 key < keys[i] ≤ 右孩子所有 key
//      所有数据只存在叶子节点;叶子节点用 next 串成一条有序链表

export type Node = InternalNode | LeafNode;

export interface InternalNode {
  leaf: false;
  keys: number[];
  children: Node[];
}

export interface LeafNode {
  leaf: true;
  keys: number[];
  values: string[];
  next: LeafNode | null;
}

export class BPlusTree {
  root: Node | null = null;
  size = 0;
  readonly order: number;
  readonly maxKeys: number;
  readonly minKeys: number;

  constructor(order = 4) {
    if (order < 3) throw new Error('order 至少为 3');
    this.order = order;
    this.maxKeys = order - 1;
    this.minKeys = Math.ceil(order / 2) - 1;
  }

order=4 对应 maxKeys=3、minKeys=Math.ceil(4/2)-1=1。这两个数字后面会反复用到。下面按操作逐个补全这个类,所有片段按顺序拼起来就是完整文件。

四、查找:在每一层做一次路由

查找 key=7 的路径

从根开始,在内部节点里找“第一个大于 key 的分界值”:比它小就走左边,否则(包括相等)走右边。走到叶子后,在叶子的 keys 里二分。

  // ---------------------------------------------------------- 查找

  /** 点查:沿树下降到叶子,再在叶子里二分。 */
  search(key: number): string | undefined {
    if (!this.root) return undefined;
    const leaf = this.findLeaf(key, []);
    const at = this.lowerBound(leaf.keys, key);
    return leaf.keys[at] === key ? leaf.values[at] : undefined;
  }

  /** 记录下降过程中经过的内部节点,最后一个就是叶子的父节点。 */
  private findLeaf(key: number, path: InternalNode[]): LeafNode {
    let node: Node = this.root!;
    while (!node.leaf) {
      path.push(node);
      let i = 0;
      while (i < node.keys.length && key >= node.keys[i]) i++;
      node = node.children[i];
    }
    return node;
  }

  private lowerBound(keys: number[], key: number): number {
    let lo = 0;
    let hi = keys.length;
    while (lo < hi) {
      const mid = (lo + hi) >>> 1;
      if (keys[mid] < key) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  }

三个细节:

  • key >= keys[i] 的等号很重要:key 等于分界值时,只可能去右子树找。分界值不大于右子树的最小 key;左边所有 key 都小于它,所以不可能在左边。
  • lowerBound 返回第一个大于等于 key 的位置,查找和插入都用它定位。
  • 查找只读 height 个页;页内比较是内存操作,可以忽略(真实数据库会在页内二分,把每层比较压到 log m)。

五、插入:先放进去,满了就分裂

5.1 叶子有空位

插入 7:叶子还有空位

插入永远发生在叶子:先按查找的路径走到叶子,把 key 插到有序位置。key 已存在就更新 value,不新增。

  // ---------------------------------------------------------- 插入

  /** 插入;key 已存在时更新 value。 */
  insert(key: number, value: string): void {
    if (!this.root) {
      this.root = { leaf: true, keys: [key], values: [value], next: null };
      this.size = 1;
      return;
    }

    const path: InternalNode[] = []; // 从根到叶经过的内部节点
    const leaf = this.findLeaf(key, path);
    const at = this.lowerBound(leaf.keys, key);

    if (leaf.keys[at] === key) {
      leaf.values[at] = value; // 重复 key:只更新
      return;
    }
    leaf.keys.splice(at, 0, key);
    leaf.values.splice(at, 0, value);
    this.size++;

    if (leaf.keys.length > this.maxKeys) {
      const [left, right, upKey] = this.splitLeaf(leaf);
      this.fixAfterSplit(path, left, right, upKey);
    }
  }

注意 path:下降时把经过的内部节点记下来,后面分裂时才知道该把新 key 插到哪个父节点。

5.2 叶子分裂

插入 8:叶子分裂

叶子最多 3 个 key,插入第 4 个就溢出。做法是从中间劈开:

  • 左叶留前 floor(m/2) 个 key;
  • 右叶拿剩下的;
  • 右叶的第一个 key(右子树的最小 key)复制一份,插到父节点做分界值。key 本身留在叶子里,因为数据不能丢。
  private splitLeaf(leaf: LeafNode): [LeafNode, LeafNode, number] {
    const mid = Math.floor(this.order / 2);
    const right: LeafNode = {
      leaf: true,
      keys: leaf.keys.slice(mid),
      values: leaf.values.slice(mid),
      next: leaf.next,
    };
    leaf.keys = leaf.keys.slice(0, mid);
    leaf.values = leaf.values.slice(0, mid);
    leaf.next = right;
    return [leaf, right, right.keys[0]]; // 右叶最小 key 复制一份给父节点
  }

splitLeaf 里返回的 right.keys[0] 是复制,不是移动。父节点里的 7 只是目录,真正的数据 7 还在右叶。

5.3 内部节点也满了

插入 10:内部节点分裂,树长高一层

父节点收到新的分界值后可能也超过 3 个,需要继续分裂。规则和叶子不同:

  • 内部节点的中间 key 上移,成为父节点的分界值,原节点不再保留它;
  • 左右两边各自保留一部分 key 和对应的子页指针。

为什么中间 key 可以“搬走”?因为内部 key 只是目录项,真正的 key 在叶子里都有一份。这是 B+ 树分裂时最容易和 B 树混淆的地方。

内部页的分裂点取 floor((m−1)/2):order=4 时,把下标 1 的那个 key 搬上去,左边留 1 个 key,右边留 2 个。

  private splitInternal(node: InternalNode): [InternalNode, InternalNode, number] {
    const mid = Math.floor((this.order - 1) / 2);
    const upKey = node.keys[mid];
    const right: InternalNode = {
      leaf: false,
      keys: node.keys.slice(mid + 1),
      children: node.children.slice(mid + 1),
    };
    node.keys = node.keys.slice(0, mid);
    node.children = node.children.slice(0, mid + 1);
    return [node, right, upKey]; // 中间 key 上移,不再留在本层
  }

  /** 叶子分裂后,把新节点和分隔 key 插进父节点;父节点可能继续分裂。 */
  private fixAfterSplit(path: InternalNode[], left: Node, right: Node, upKey: number): void {
    let node = left;
    let sibling = right;
    let key = upKey;
    for (;;) {
      const parent = path.pop();
      if (!parent) {
        this.root = { leaf: false, keys: [key], children: [node, sibling] };
        return;
      }
      const at = parent.children.indexOf(node);
      parent.children.splice(at + 1, 0, sibling);
      parent.keys.splice(at, 0, key);
      if (parent.keys.length <= this.maxKeys) return;
      [node, sibling, key] = this.splitInternal(parent);
    }
  }

fixAfterSplit 是一个循环:叶子裂了插父页,父页裂了插祖父页,直到某层放得下。如果一路裂到根,就新建一个根,树高加 1。

六、删除:借一个,借不到就合并

删除先定位叶子并移除 key。如果删除后叶子仍然不少于 minKeys(order=4 时 1 个 key),直接结束;否则下溢,分三种情况:借位、合并、根收缩。如果根本身就是叶子,删空后直接把 root 置空,树变成空树。

删除主体和修复循环:

  // ---------------------------------------------------------- 删除

  /** 删除;key 不存在返回 false。 */
  delete(key: number): boolean {
    if (!this.root) return false;

    const path: InternalNode[] = [];
    const leaf = this.findLeaf(key, path);
    const at = this.lowerBound(leaf.keys, key);
    if (leaf.keys[at] !== key) return false;

    leaf.keys.splice(at, 1);
    leaf.values.splice(at, 1);
    this.size--;
    if (this.root === leaf) {
      if (leaf.keys.length === 0) this.root = null;
      return true;
    }
    if (leaf.keys.length < this.minKeys) this.fixUnderflow(path, leaf);
    return true;
  }

  /** 从下往上修:能借就借,借不到就合并;父节点不够了继续往上。 */
  private fixUnderflow(path: InternalNode[], leaf: Node): void {
    let node = leaf;
    for (;;) {
      const parent = path.pop();
      if (!parent) {
        if (!node.leaf && node.keys.length === 0) this.root = node.children[0];
        return;
      }
      const at = parent.children.indexOf(node);
      const left = at > 0 ? parent.children[at - 1] : null;
      const right = at < parent.children.length - 1 ? parent.children[at + 1] : null;

      if (left && left.keys.length > this.minKeys) {
        this.borrowFromLeft(parent, at);
        return;
      }
      if (right && right.keys.length > this.minKeys) {
        this.borrowFromRight(parent, at);
        return;
      }

      if (left) this.merge(parent, at - 1);
      else this.merge(parent, at); // 没有左兄弟,就把右兄弟并进来

      if (parent === this.root) {
        if (parent.keys.length === 0) this.root = parent.children[0]; // 根空了:树高 -1
        return;
      }
      if (parent.keys.length >= this.minKeys) return;
      node = parent; // 父节点也不够了,继续往上
    }
  }

6.1 借位

删除 3:向左边兄弟借

删除 1、2:向右边兄弟借

  • 左兄弟有富余:把左兄弟最大的 key(和 value)移到当前叶子最前面;父页分界值改成当前叶子的新最小值。
  • 右兄弟有富余:把右兄弟最小的 key 移到当前叶子最后面;父页分界值改成右兄弟的新最小值。
  private borrowFromLeft(parent: InternalNode, at: number): void {
    const node = parent.children[at];
    const left = parent.children[at - 1];
    if (node.leaf && left.leaf) {
      node.keys.unshift(left.keys.pop()!);
      node.values.unshift(left.values.pop()!);
      parent.keys[at - 1] = node.keys[0];
    } else if (!node.leaf && !left.leaf) {
      node.keys.unshift(parent.keys[at - 1]);
      node.children.unshift(left.children.pop()!);
      parent.keys[at - 1] = left.keys.pop()!;
    } else {
      throw new Error('树结构损坏:兄弟节点类型不一致');
    }
  }

  private borrowFromRight(parent: InternalNode, at: number): void {
    const node = parent.children[at];
    const right = parent.children[at + 1];
    if (node.leaf && right.leaf) {
      node.keys.push(right.keys.shift()!);
      node.values.push(right.values.shift()!);
      parent.keys[at] = right.keys[0];
    } else if (!node.leaf && !right.leaf) {
      node.keys.push(parent.keys[at]);
      node.children.push(right.children.shift()!);
      parent.keys[at] = right.keys.shift()!;
    } else {
      throw new Error('树结构损坏:兄弟节点类型不一致');
    }
  }

内部节点借用同理,只是移动的是“key + 子页指针”,父页分界值也要跟着换。

6.2 合并

删除 3、2、1:合并与向上修复

两边兄弟都只有最少 key 时,借不到,只能合并:把右叶的 key/value 追加到左叶,左叶的 next 指向右叶的 next;父页里对应的分界值和子页指针一起删掉。

  /** 合并 parent.keys[at] 左右两个孩子,并把分隔 key 摘掉。 */
  private merge(parent: InternalNode, at: number): void {
    const left = parent.children[at];
    const right = parent.children[at + 1];
    if (left.leaf && right.leaf) {
      left.keys.push(...right.keys);
      left.values.push(...right.values);
      left.next = right.next;
    } else if (!left.leaf && !right.leaf) {
      left.keys.push(parent.keys[at], ...right.keys);
      left.children.push(...right.children);
    } else {
      throw new Error('树结构损坏:兄弟节点类型不一致');
    }
    parent.keys.splice(at, 1);
    parent.children.splice(at + 1, 1);
  }

父页少了一个 key,可能也下溢,于是修复继续向上——这就是 fixUnderflow 的循环。内部节点合并还要把父页的分界值下沉到合并后的节点里。

6.3 根收缩

根页空了:树高减一

如果合并一路合并到根,根可能变成 0 个 key、1 个孩子。这时把唯一的孩子提上来当新根,树高减 1。这是 B+ 树变矮的唯一方式。

七、范围查询:叶子链表就是答案

范围查询 4..9:沿叶子链表扫

先按 low 下降找到叶子,在叶子里二分到第一个大于等于 low 的位置;然后沿着 next 一路往后扫,直到 key 大于 high。

  // ---------------------------------------------------------- 范围查询

  /** 范围查询:先找到 low 所在的叶子,再沿 next 指针往后扫。 */
  range(low: number, high: number): Array<[number, string]> {
    const out: Array<[number, string]> = [];
    if (!this.root) return out;
    let leaf: LeafNode | null = this.findLeaf(low, []);
    let at = this.lowerBound(leaf.keys, low);
    while (leaf) {
      for (; at < leaf.keys.length; at++) {
        if (leaf.keys[at] > high) return out;
        out.push([leaf.keys[at], leaf.values[at]]);
      }
      leaf = leaf.next;
      at = 0;
    }
    return out;
  }

代价是:height 次下降(第 height 次已经读到起点叶子),再加上后面扫过的叶子页数;如果一共命中 k 个叶子,页读是 height + k − 1。因为叶子是顺序链表,范围扫描是顺序 I/O。点查再快也替代不了这一点,所以 B+ 树能同时覆盖 OLTP 点查和范围扫描。

八、把它跑起来:不变量 + 随机对拍

只靠“看起来对”不够。B+ 树必须始终满足:

  • 每个节点 key 有序,且不超过 maxKeys;
  • 非根节点不少于 minKeys;
  • 内部节点 children.length === keys.length + 1;
  • 所有叶子在同一层,叶子链表顺序正确且与 key 顺序一致;
  • 叶子里的 key 总数等于 size。

两个调试辅助方法:

  // ---------------------------------------------------------- 调试辅助

  get height(): number {
    let h = 0;
    let node: Node | null = this.root;
    while (node) {
      h++;
      node = node.leaf ? null : node.children[0];
    }
    return h;
  }

  /** 按顺序遍历所有叶子,方便测试与调试。 */
  *leaves(): Generator<LeafNode> {
    if (!this.root) return;
    let node: Node = this.root;
    while (!node.leaf) node = node.children[0];
    let leaf: LeafNode | null = node as LeafNode; // 循环结束时 node 一定是叶子
    while (leaf) {
      yield leaf;
      leaf = leaf.next;
    }
  }
}

测试用最笨但最可靠的办法:拿一个 Map 当标准答案,随机做 2 万次操作(插入、删除合计),定期检查全部不变量和全量查找;完整脚本还会对随机区间做范围查询对拍。

const t = new BPlusTree(4);
const model = new Map();
let seed = 20261018;
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32);

for (let step = 0; step < 20000; step++) {
  const key = Math.floor(rnd() * 300);
  if (rnd() < 0.62) {
    t.insert(key, `v${step}`);
    model.set(key, `v${step}`);
  } else if (t.delete(key) !== model.delete(key)) {
    throw new Error(`delete(${key}) 与标准答案不一致`);
  }
  if (step % 250 === 0) {
    check(t); // 逐条检查上面的不变量,见 test.mjs
    for (const [k, v] of model) {
      if (t.search(k) !== v) throw new Error(`search(${k}) 不一致`);
    }
  }
}
console.log('2 万次随机操作对拍通过');

关键输出(节选):

插入 1..10 后:
内部 [5]
  c0: 内部 [3]
    c0: 叶 [1,2]
    c1: 叶 [3,4]
  c1: 内部 [7,9]
    c0: 叶 [5,6]
    c1: 叶 [7,8]
    c2: 叶 [9,10]

range(4,9) = [[4,"v4"],[5,"v5"],[6,"v6"],[7,"v7"],[8,"v8"],[9,"v9"]]
…(中间删除过程略)…
随机 20000 次插入/删除对拍通过,最终 size = 183 height = 6

这棵树和第二节的全景图、第六节的删除图是同一棵。你可以用文末的完整代码复现每一步。

九、代价:B+ 树不是免费的

结构 点查 范围查询 顺序写 典型场景
哈希表 O(1) 不支持 差 等值缓存
红黑树 O(log n) 次内存访问 支持 一般 内存有序结构
B+ 树 约 3 次页 I/O 叶子链表顺序扫 好 磁盘索引
LSM 树 内存 + 多层查找 支持 极好 写多读少

B+ 树的代价也很明确:

  • 写放大:一次分裂要改父页,最坏一路改到根;随机插入还会造成页分裂和空间碎片。
  • 空间开销:非根节点按孩子数至少半满;真实数据库的 order 很大,页内平均填充率通常约 2/3 到 3/4(order=4 这种教学树会更低)。
  • 点查不如哈希:哈希一次定位;但哈希不支持范围、排序和最左前缀。

所以选不选 B+ 树,本质是问一句:你的查询需不需要顺序? 需要范围、排序、前缀匹配,B+ 树几乎总是默认答案;只要单点 KV,哈希可能更合适。

十、速查:一张表记住所有操作

操作 路径 触发条件 修复动作
点查 根 → 叶 — —
插入 根 → 叶 节点 key 数 > maxKeys 分裂;叶子复制右叶最小 key,内部页搬中间 key;根裂则树高 +1
删除 根 → 叶 节点 key 数 < minKeys 先借,借不到合并;根空则树高 −1
范围 根 → 叶 → next — —

最后三句话:

  1. 内部页是目录,叶子页是数据;目录只负责把路指对。
  2. 所有操作都从“找到叶子”开始:插入向上裂,删除向上修。
  3. 树高就是点查的页读次数。让节点变胖、让 key 变短,是数据库索引优化的第一性原理。

完整代码与测试脚本(打包下载):