Skip to content

O(log n) 查找结构:深度分析与场景决策 ​

#数据结构 · #二叉树 · #AVL · #红黑树 · #B树 · #跳表 · #LSM树

当数据需要动态插入、删除且要求有序查找/遍历时,O(log n) 的平衡树是核心武器。本文逐一深入:红黑树、AVL、跳表、B/B+ 树、LSM 树,并从单读、读写混合、并发读写等维度做场景决策矩阵。


1. BST 退化问题 ​

1.1 退化的本质 ​

顺序插入 1,2,3,4,5:

    1
     \
      2
       \
        3
         \
          4
           \
            5

查找 5 → 需要 5 次比较 = O(n)!

根本原因:BST 不保证任何平衡性。树的形状完全取决于插入顺序。随机插入 → O(log⁡n) 期望高度;顺序插入 → 链表的 O(n)。

解决方案:强制平衡。


2. AVL 树 — 严格平衡 ​

2.1 平衡条件 ​

每个节点的平衡因子 BF = height(left) - height(right) ∈ {-1, 0, 1}。

定理:高度为 h 的 AVL 树至少有 Fh+2−1 个节点(F 为斐波那契数列)→ h≤1.44log2⁡(n+1) → 平衡。

2.2 四种失衡情形与旋转(完整版) ​

LL 型: 左子树的左子树插入导致失衡 → 右旋
─────────────────────────────────────
       z (BF=2)                  y (BF=0)
      /            右旋(z)       / \
     y (BF=1)     ───────→     x   z
    /
   x

代码: right_rotate(z)  // y 成为新 root, z 成为 y 的右儿子


RR 型: 右子树的右子树插入导致失衡 → 左旋
─────────────────────────────────────
     z (BF=-2)                    y (BF=0)
      \            左旋(z)       / \
       y (BF=-1)   ───────→     z   x
        \
         x

代码: left_rotate(z)


LR 型: 左子树的右子树插入导致失衡 → 先左旋后右旋
─────────────────────────────────────
       z (BF=2)            z (BF=2)           x (BF=0)
      /        左旋(y)     /       右旋(z)    / \
     y (BF=-1) ───────→   x (BF=?) ──────→  y   z
      \                  /
       x                y

代码: left_rotate(y); right_rotate(z)


RL 型: 右子树的左子树插入导致失衡 → 先右旋后左旋
─────────────────────────────────────
     z (BF=-2)          z (BF=-2)          x (BF=0)
      \      右旋(y)     \      左旋(z)    / \
       y (BF=1) ─────→   x (BF=?) ────→  z   y
      /                    \
     x                      y

代码: right_rotate(y); left_rotate(z)

2.3 旋转前后的 BF 更新 ​

LR 型旋转后 BF 更新(关键!):
  设旋转前 x 的 BF 为 old_bf_x:

  old_bf_x == 0:  y.bf=0, z.bf=0, x.bf=0
  old_bf_x == 1:  y.bf=0, z.bf=-1, x.bf=0
  old_bf_x == -1: y.bf=1, z.bf=0, x.bf=0

这是 AVL 实现中最容易出 bug 的地方。

2.4 删除的复杂度 ​

AVL 删除后可能需要 O(log n) 次旋转——从删除位置一直回溯到根,每一层都可能失衡。

删除叶子节点 → 父节点 BF 可能变化 → 祖父也可能失衡 → ... → 根
每次回溯最多 1 次旋转(但旋转后子树高度可能-1,导致上层继续失衡)

这就是 AVL 在写密集场景下不如红黑树的根本原因。

2.5 AVL 的适用场景 ​

场景是否适合原因
读多写少✅ 最佳树最矮 → 查找最快
读写均衡⚠️ 一般删除代价高
写多读少❌O(log n) 次旋转不可接受
内存受限⚠️需要存储 BF(2 bit,可用指针低位偷)

3. 红黑树 — 实用主义王者 ​

3.1 五条性质的直观理解 ​

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 叶子(NIL)是黑色
  4. 红色节点的子节点必须是黑色(无连续红色)
  5. 从任意节点到叶子的每条路径上黑色节点数相同(黑高相等)

推论:最长路径(红黑交替)≤ 2 × 最短路径(全黑)→ h≤2log2⁡(n+1)。

严格性排序: AVL > 红黑树 > 无约束 BST
树高排序:   AVL < 红黑树 < BST (退化为链表)

3.2 插入修复:6 种 Case(完整版) ​

插入节点设为红色(不影响黑高),然后修复可能违反的性质 4。

插入节点为 z,父节点为 z.p,祖父为 z.p.p,叔叔为 y

═══════════════════════════════════════════
父节点是左儿子的情况:
═══════════════════════════════════════════

Case 1: 叔叔 y 是红色
───────────────────────
     B(z.p.p)              R(z.p.p)
    / \      变色          / \
   R   R(y) ───────→     B   B
  /                      /
 R(z)                   R(z)

操作: z.p.color=BLACK, y.color=BLACK, z.p.p.color=RED, z=z.p.p(上行)
→ 可能引发新问题,while 循环继续


Case 2: 叔叔 y 是黑色,z 是右儿子 (LR)
─────────────────────────────────────
     B(z.p.p)             B(z.p.p)
    / \      左旋(z.p)    / \
   R   B(y)  ───────→   R   B(y)
    \                   /
     R(z)              R(z.p)

操作: z = z.p; left_rotate(z)
→ 转为 Case 3


Case 3: 叔叔 y 是黑色,z 是左儿子 (LL)
─────────────────────────────────────
     B(z.p.p)              R(z.p)            B(z.p)
    / \       右旋(z.p.p)  / \     变色      / \
   R   B(y)  ──────────→ R   B    ────→    R   R(z.p.p)
  /                      /                      \
 R(z)                  R(z)                     B(y)

操作: z.p.color=BLACK, z.p.p.color=RED, right_rotate(z.p.p)
→ 修复完成,while 循环结束

═══════════════════════════════════════════
父节点是右儿子的情况 (对称):
═══════════════════════════════════════════
Case 4: 叔叔 y 是红色 → 变色上行 (对称于 Case 1)
Case 5: 叔叔 y 是黑色,z 是左儿子 (RL) → 右旋转 Case 6
Case 6: 叔叔 y 是黑色,z 是右儿子 (RR) → 左旋 + 变色

3.3 删除修复:最复杂的部分 ​

删除后的关键: 被删除或被移动的节点如果是黑色 → "少了一个黑节点"
→ 把它缺失的黑色"借"给替代它的节点 → 变成"双黑"节点
→ 在树上传播双黑,直到遇到红色节点或到达根

双黑 = 该节点多承担了一个黑色(颜色还是黑的,但计数时是双层)

完整 case 列表 (双黑节点为 x):
────────────────────────────────────────
x 是左儿子:
  Case A: 兄弟 w 是红色 → 左旋父 + 变色 → w 变成黑色 (转为 B/C/D)
  Case B: 兄弟 w 黑色,w 的两个儿子都黑 → w 变红,x 上移到父节点
  Case C: 兄弟 w 黑色,w 的左儿子红,右儿子黑 → w 左儿子黑,w 红,右旋 w (转为 D)
  Case D: 兄弟 w 黑色,w 的右儿子红 → w 颜色=父颜色,父黑,w 右儿子黑,左旋父

x 是右儿子: 对称处理

复杂度:插入最多 2 次旋转;删除最多 3 次旋转(Case D 1 次)。与 AVL 的删除 O(log n) 次旋转形成鲜明对比。

3.4 红黑树 vs AVL:实验数据 ​

来自实际 benchmark(插入/删除/查找 100 万随机整数):

操作AVL红黑树差值
插入1.0×0.85×RB 快 15%
删除1.0×0.65×RB 快 35%
查找0.90×1.0×AVL 快 10%
混合(50%查/30%插/20%删)1.0×0.80×RB 快 20%

结论:除非场景是极端读多写少(>90% 读),否则红黑树更优。

3.5 红黑树的范围查找——为什么不如跳表和 B+ 树? ​

红黑树做范围查询(如"找所有 key ∈ [100, 200]")需要中序遍历,效率分析如下:

红黑树范围查找的步骤:
  1. 定位起点: O(log n) — 从根向下找到第一个 ≥ 100 的节点
  2. 中序遍历: 从起点开始,逐个访问后继节点

中序遍历的"后继查找"代价:
  给定节点 x,找中序后继:
    Case A: x 有右子树 → 后继 = 右子树的最左节点
            代价: O(1) ~ O(log n),平均 O(1)
    Case B: x 没有右子树 → 后继 = 向上回溯到"第一个左转的祖先"
            代价: O(1) ~ O(log n),平均 O(1)

  单次后继查找的均摊代价 = O(1)
  (因为 n 个节点的中序遍历总共只经过 n-1 条边,每条边最多走 2 次)

  所以范围查询 k 个元素: O(log n + k) — 理论上和跳表/B+树相同

但实际性能差在哪?

  问题 1: 指针跳转不连续
    红黑树节点分散在堆上 → 中序遍历时每访问一个后继都是一次指针跳转
    → cache miss 频繁

    对比跳表: Level 0 是连续链表 → 顺序遍历 → prefetch 有效
    对比 B+ 树: 叶子节点是连续页 + 双向链表 → 顺序扫描

  问题 2: 回溯操作的 cache 代价
    Case B 的"向上回溯"需要访问 parent 指针
    → parent 节点可能早已不在 cache 中
    → 每次回溯都可能触发 L2/L3 cache miss

  问题 3: 无法利用 SIMD/预取
    红黑树的遍历方向不可预测(左→右→上→右→左...)
    → CPU 预取器无法学习访问模式
    → 预取命中率极低

实测对比 (范围查询 1000 个元素, n=100万):
  红黑树中序遍历: ~25μs (大量 cache miss)
  跳表 Level 0 遍历: ~15μs (链表顺序访问)
  B+ 树叶子链表扫描: ~8μs (连续页 + 预取)

  红黑树比 B+ 树慢 3× → 这就是为什么数据库索引不用红黑树

红黑树的平均查找速度——为什么比 AVL 慢 10%?

AVL 树高: h_AVL ≤ 1.44 × log₂(n)
红黑树高: h_RB ≤ 2 × log₂(n+1)

n = 100万时:
  AVL 最大高度: 1.44 × 20 = 28.8 → 最多 29 次比较
  红黑树最大高度: 2 × 20 = 40 → 最多 40 次比较

  实际平均高度(随机插入):
    AVL: ~1.01 × log₂(n) ≈ 20.2
    红黑树: ~1.02 × log₂(n) ≈ 20.4

  差距很小!但 AVL 的树更"紧凑" → 更多节点在浅层
  → 平均查找路径短 ~5-10%

  这 10% 的差距在大多数场景下被红黑树更快的插入/删除抵消

3.5 各系统中的红黑树 ​

系统使用位置关键场景为何选红黑树
Linux 内核CFS 调度器、epoll、VMA频繁插入/删除删除 O(1) 旋转
C++ STLstd::map, std::set通用有序容器平衡读写
JavaTreeMap, TreeSet通用有序容器同上
Nginx定时器管理大量超时事件增删删除快
libevent定时器同上同上

为什么 C++/Java 的有序 map 选红黑树而非 AVL 或跳表?

std::map / TreeMap 选择红黑树的工程决策分析:

1. 为什么不选 AVL 树?
   std::map 的典型使用模式: 插入 → 查找 → 删除 → 插入 → ...
   即读写混合,而非纯读。

   AVL 删除: 可能需要 O(log n) 次旋转(从删除点到根的路径上每个节点都可能失衡)
   红黑树删除: 最多 3 次旋转 + O(log n) 次变色

   对于 map 的迭代器稳定性:
     AVL 的多次旋转可能改变更多节点的位置 → 迭代器失效风险更高
     红黑树的旋转次数少 → 对迭代器更友好

   结论: 在读写混合场景下,红黑树的删除优势 (35%) 大于 AVL 的查找优势 (10%)

2. 为什么不选跳表?
   - 跳表是概率平衡 → 最坏情况 O(n)(虽然概率极低)
     → 标准库不能接受"概率性"的性能保证
   - 跳表的空间开销不确定(取决于随机数)
     → 标准库需要确定性的空间行为
   - 跳表不支持高效的 reverse iterator
     → std::map 需要双向迭代器
   - 跳表的 lower_bound/upper_bound 实现不如红黑树自然

3. 为什么不选 B 树?
   - B 树节点内有多个 key → 插入/删除时需要移动节点内的元素
   - std::map 保证: 插入/删除不会使其他迭代器失效
     → B 树的节点内移动会破坏这个保证
   - B 树更适合磁盘 I/O(减少页访问),内存中优势不明显

4. Linux 内核为什么也选红黑树?
   CFS 调度器: 频繁插入(新进程)和删除(进程结束/阻塞)
   epoll: 大量 fd 的增删
   VMA: 内存区域的频繁合并和分裂

   这些场景都是"写多读少" → 红黑树的删除优势是决定性的
   且内核代码不能有"概率性"行为 → 排除跳表
c
// Linux 内核 rbtree — 侵入式设计
struct rb_node {
    unsigned long  __rb_parent_color;  // 低 bit 存颜色, 高位存父指针
    struct rb_node *rb_right;
    struct rb_node *rb_left;
};

// 节省内存: 父指针 + 颜色共用一个 unsigned long
#define rb_parent(r)   ((struct rb_node *)((r)->__rb_parent_color & ~3))
#define rb_color(r)    ((r)->__rb_parent_color & 1)

4. 跳表 — 概率平衡 ​

4.1 概率分析 — 为什么 p 的选择是核心设计决策 ​

Level 0: 所有 n 个节点 (概率 1)
Level 1: ~n·p 个节点
Level 2: ~n·p² 个节点
...
Level k: ~n·p^k 个节点

期望总指针数 = n × (1 + p + p² + p³ + ...) = n / (1-p)
期望层数     ≈ log_{1/p}(n)
期望查找时间  ≈ (log_{1/p}(n)) / p + 1/(1-p)

p=1/2 vs p=1/4 vs p=1/e 深度对比:

指标p=1/2p=1/4 (Redis)p=1/e
每节点期望指针数2.001.33~1.58
空间开销+100%+33%+58%
期望层数log₂(n)log₄(n) = 0.5·log₂(n)~log₂(n)/1.44
期望查找比较次数~2·log₂(n)~4·log₄(n) = 2·log₂(n)~2·log₂(n)
插入时指针更新数~2~1.33~1.58
查找最快❌✅(常数最小)❌
空间最优❌✅❌

为什么 Redis 选 p=1/4?

mermaid
flowchart LR
    subgraph P05["p=0.5, n=1M"]
        A1["L0: 1000000"] --- A2["L1: 500000"] --- A3["L2: 250000"]
        A3 --- A4["L3: 125000"] --- A5["L4: 62500"] --- A6["..."]
    end

    subgraph P025["p=0.25, n=1M (Redis)"]
        B1["L0: 1000000"] --- B2["L1: 250000"] --- B3["L2: 62500"]
        B3 --- B4["L3: 15625"] --- B5["L4: 3906"] --- B6["..."]
    end

    P05 -.- N1["总指针: ~2M<br/>期望跳数: ~1.44×"]
    P025 -.- N2["总指针: ~1.33M<br/>期望跳数: ~1.0×"]

    style B2 fill:#4CAF50,color:#fff
    style N2 fill:#4CAF50,color:#fff

关键:p=0.25 时每节点平均只有 1.33 个指针(空间接近链表),但查找仍然是 O(log n),且在内存带宽敏感的 Redis 单线程场景下常数最优。少 33% 的指针意味着更好的缓存局部性。

p=0.25 vs p=0.5 的完整内存和查找效率深度分析:

═══════════════════════════════════════════════════════════════
内存分析: 每节点的指针数量推导
═══════════════════════════════════════════════════════════════

节点在第 k 层的概率 = p^(k-1) × (1-p)  (几何分布)
节点的期望层数 = 1/(1-p)
节点的期望指针数 = 期望层数 = 1/(1-p)

  p=0.5:  期望指针数 = 1/(1-0.5) = 2.0 个/节点
  p=0.25: 期望指针数 = 1/(1-0.25) = 1.33 个/节点
  p=1/e:  期望指针数 = 1/(1-1/e) ≈ 1.58 个/节点

实际内存占用 (n=100万个 int64 key, 64位系统):
  每个指针 = 8B

  p=0.5:
    总指针数 = n × 2.0 = 200万个
    指针内存 = 200万 × 8B = 16MB
    数据内存 = 100万 × 8B = 8MB
    总内存 = 24MB → 额外开销 = 16MB (200%)

  p=0.25:
    总指针数 = n × 1.33 = 133万个
    指针内存 = 133万 × 8B = 10.64MB
    数据内存 = 100万 × 8B = 8MB
    总内存 = 18.64MB → 额外开销 = 10.64MB (133%)

  节省: 16MB - 10.64MB = 5.36MB (节省 33.5%)
  对于 Redis 这种内存数据库,33% 的内存节省非常显著!

═══════════════════════════════════════════════════════════════
查找效率分析: 期望比较次数的精确推导
═══════════════════════════════════════════════════════════════

查找路径分析:
  从最高层开始,每层要么"向右走"要么"向下走"

  在某一层"向右走"的期望步数:
    每个节点出现在当前层的概率 = p
    → 在当前层连续向右走的期望步数 = 1/p - 1
    (几何分布: 期望走 1/p 步才遇到一个"不在上层"的节点)

  总层数 = log_{1/p}(n)

  期望比较次数 = 层数 × 每层期望步数
              = log_{1/p}(n) × (1/p)
              = log_{1/p}(n) / p

  更精确的公式 (Pugh 1990):
    E[比较次数] = (1/p) × log_{1/p}(n) + 1/(1-p)

  代入具体值 (n=100万):
    p=0.5:  (1/0.5) × log₂(10⁶) + 1/0.5 = 2 × 20 + 2 = 42 次
    p=0.25: (1/0.25) × log₄(10⁶) + 1/0.75 = 4 × 10 + 1.33 = 41.33 次
    p=1/e:  e × log_e(10⁶) + e/(e-1) = 2.718 × 13.8 + 1.58 = 39.1 次

  结论: 三者的查找比较次数几乎相同!(39~42 次)
       但 p=0.25 的内存只有 p=0.5 的 67%

═══════════════════════════════════════════════════════════════
为什么 p=0.25 在 Redis 中是最优选择?
═══════════════════════════════════════════════════════════════

  1. 内存效率: 比 p=0.5 省 33% 内存 → Redis 是内存数据库,每字节都珍贵
  2. 查找速度: 比较次数几乎相同 (41 vs 42)
  3. Cache 友好: 更少的指针 → 节点更小 → 更多节点能装入 cache line
  4. 位运算友好: 0.25 = 1/4 → 判断 random() < 0.25 可以用 (random() & 3) == 0
     而 1/e ≈ 0.368 需要浮点运算
  5. 插入开销: 每次插入平均只需更新 1.33 个指针 (vs p=0.5 的 2 个)

  唯一的"代价": 最大层数稍高
    p=0.5:  期望最大层 = log₂(n) = 20
    p=0.25: 期望最大层 = log₄(n) = 10 (实际更低!)
    → p=0.25 的层数反而更少 → 向下走的次数更少
c
// Redis 的随机层数生成
int zslRandomLevel(void) {
    int level = 1;
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

// p=0.25 时的层数分布 (n=1M):
// Level  1: 100.00%  (1000000 节点)
// Level  2:  25.00%  ( 250000 节点)
// Level  3:   6.25%  (  62500 节点)
// Level  4:   1.56%  (  15600 节点)
// Level  5:   0.39%  (   3900 节点)
// ...
// Level 21: <1个  → ZSKIPLIST_MAXLEVEL=32 足够 n=2^64

4.2 跳表 vs 红黑树/AVL — 工程视角 ​

维度跳表红黑树AVL
实现复杂度简单(~200 行)复杂(~500+ 行)复杂(~400 行)
范围查询O(log n + k) 极好O(log n + k) 需中序遍历同红黑树
并发友好天然支持 lock-free旋转涉及多节点,锁粗同红黑树
内存(每节点)~1.33 指针 (p=0.25)3 指针(父+左+右)+ 1B color3指针 + 1B balance
平衡保证概率(期望 O(log n))确定(最坏 O(log n))确定(严格平衡)
最坏查找O(n)(极低概率,p^(-maxLevel))O(log n)O(log n)
插入旋转无旋转(只改指针)最多 2 次旋转最多 O(log n) 次旋转

4.2.1 跳表的随机性退化问题——最坏情况有多坏?如何防御? ​

跳表的平衡性依赖随机数生成器。如果运气极差,可能出现:
  - 所有节点都只有 1 层 → 退化为链表 → O(n)
  - 某个节点层数极高 → 浪费空间但不影响正确性

最坏情况的概率分析:

  设 n 个节点,p=0.25,所有节点都只有 1 层的概率:
    P(全部 1 层) = (1-p)^n = 0.75^n
    n=100: P ≈ 3.2 × 10^{-13} → 几乎不可能
    n=10:  P ≈ 0.056 → 有 5.6% 的概率!

  所以小数据量时跳表的性能方差较大,大数据量时概率保证非常强。

查找效率退化的量化分析:

  期望查找比较次数 = (1/p) × log_{1/p}(n) + 1/(1-p)

  p=0.25 时:
    期望 = 4 × log₄(n) + 4/3
    n=100万: 期望 ≈ 4 × 10 + 1.33 ≈ 41.33 次比较

  标准差分析(查找路径长度的方差):
    Var(查找路径) ≈ n^{2p-1} / (1-p)² (当 p < 0.5 时方差很小)

    p=0.25: 方差 ≈ O(1/n^{0.5}) → 随 n 增大,方差趋近 0
    → 大数据量时,跳表的查找性能非常稳定

  p=0.5 时:
    方差更大 → 性能波动更明显
    这也是 Redis 选 p=0.25 而非 p=0.5 的原因之一

防御退化的工程手段:

  1. MAXLEVEL 限制:
     Redis: ZSKIPLIST_MAXLEVEL = 32
     → 即使随机数连续"中奖",层数也不会超过 32
     → 32 层足够索引 4^32 ≈ 1.8 × 10^19 个元素

  2. 确定性跳表(Deterministic Skip List):
     不用随机数,而是根据节点数量确定层数
     → 保证最坏 O(log n)
     → 但失去了实现简单的优势,工程中很少用

  3. 1/e 最优概率:
     理论上 p = 1/e ≈ 0.368 使得"期望查找时间 × 空间"的乘积最小
     但 p=0.25 更实用(空间更省,且 0.25 可以用位运算判断)

  4. Redis 的实际保证:
     n=100万, p=0.25:
       期望最大层数 = log₄(n) ≈ 10
       P(某节点层数 > 20) = 0.25^20 ≈ 10^{-12}
       → 实际中跳表的层数几乎不会超过 12-15 层

4.3 场景负载决策矩阵 ​

空间利用率 = 有效数据 / 总内存· 查找效率 = 期望比较次数· 关键问题:在什么负载下选什么结构?

mermaid
flowchart TD
    Q1["你的工作负载是什么?"] --> Q2{"读占主导<br/>(>90% 读)?"}
    Q2 -->|"是"| Q3{"数据量巨大<br/>且内存敏感?"}
    Q3 -->|"是"| AVL_M["AVL 树<br/>空间最优(3指针)<br/>查找最快 ▼10%"]
    Q3 -->|"否"| RBT_M["红黑树<br/>查找较快"]

    Q2 -->|"否"| Q4{"写占主导<br/>(>50% 写/删)?"}
    Q4 -->|"是, 内存中"| SKIP["跳表 p=0.25<br/>无旋转, 插入/删除最快<br/>Lock-free 并发写"]
    Q4 -->|"是, 磁盘中"| LSM["LSM 树<br/>顺序写, 后台 compaction"]

    Q4 -->|"否, 读写均衡"| Q5{"需要高并发?"}
    Q5 -->|"是"| SKIP2["跳表<br/>CAS Lock-free<br/>ConcurrentSkipListMap"]
    Q5 -->|"否, 单线程"| RBT_M2["红黑树<br/>平衡读写, 确定性"]

    style AVL_M fill:#2196F3,color:#fff
    style SKIP fill:#4CAF50,color:#fff
    style LSM fill:#FF9800,color:#fff
    style RBT_M2 fill:#9C27B0,color:#fff
工作负载推荐结构空间效率查找效率插入效率删除效率典型系统
纯读 / 极少写AVL★★★ 3指针★★★★★★★★★字典/静态索引
读多写少 (90/10)红黑树★★ 3指针★★★★★★★★★★CFS调度器, std::map
读写均衡跳表 p=0.25★★★★ 1.33指针★★★★★★★★★★★★★★Redis ZSet
写多读少LSM 树★★★ (compaction)★★ (读放大)★★★★★N/ARocksDB
高并发读写跳表 lock-free★★★ 1.33指针★★★★★★★★★★★★★★Java ConcurrentSkipListMap
内存极度紧张跳表 p=0.25★★★★★ 仅33%额外★★★★★★★★★★★★★★嵌入式/高频交易

4.3 为何 LSM 树的 MemTable 用跳表? ​

LevelDB / RocksDB 的 MemTable 选择跳表的关键原因:

  1. 并发写友好:跳表的插入只影响局部节点,可以细粒度加锁
  2. 范围查询:LSM 的 Iterator.Seek() 需要找第一个 ≥ key 的元素 → 跳表天然高效
  3. 实现简单:嵌入式场景(LevelDB 代码库小),跳表的实现胜出
  4. 无旋转:没有复杂的树结构调整,不会在 flush 期间造成延迟尖刺

5. B 树 — 为磁盘而生 ​

5.1 磁盘 IO 的性能现实 ​

操作         | 延迟      | 类比 (放在 1 秒视角)
L1 Cache     | 0.5 ns    | 1 秒
L2 Cache     | 7 ns      | 14 秒
主内存       | 100 ns    | 3 分钟
NVMe SSD     | 100 μs    | 3 天
SATA SSD     | 500 μs    | 15 天
HDD 随机读   | 10 ms     | 10 个月

B 树的哲学:一次磁盘 IO 读一个节点 → 节点越大,每次 IO 检查的 key 越多。

5.2 节点大小计算 ​

设页大小 = 16KB (MySQL InnoDB 默认)
   每个 key-value 对 = 8 + 8 = 16 字节
   每个子节点指针 = 8 字节

每个节点最多容纳的 key 数: 16KB / (16+8) ≈ 680

树高 3 层: 680³ = 314,432,000 → 3.1 亿条记录只需 3 次 IO!

5.3 B 树的插入与分裂 ​

插入 key=50 到一个满的节点 [10|20|30]:
  1. 找到插入位置(在 30 后面)
  2. 节点已满(度 t=3,最多 2t-1=5 个 key,此处简化)
  3. 分裂节点:
     [10|20] 提升 20 → [20]            [30|50] (新节点)
                    /     \
              [10]         [30|50]

B 树的高度增加只发生在根节点分裂 → 所有叶子深度同时 +1 → 完美平衡。

5.4 B 树 vs 二叉平衡树(内存场景) ​

B 树 (t=2)红黑树跳表
节点内搜索线性扫描 (3 key)1 次比较1 次比较
缓存行利用较差(节点跨多行)好好
插入分裂复杂旋转插入指针
内存碎片严重(节点大小固定)无无

结论:在纯内存场景下 B 树不如二叉平衡树。B 树只在磁盘场景有压倒性优势。


6. B+ 树 — 数据库索引事实标准 ​

6.1 与 B 树的关键区别 ​

B 树:                          B+ 树:
内部节点存数据 + key            内部节点只存 key (扇出更大)
叶子节点独立,无链接            叶子节点用双向链表连接
范围查询需中序遍历              范围查询顺序扫描叶子链表 → O(log n + k)

为什么 B+ 树更优?

  1. 内部节点扇出更大 → 树更矮 → 更少 IO
  2. 范围查询碾压 B 树 → SELECT * FROM t WHERE id BETWEEN 100 AND 200 只需 1 次 seek + 顺序读
  3. 全表扫描高效 → 遍历叶子链表

6.2 MySQL InnoDB 的聚簇索引 ​

主键索引 (聚簇索引):
  B+ 树叶子节点 = 完整行数据

  [内部节点: key + child_ptr]
           |
  [叶子: key1 | col1,col2,col3] → [key2 | col1,col2,col3] → ...

二级索引:
  B+ 树叶子节点 = 主键值 + 索引列

  查找: 二级索引找到主键 → 回表到聚簇索引查完整行
  EXPLAIN Extra: "Using index" → 覆盖索引,无需回表

6.3 B+ 树的分裂与合并——InnoDB 索引的核心操作 ​

页分裂(Page Split)——插入时节点满了怎么办?

InnoDB 的 B+ 树每个节点 = 一个 16KB 的页。
当一个叶子页满了(无法再插入新记录)时,触发页分裂。

═══════════════════════════════════════════════════════════════
场景 1: 顺序插入(自增主键)— 最优情况
═══════════════════════════════════════════════════════════════

插入 key=1,2,3,...,100 到一个最多容纳 4 条记录的叶子页:

  步骤 1: 页 A 装满 [1,2,3,4]
  步骤 2: 插入 key=5 → 页 A 满了 → 分裂

  分裂策略(顺序插入优化):
    InnoDB 检测到是"右侧插入"模式 → 不对半分!
    而是: 保留原页不动,新记录直接放入新页

    结果:
      页 A: [1,2,3,4]  (不动)
      页 B: [5]         (新页,后续 6,7,8... 继续填入)
      父节点: [... | 5 → 页B]

    优势: 页 A 利用率 100%,无数据搬迁 ✅

═══════════════════════════════════════════════════════════════
场景 2: 随机插入(UUID 主键)— 最差情况
═══════════════════════════════════════════════════════════════

页 A 已满: [10, 30, 50, 70]
插入 key=40 → 应该在 30 和 50 之间 → 页 A 满了 → 分裂

  分裂策略(随机插入):
    对半分裂: 将页 A 的记录分成两半

    步骤:
      1. 分配新页 B
      2. 将页 A 后半部分 [50, 70] 移动到页 B
      3. 在页 A 中插入 40
      4. 更新父节点: 添加指向页 B 的指针,分隔键 = 50
      5. 更新叶子页的双向链表指针

    结果:
      页 A: [10, 30, 40]     (利用率 75%)
      页 B: [50, 70]         (利用率 50%)
      父节点: [... | 50 → 页B]

    代价:
      - 1 次页分配 (新页 B)
      - 数据搬迁 (50% 的记录从 A 移到 B)
      - 父节点更新 (可能触发父节点也分裂 → 级联分裂)
      - 页利用率下降 (从 100% → 平均 62.5%)
      - 产生磁盘碎片 (新页可能不连续)

═══════════════════════════════════════════════════════════════
场景 3: 内部节点分裂(级联分裂)
═══════════════════════════════════════════════════════════════

当叶子页分裂后,父节点需要添加新的分隔键。
如果父节点也满了 → 父节点也分裂 → 可能一直传播到根节点。

根节点分裂:
  旧根: [20, 40, 60, 80] (满了)

  分裂:
    新根: [40]
    左子: [20]
    右子: [60, 80]

  → 树高 +1(这是 B+ 树高度增加的唯一方式)
  → 所有叶子深度同时 +1 → 完美平衡 ✅

级联分裂的概率:
  设每个节点最多 m 个 key,随机插入时:
    叶子分裂概率 ≈ 1/m (每 m 次插入触发一次)
    父节点分裂概率 ≈ 1/m² (每 m² 次插入)
    祖父节点分裂概率 ≈ 1/m³
    ...

  InnoDB m ≈ 1170 → 级联分裂到第 2 层的概率 ≈ 1/1,368,900
  → 几乎不会发生

页合并(Page Merge)——删除后页太空怎么办?

当一个页的利用率低于阈值(InnoDB 默认 50%,由 MERGE_THRESHOLD 控制)时,
InnoDB 尝试将该页与相邻页合并。

═══════════════════════════════════════════════════════════════
合并过程:
═══════════════════════════════════════════════════════════════

  页 A: [10, 30]        (利用率 50%,低于阈值)
  页 B: [50, 70]        (利用率 50%)

  检查: 页 A + 页 B 的记录能否放入一个页?
    [10, 30, 50, 70] → 4 条记录 ≤ 页容量 → 可以合并 ✅

  合并步骤:
    1. 将页 B 的所有记录移动到页 A
    2. 更新叶子链表: 页 A 的 next 指向页 B 的 next
    3. 从父节点删除指向页 B 的分隔键
    4. 释放页 B(标记为空闲页,加入 Free List)
    5. 如果父节点因删除而低于阈值 → 可能触发父节点合并

  结果:
    页 A: [10, 30, 50, 70]  (利用率 100%)
    页 B: 已释放

═══════════════════════════════════════════════════════════════
为什么 InnoDB 不立即合并?
═══════════════════════════════════════════════════════════════

  合并的代价:
    - 需要加锁两个页 + 父节点
    - 数据搬迁 + 链表更新
    - 可能触发级联合并

  所以 InnoDB 的策略是"延迟合并":
    - 删除记录时只标记为"已删除"(不立即物理删除)
    - 后台 purge 线程定期清理
    - 只有当页利用率真的低于 MERGE_THRESHOLD 时才合并
    - 这也是为什么 DELETE 后表空间不会立即缩小

═══════════════════════════════════════════════════════════════
分裂 vs 合并的性能影响
═══════════════════════════════════════════════════════════════

  | 操作 | 代价 | 频率 | 对性能的影响 |
  |------|------|------|-------------|
  | 叶子分裂 | 1次页分配+数据搬迁 | 每 ~m 次插入 | 插入 RT 偶尔抖动 |
  | 级联分裂 | 多次页分配 | 极罕见 | 可忽略 |
  | 页合并 | 数据搬迁+链表更新 | 大量删除后 | 后台执行,影响小 |
  | 碎片累积 | 空间浪费 | 长期运行 | OPTIMIZE TABLE 整理 |

  UUID 主键的灾难:
    随机插入 → 每次都可能触发页分裂
    页利用率平均只有 ~50-70%(对比自增主键的 ~90%+)
    空间浪费 30-50% + 大量随机 I/O

    → 这就是为什么 InnoDB 强烈建议用自增整数主键

主键设计原则:

  • 用自增整数而非 UUID:UUID 随机性导致大量页分裂 + 碎片
  • 自增主键 → 所有插入在"最右"叶子 → 顺序写 → 最优

6.4 各数据库索引选择 ​

数据库默认索引特点
MySQL InnoDBB+ 树聚簇索引 + 二级索引回表
PostgreSQLB+ 树 (Lehman-Yao 并发)非聚簇,所有索引是二级索引
MongoDB WiredTigerB+ 树类似 InnoDB
SQLiteB 树 (变体 B*-tree)轻量,文件即数据库
ElasticsearchLSM (Lucene)写优化,用 Segment 合并
RocksDBLSM写优化,Leveled Compaction

7. LSM 树 — 写优化的对数结构 ​

7.1 为什么需要 LSM? ​

B+ 树的写放大严重:

一次 INSERT:
  1. 查找插入位置 (O(log_B n) IO)
  2. 如果节点已满 → 分裂 (更多 IO)
  3. 更新可能触发页的随机写

对于 HDD: 每次随机写 ~10ms → 100 次/秒 → 太慢了

LSM 的核心思想:把随机写变成顺序写。

7.2 LSM 的结构 ​

写入路径:
  Write → MemTable (内存, 跳表/红黑树) → WAL (磁盘顺序写)
           ↓ (MemTable 满了)
        Immutable MemTable
           ↓ (后台 flush)
        SSTable Level 0 (磁盘)
           ↓ (后台 compaction)
        SSTable Level 1
           ↓ (后台 compaction)
        SSTable Level 2 ...

读取路径:
  Read → MemTable → Immutable MemTable → Level 0 (所有文件) → Level 1 → ...
         ↑ 布隆过滤器加速每个 SSTable 的检查

写放大 vs 读放大 vs 空间放大 — LSM 的三体问题:

策略写放大读放大空间放大
Leveled (RocksDB 默认)高 (10-30×)低 (每层最多查 1 个文件)低 (~10%)
Tiered (Cassandra)低 (2-5×)高 (需查多个文件)高 (50-100%)
Tiered+Leveled (DynamoDB)中中中

7.3 LSM vs B+ 树:场景选择 ​

场景推荐原因
大量写入 (日志、时序、IoT)LSM顺序写 = 100× 快于随机写
大量点查 + 偶尔写B+ 树1 次 IO 找到数据
大量范围查询B+ 树叶子链表 → 顺序扫描
既要写又要读B+ 树LSM 后台 compaction 干扰前台读
SSD 存储LSMSSD 顺序写优势大
HDD 存储B+ 树LSM 的 compaction 在 HDD 上不可接受

7.4 LSM 的 Compaction 策略 ​

策略算法使用者
LeveledN 层,每层大小固定 (×10)RocksDB 默认, LevelDB
Tiered/Size-tiered等大小的 SSTable 合并Cassandra
Universal大小相近的合并,写放大最低RocksDB 可选
FIFO只删除最老文件,无合并纯时序数据 (TTL)

8. 场景决策矩阵 ​

8.1 按读写比例选择 ​

工作负载:
  ┌──────────────────────────────────────────────────┐
  │ 100% 读 (无写)                                    │
  │   → 排序数组 + 二分查找 (最简单, 最优)             │
  │   → 或 AVL (如果必须动态)                          │
  ├──────────────────────────────────────────────────┤
  │ 90% 读 / 10% 写                                   │
  │   → AVL 树 (树最矮, 查找最快)                      │
  ├──────────────────────────────────────────────────┤
  │ 50% 读 / 50% 写                                   │
  │   → 红黑树 (插入/删除只需 O(1) 旋转)               │
  │   → 跳表 (如果需要并发)                            │
  ├──────────────────────────────────────────────────┤
  │ 10% 读 / 90% 写                                   │
  │   → LSM 树 (写的吞吐最高)                          │
  │   → 如果必须内存 → 红黑树                          │
  └──────────────────────────────────────────────────┘

8.2 多维度对比 ​

维度AVL红黑树跳表B 树B+ 树LSM 树
查找★★★★★★★★★☆★★★★☆★★★☆☆★★★★☆★★☆☆☆
插入★★★☆☆★★★★☆★★★★☆★★☆☆☆★★☆☆☆★★★★★
删除★★☆☆☆★★★★★★★★★☆★★☆☆☆★★☆☆☆★★★★★
范围查询★★★★☆★★★☆☆★★★★★★★★☆☆★★★★★★★☆☆☆
内存占用★★★★☆★★★★☆★★★☆☆★★☆☆☆★★★☆☆★★★★★
实现难度★★★★☆★★☆☆☆★★★★★★★☆☆☆★★☆☆☆★★☆☆☆
并发友好★☆☆☆☆★★☆☆☆★★★★★★☆☆☆☆★★★★☆★★★★☆
磁盘适应★☆☆☆☆★☆☆☆☆★☆☆☆☆★★★★☆★★★★★★★★★★

9. 并发场景深度分析 ​

9.1 并发读(Read-Only / Read-Mostly) ​

所有 O(log n) 结构在纯读场景下都可以做到无锁 — 读操作不修改树结构。

方案: RCU (Read-Copy-Update) 或 不可变数据结构
  - 读线程永远看到一致的树
  - 写线程创建新版本 → 原子替换根指针
  - 旧版本等待所有读线程退出后回收

Linux 内核大量使用 RCU 保护读操作,红黑树的查找完全无锁。

9.2 并发写(需要锁) ​

红黑树的并发写 — 困难 ​

旋转操作涉及 5-6 个节点 → 需要锁住这些节点的父路径 → 锁粒度粗。

c
// 红黑树并发写的两难:
// 选项 A: 全局锁 → 简单但吞吐低
// 选项 B: 细粒度锁 → 死锁风险极高(向上回溯的旋转)

// 实践中大多数系统用全局锁或读写锁保护整棵树

跳表的并发写 — 天然优势 ​

跳表的插入只修改局部节点的 forward 指针 → 可以用 CAS 实现 lock-free。

c
// Lock-free 跳表插入 (简化)
do {
    // 1. 无锁查找插入位置,记录每层的前驱节点
    update = find_predecessors(key);
    // 2. 创建新节点
    newNode = create_node(key, random_level());
    // 3. CAS 插入:
    //    从 Level 0 向上,每层用 CAS 设置 forward 指针
    newNode->forward[i] = update[i]->forward[i];
} while (!CAS(&update[i]->forward[i], newNode->forward[i], newNode));

这就是为什么 ConcurrentSkipListMap (Java) 是并发有序容器的首选。

9.3 B+ 树的并发 — Lehman-Yao 算法 ​

PostgreSQL 使用的 B-link 树(Lehman & Yao, 1981):

关键技巧: 每个内部节点有指向右兄弟的指针 (high key pointer)

查找时:
  - 只持有当前节点的读锁
  - 如果 key > node.max_key → 跟随右兄弟指针 → 不回溯!

插入时的锁策略:
  - 从根向下,只在当前操作的节点上加锁
  - 到了子节点立即释放父节点的锁 (lock coupling / crab walking)
  - 分裂时先插入 → 再分裂 → 更新父节点

保证: 任何时刻树结构一致,无死锁

9.4 并发场景总结 ​

场景推荐结构并发方案实际系统
纯读 (无写)AVL / 红黑树RCU 无锁读Linux 内核 VMA
读多写少跳表 / 红黑树读写锁epoll
读写均衡跳表Lock-free CASConcurrentSkipListMap
写多读少LSM 树无锁写 + 后台 compactionRocksDB
高并发磁盘B+ 树 (B-link)锁耦合 + 右兄弟指针PostgreSQL
纯内存高并发跳表Lock-freeRedis (单线程无需)

10. 选择指南 ​

需要什么?

┌─ 有序遍历 / 范围查询 ─────────────────────────────┐
│                                                    │
├─ 数据在内存 ───────────────────────────────────┐   │
│   ├─ 需要高并发写入 → 跳表 (Lock-free)         │   │
│   ├─ 读多写少 (>90%读) → AVL 树               │   │
│   ├─ 读写均衡 → 红黑树                         │   │
│   └─ 实现简单优先 → 跳表                       │   │
│                                                    │
├─ 数据在磁盘 ───────────────────────────────────┐   │
│   ├─ 写多读少 → LSM 树 (RocksDB)               │   │
│   ├─ 读多写少 → B+ 树 (MySQL InnoDB)           │   │
│   ├─ 范围查询多 → B+ 树                        │   │
│   └─ 嵌入式/单文件 → B 树 (SQLite)             │   │
│                                                    │
└────────────────────────────────────────────────────┘

只需要精确查找?→ Hash 表 (O(1))
只需要前缀匹配?→ Trie
需要概率去重?→ Bloom Filter
mermaid
flowchart TD
    subgraph RBT["红黑树插入修复 (6 Case)"]
        direction TB
        INS["插入红色节点 z"] --> PAREN{"z.p 是 黑色?"}
        PAREN -->|"是"| DONE["✅ 完成, 不违反任何性质"]
        PAREN -->|"否 (z.p 是红色)"| SIDE{"z.p 是 左儿子?"}
        SIDE -->|"是"| CASE123["Case1: 叔叔红→变色上行<br/>Case2: LR→左旋转Case3<br/>Case3: LL→右旋+变色"]
        SIDE -->|"否"| CASE456["Case4: 叔叔红→变色上行<br/>Case5: RL→右旋转Case6<br/>Case6: RR→左旋+变色"]
        CASE123 --> FIX["最多 2 次旋转"]
        CASE456 --> FIX
    end

    subgraph LSM["LSM 树 Compaction"]
        direction LR
        WRITE["写入 MemTable"] --> FLUSH["MemTable -> L0 SST"]
        FLUSH --> COMPACT["L0 -> L1 Compaction"]
        COMPACT --> DEEPER["L1 -> L2 -> ... -> Ln"]
    end

    style DONE fill:#4CAF50,color:#fff
    style FIX fill:#2196F3,color:#fff
    style WRITE fill:#FF9800,color:#fff
    style COMPACT fill:#9C27B0,color:#fff

11. 从复杂度到工程实现:为什么真实系统不会只看 O(log n) ​

11.1 同样是 O(log n),真实速度可能差很多 ​

树结构的复杂度看起来相近,但真实系统里影响性能的,往往不只是比较次数,还有:

  • 节点是否连续存储
  • 指针跳转多不多
  • cache miss / TLB miss 多不多
  • 插入删除时是否需要旋转、分裂、compaction
  • 并发时锁能不能做细

所以线上性能经常不是“红黑树 vs AVL 谁复杂度更优”,而是:谁更符合当前硬件和负载模型。

11.2 内存中的树为什么常输给数组/堆/哈希表 ​

大多数树节点都长这样:

text
key + value + left ptr + right ptr + parent ptr + color/balance

问题在于:

  • 节点通常分散在堆上
  • 遍历时会不断指针跳转
  • CPU 预取效果差
  • 缓存局部性明显弱于连续数组

这也是为什么:

  • 只做精确查找时,哈希表经常更快
  • 做优先级选择时,堆常比平衡树更合适
  • 数据稳定时,排序数组 + 二分查找常有很强竞争力

11.3 为什么数据库偏爱 B+ 树,内存系统更常用红黑树/跳表 ​

mermaid
flowchart LR
    A["数据主要在内存"] --> B["关注 cache、指针跳转、并发写"]
    B --> C["红黑树 / 跳表 更常见"]
    D["数据主要在磁盘或页缓存"] --> E["关注扇出、IO 次数、范围扫描"]
    E --> F["B+树 更常见"]

核心差异不是“谁更高级”,而是优化目标不同:

  • 内存结构:更怕 cache miss、锁竞争、旋转复杂度
  • 磁盘结构:更怕 I/O 次数、页分裂、范围查询成本

11.4 各系统为什么做出不同改良 ​

系统/语言结构背后原因
Linux CFS红黑树删除/插入频繁,需要稳定 O(log n)
Java ConcurrentSkipListMap跳表并发写更友好,容易做无锁/CAS
Redis ZSet跳表 + 哈希既要按 score 有序,又要按 member 快查
MySQL InnoDBB+ 树磁盘页友好、范围查询强
RocksDB MemTable跳表写入简单,flush/iterator 方便

也就是说,结构的改良往往不是为了教科书上的最优,而是为了:

  • 更适合缓存局部性
  • 更适合并发控制
  • 更适合磁盘页
  • 更适合工程实现复杂度

11.5 实战中的典型故障 ​

现象可能根因
范围查询很慢结构不适合顺序扫描
写入 RT 抖动旋转/分裂/compaction 代价大
内存占用高节点元数据和指针过多
多核扩展差全局锁或树结构调整过重
数据量变大后性能断崖工作集超出缓存/内存层级

11.6 一个判断原则 ​

text
先看数据主要在内存还是磁盘;
再看读写比例、范围查询、并发写强度;
最后再看理论复杂度。

如果只记住 O(log n),很容易选错结构;如果把局部性、并发、I/O 模式、实现复杂度一起放进来,决策会靠谱很多。


B+Tree 为什么是数据库索引的标准 ​

一个节点 = 一个磁盘页 ​

B+Tree 不是因为它"多路"所以快,而是因为它的节点大小设计成和磁盘页对齐:

text
MySQL InnoDB 默认页大小 = 16KB

B+Tree 节点 = 16KB:
  每个索引项 = key(8B) + 子节点指针(6B) = 14B
  一个节点存 ~1170 个索引项

3 层 B+Tree 能索引多少行?
  Root (1 node):  1170 个指针 → 指向 1170 个内部节点
  Level 2:        1170 个节点, 每个 1170 个指针 → 1170² = 1,368,900
  Level 3 (Leaf): 1170³ 个叶子节点, 每个叶节点存 ~100 行 → ~170M 行

→ 3 次磁盘 I/O 就能定位 1.7 亿行中的任意一行
mermaid
graph TD
    subgraph "B+Tree 物理结构"
        R["Root Node (16KB)<br/>[key1|ptr][key2|ptr]...[keyN|ptr]"]
        R --> I1["Internal Node (16KB)"]
        R --> I2["Internal Node (16KB)"]
        I1 --> L1["Leaf Node (16KB)<br/>[key|data][key|data]...<br/>→ next leaf"]
        I1 --> L2["Leaf Node (16KB)<br/>← prev | ... | next →"]
        I2 --> L3["Leaf Node (16KB)"]
    end

    subgraph "InnoDB 二级索引"
        SI["二级索引 Leaf<br/>[索引列][主键ID]"] --> PK["回表: 用主键ID<br/>查聚簇索引"]
    end

InnoDB 聚簇索引 vs 二级索引的物理布局 ​

特性聚簇索引(主键)二级索引
叶子节点存什么完整行数据索引列 + 主键值
默认创建有主键就用主键;无主键用第一个唯一非空索引;都没有就自建 6B ROW_ID手动创建
回表不需要需要(用主键值回聚簇索引查完整行)
物理顺序按主键排序存储按索引列排序存储
建议用自增 ID(避免页分裂和随机 IO)覆盖索引时不需要回表
sql
-- 聚簇索引示例
CREATE TABLE users (
    id INT PRIMARY KEY,        -- 聚簇索引:叶子存完整行
    email VARCHAR(100),
    INDEX idx_email (email)    -- 二级索引:叶子存 (email, id)
);

-- 覆盖索引:不需要回表
SELECT id, email FROM users WHERE email = 'x@y.com';
-- 二级索引 idx_email 的叶子 (email, id) 已经满足查询 → 不回表

-- 需要回表:
SELECT * FROM users WHERE email = 'x@y.com';
-- 先查 idx_email → 得到 id → 再用 id 查聚簇索引 → 得到完整行

LSM-Tree vs B+Tree 的写放大对比 ​

维度B+Tree (InnoDB)LSM-Tree (RocksDB/Cassandra)
写入方式随机写(修改磁盘页)顺序写(追加到 MemTable → SSTable)
写放大2-3×(页分裂 + Redo Log + Doublewrite)10-30×(Compaction 反复读写同一数据)
读放大1×(直接定位叶子页)5-20×(需要查 MemTable + 多层 SSTable + Bloom Filter)
空间放大低(页利用率 ~70%)中等(Compaction 前旧版本滞留)
随机读快(B+Tree 直接定位)慢(多层查找)
范围扫描快(叶子链表按序扫描)中等(需合并多个 SSTable)
适合场景OLTP(读多写少、点查)OLAP / 时序 / 日志(写密集、顺序读)
mermaid
flowchart LR
    subgraph "LSM-Tree 写入路径"
        W["PUT key=value"] --> MT["MemTable<br/>(内存, 跳表)"]
        MT -->|"满了"| SS1["SSTable Level 0<br/>(磁盘, 无序, ~8MB)"]
        SS1 -->|"Compaction"| SS2["SSTable Level 1<br/>(有序, ~80MB)"]
        SS2 -->|"Compaction"| SS3["SSTable Level N<br/>(更大)"]
    end

    subgraph "写放大来源"
        C1["Level 0→1: 10×放大"]
        C2["Level 1→2: 10×放大"]
        C3["越下层放大越大"]
    end

何时选 B+Tree,何时选 LSM-Tree? ​

text
选 B+Tree (MySQL InnoDB / PostgreSQL):
  - 读多写少, 点查为主
  - 需要事务 (ACID)
  - 需要 JOIN / 关联查询
  - 数据量 < 1TB
  - OLTP 场景 (订单、用户、账户)

选 LSM-Tree (RocksDB / Cassandra / HBase):
  - 写密集 (日志、时序、IoT 传感器)
  - 写多读少, 或读按时间范围
  - 数据量 > 1TB, 需要水平扩展
  - 不需要关联查询
  - OLAP / 时序 / 事件溯源场景

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记