yuqi-zheng

LevelDB 源码阅读:SkipList 跳表索引


Arena 那篇讲了 MemTable 的节点从哪来(内存层面)。这一篇看它们被编织成的索引结构:LevelDB 的跳表db/skiplist.h)。这是工业界里最干净的单写多读无锁数据结构范例之一,几乎每一行都是同一条规则的推论——写操作由外部加锁,读操作不加锁

如果还没读过 Arena 那篇,它是天然的前置阅读:这里的节点都是通过 arena_->AllocateAligned(...) 分配的。→ LevelDB 源码阅读:Arena 区域分配器

1. 线程安全契约

文件头把契约说得很直白(db/skiplist.h:11-14):

// Writes require external synchronization, most likely a mutex.
// Reads require a guarantee that the SkipList will not be destroyed
// while the read is in progress.  Apart from that, reads progress
// without any internal locking or synchronization.

两条推论:

  • 写(Insert 内部无锁,完全靠外部 mutex(MemTable / DB 写锁)保护。
  • Contains、迭代器 seek)完全不加锁,可多读并发——唯一要求是读期间跳表对象不被销毁。

这之所以成立,靠的是代码坚持的两个不变式:

  1. 节点在跳表存活期间绝不删除(§2)。
  2. 节点内容(除 next 指针外)不可变,新插入的节点用 release store 发布(§4)。

2. 节点常驻内存与生命周期

// (1) Allocated nodes are never deleted until the SkipList is
// destroyed.  This is trivially guaranteed by the code since we
// never delete any skip list nodes.

节点一旦分配,在跳表(也就在它的 MemTable)存活期间常驻内存、绝不单独释放;MemTable 写满刷盘(minor compaction)后,整个 Arena——连同其中的每个节点——一次性释放。

在 LevelDB 里这是自然契合:

  • 删除也是插入——写入一个”墓碑标记”键,而非移除旧节点。
  • 结构用”细粒度回收单个节点”的代价,换来了”刷盘时整体回收”,正好匹配 MemTable”攒批落盘”的生命周期。

3. 节点分配:NewNode + placement new

template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node* SkipList<Key, Comparator>::NewNode(
    const Key& key, int height) {
  char* const node_memory = arena_->AllocateAligned(
      sizeof(Node) + sizeof(std::atomic<Node*>) * (height - 1));
  return new (node_memory) Node(key);
}

逐段拆解:

  • height:该节点带几个前向(next)指针,由 RandomHeight() 随机生成,受 kMaxHeight 约束。
  • 大小计算sizeof(Node) 已含 next_[0](一个指针),所以补 (height - 1) 个指针槽,凑成 height 个前向指针。这就是经典柔性数组技巧(§5)。
  • char* constAllocateAligned 返回原始、未类型化、已对齐的字节。const 只是把这个局部指针钉死不被重新赋值,内存是可写的。
  • placement new new (node_memory) Node(key):在已分配好的内存上构造对象——不申请新内存。内存始终归 Arena 所有(整体释放),大小与对齐都被精确控制。
  • 一个 Arena 块装多个节点kBlockSize = 4096(4 KB)是块粒度,非每节点一块;几十字节的小节点背靠背打包进同一 4 KB 块,只有超过 1 KB 的大对象才单独占块。

4. 内存屏障:acquire/release 配对

这是无锁读的核心。Node 暴露四个访问器(db/skiplist.h:151-172):

Node* Next(int n) {                        // acquire 读
  return next_[n].load(std::memory_order_acquire);
}
void SetNext(int n, Node* x) {             // release 写
  next_[n].store(x, std::memory_order_release);
}
Node* NoBarrier_Next(int n) {
  return next_[n].load(std::memory_order_relaxed);
}
void NoBarrier_SetNext(int n, Node* x) {
  next_[n].store(x, std::memory_order_relaxed);
}

为何要配对:

  • release(写端):保证本次存储之前的一切(节点完整初始化——key、value、低层指针)都不重排到其之后。指针只有等节点构造完才发布。
  • acquire(读端):保证本次加载之后的一切都能看到那份完整初始化。
  • release + acquire ⇒ synchronizes-with:写线程在 release 前做的初始化,对读线程在 acquire 后保证可见。这个配对(而非 mutex)才让并发无锁遍历安全。

relaxed 只用在顺序无关处:单线程自己的指针游走,或”陈旧值无所谓”的读(如 max_height_ 的 racy 读,§10)。

5. next_[1] —— 柔性数组

std::atomic<Node*> next_[1];

尽管写成 [1],它不是单个指针,而是长度为 1 的数组充当柔性数组占位符NewNodeheight 超分配内存(§3),把 next_[0 .. height-1] 当作各层前向指针。Next(n) / SetNext(n, x) 读写的正好是第 n 层的后继。

6. 越界安全靠”纪律”而非检查

void SetNext(int n, Node* x) {
  assert(n >= 0);          // 只有下界检查,无上界检查

SetNext / Next 无上界检查,标准 C++ 下读 next_[n](n ≥ 1)属未定义行为,仅靠 §3 的超分配兜住物理内存。防止真正越界的两道纪律:

  1. Insertfor (int i = 0; i < height; i++) 上界 = 节点自身 height,而 NewNode 恰好分配了 height 个槽;
  2. 被链接在第 i 层的节点必位于第 i 层之上 ⇒ 其 height > i ⇒ 必有 next_[i]

结论:store 本身不保证不越界,物理安全靠超分配、逻辑安全靠调用纪律,二者缺一不可。

7. kMaxHeight = 12 的由来

enum { kMaxHeight = 12 };

配合分支因子 kBranching = 4(§9),节点高度 ≥ h 的概率为 (1/4)^(h-1)。第 i 层跨度期望 4^i,覆盖 N 节点需层数 ≈ log_4(N)

  • 4^12 ≈ 16,777,216 ≈ 1600 万:12 层足以高效索引约 1600 万节点。
  • 这远超任何现实 MemTable:write_buffer_size 默认 4 MB(上限 1 GB),根本到不了 1600 万节点。
  • 12 是”覆盖足够 + 不浪费指针内存”的平衡点。实际层数 max_height_ 从 1 起,仅当 RandomHeight 更高时才增长。

8. 驳”64 GB”误区

常见误区:“16 M 节点 × 4 KB = 64 GB”——错把 Arena 的 4 KB 块粒度当成每节点大小。

实际:

  • 节点结构极小(key const char* 指针 8B + next_ 平均 ~1.33 层 × 8B ≈ 19B,加对齐约 24B)。
  • 多个节点打包进同一 4 KB 块。
  • MemTable 受 write_buffer_size 封顶,到不了 1600 万节点;4^12 只是 12 层的理论覆盖能力,非真实节点数。

9. RandomHeight —— 几何分布高度

int SkipList<Key, Comparator>::RandomHeight() {
  static const unsigned int kBranching = 4;
  int height = 1;
  while (height < kMaxHeight && rnd_.OneIn(kBranching)) {
    height++;
  }
  return height;
}

OneIn(4) 每次 1/4 概率返回 true(继续加层)、3/4 概率停止。于是绝大多数节点 1 层,越高越稀有——经典”底层密、高层疏”结构。

期望高度推导。设 K = 加层次数,P(K = k) = (1/4)^k · (3/4)height = 1 + K

E[K] = Σ k·(1/4)^k·(3/4)
     = (3/4) · (1/4) / (3/4)²
     = 1/3
E[height] = 1 + 1/3 = 4/3 ≈ 1.33
  • 触达 12 层封顶的概率极低:(1/4)^11 ≈ 2.4×10⁻⁷

10. FindGreaterOrEqual —— 核心查找

bool KeyIsAfterNode(const Key& key, Node* n) const {
  return (n != nullptr) && (compare_(n->key, key) < 0);  // n->key < key?
}

Node* FindGreaterOrEqual(const Key& key, Node** prev) const {
  Node* x = head_;
  int level = GetMaxHeight() - 1;
  while (true) {
    Node* next = x->Next(level);
    if (KeyIsAfterNode(key, next)) {
      x = next;                       // 还能往右:前进
    } else {
      if (prev != nullptr) prev[level] = x;   // 记下本层前驱
      if (level == 0) return next;    // 到底层:返回第一个 ≥ key 的节点
      else level--;                   // 下沉一层
    }
  }
}

算法:

  • 从最高层出发。若 next 仍在目标之前KeyIsAfterNode 为真),往右移。
  • 否则把当前节点记为本层前驱(prev[level])并下沉。
  • 到 level 0 时,next 即第一个 ≥ key 的节点;无则 nullptr

KeyIsAfterNoden == nullptr 当 +∞(尾哨兵);对真实节点只判断 n->key < key

prev[] 是什么

prev 是每层一个槽的数组,存”在第 level 层,新节点应插在谁后面”。查找从最高层下沉,顺手把每层前驱记进 prev[]Insert 随后用标准链表链接把新节点挂到各层(new->next = prev[i].next; prev[i].next = new),省去重复查找。

示例(插入 15,高度 2):查找得 prev = {14, 10, 10}(level0/1/2),插入时把 15 挂入 level0、level1。

prev 是可选参数 —— 一个函数,两条调用路径

FindGreaterOrEqual 在单个 if (prev != nullptr) 守卫下服务两条路径:

  • Insert 调用 FindGreaterOrEqual(key, prev) —— 传数组,收集前驱;
  • 读操作ContainsIterator::Seek)调用 FindGreaterOrEqual(key, nullptr) —— 纯查找,前驱记录段整段跳过。

一个搜索函数同时服务插入与读取。注意 Insert 本身无”key 已存在就跳过”分支——去重是 MemTable 的职责,不是跳表的。一旦调用 Insert,就假定前驱数组必被使用。

11. 后续阅读方向

跳表是 MemTable 的内存索引。自然后续:

  1. LevelDB 源码阅读:MemTable —— internal key(user key + sequence + type)如何编码进节点,以及 Add / Get 实现;
  2. LevelDB 源码阅读:MemTable 刷盘与 SSTable 格式 —— mem_ 写满后发生什么、SSTable 如何落盘布局;
  3. 回到主线:MemTable → WAL (log) → SSTable (table/block) → Compaction (version_set)

Arena 那篇是这一切的地板——LevelDB 源码阅读:Arena 区域分配器

关键结论速记

主题要点
线程安全写加锁、读无锁;读只怕对象被销毁
节点生命周期常驻至 SkipList/MemTable 销毁,整体释放
分配Arena 超分配 + placement new;柔性数组 next_[1]
内存序release 写 + acquire 读配对,支撑无锁读
层数kMaxHeight=12log_4(N) 覆盖 ~1600 万
期望高度4/3 ≈ 1.33
查找高层下沉、右移 + 记录 prev[]
prev插入用各层前驱;读调用传 null 不用