【LevelDB 源码阅读】07 | MemTable 的骨架:SkipList

0 阅读34分钟

上一篇结尾停在写路径的最后一站:DBImpl::Write 里,batch 落完 WAL 之后,InsertInto 把每条记录灌进 mem_——恢复流程的终点也是它,20400 条短链一条条插回的地方。这个所有写入的必经之地、库内存里唯一可变的数据结构,内部长什么样?为什么它选了跳表,而不是红黑树或者哈希表?

答案藏在下面几个文件里:

  • db/skiplist.h(共 382 行,近四分之一是注释);
  • db/memtable.cc(共 137 行);
  • db/memtable.h(共 87 行)。

三者加起来还没有 db_impl.cc 的零头多。但这一小块代码要回答的问题一点都不少。

为什么 MemTable 选跳表,而不是红黑树或哈希表?——因为 MemTable 的需求画像是:全量有序、key 是变长字节串、单线程写多线程读、整个结构一代而亡(从不删除单个节点)。跳表用「抛硬币」代替旋转重平衡,用「新节点一次分配、只改前驱指针」代替节点原地变形,恰好把每一项需求都变成了结构性质。


MemTable 需要的是什么样的结构

我们依旧是读与写两条路径分开看。

  • 写路径,db/db_impl.cc 的 DBImpl::Write 把 batch 交给 InsertInto,最终落到 mem_->Add——每一次写入都要插一条;
  • 读路径,DBImpl::Get 先查 mem_->Get——点查一个 key;
  • flush 与用户 Iterator,MemTable::NewIterator 交出一条从小到大的 Iterator,flush 时要全量顺序扫一遍落盘。

归纳起来三个动词:插入、按序遍历、点查,外加一个硬约束:写的时候读不能停。

然后我们用这三个归纳出来的动词去看结构:

哈希表点查 O(1)O(1) 最快,但完全无序。flush 是常态路径,每次都要先全量拿出来排序——O(nlogn)O(n log n) 的额外开销挂在最频繁的后台操作上,出局。当然可以做「哈希 + 排序缓存」的混合结构,但复杂度上去了,维护难度也跟着上去了。

红黑树(或 AVL)有序、点查、插入都是 O(logn)O(log n),看起来符合需求。但有两点潜在问题。

  1. 平衡靠旋转:插入触发旋转时要改动已有节点的父子指针,一个正在无锁读的遍历者可能刚拿到节点引用,节点就被人重构了——要么给节点加锁(读写锁),要么做复杂的版本协调,两条路都无法实现「读不加锁」这个最想要的性质。
  2. 树节点上 key 怎么放?变长字节串要么塞指针二次间接访问(每节点多一次访存),要么学 B 树内嵌(定长预留,浪费)。

B 树生来是为磁盘页设计的:一个节点一页,用「扇出大」换「IO 次数少」。MemTable 在内存里,没有页的概念,节点分裂时同样要大动干戈地搬运,它的优势用不上。

跳表呢?平衡不靠旋转靠概率——插入永远不碰已有节点的内容;新节点先独立装配好(高度、key、各层指针),再把各层前驱的 next 指到它——对无锁读者来说,新节点要么完整可见、要么不可见,没有中间态;底层就是一条有序链表,迭代零成本;实现不到三百行。每一项需求都被一个具体的结构性质接住,这才是「合身」。

代码里这个选择只有一行,db/memtable.h:75:

typedef SkipList<const char*, KeyComparator> Table;

注意 key 的类型:const char*。跳表里存的不是 key 本身,是一个指针,指向 arena 里按 entry 格式编码好的一段字节。entry 里是什么,后面 MemTable::Add 那节我们详细看看。

抛硬币建平衡:跳表速览

下面是一个 9 个 key、3 层的跳表示意图:

LevelDB-Skiplist.png

读法:HEAD 是哨兵节点,每层链都从它出发;L1 是那条完整的有序链表,所有节点都在场;「住几层」是每个节点自己的属性——9 和 45 住两层,21 住三层,于是 L2 的成员是 L1 的抽样,L3 又是 L2 的抽样。要找 33:从最高层起步,L3 一步跨到 21;L2 上望一眼,下一个是 45,太远,降层;L1 上精确落脚。这个「先冲再降」的走法为什么是 O(logn)O(log n)、各层成员又是谁定的,往下看。

一条有序单链表,查找的复杂度是 O(n)O(n);给其中大约四分之一的节点加一层「快车道」,查找就能先走快车道、快到头再降一层;给快车道里大约四分之一的节点再加一层「特快」,如此向上。如果每层的节点恰好是下一层的四分之一,n 个节点的结构大约有 log4nlog_4 n 层,每层前进的期望步数不超过 4,查找期望 O(logn)O(log n)——和平衡树同阶。

难点在「恰好四分之一」怎么维持。AVL 用旋转,红黑树用变色,跳表的做法简单得有点反直觉:每个新节点插入前抛硬币,连中一次升一层,掷出 kBranching 分之一就停。不回头检查、不全局调整,赌大数定律让整体形状长期保持在期望附近。

template <typename Key, class Comparator>
int SkipList<Key, Comparator>::RandomHeight() {
  // Increase height with probability 1 in kBranching
  static const unsigned int kBranching = 4;
  int height = 1;
  while (height < kMaxHeight && ((rnd_.Next() % kBranching) == 0)) {
    height++;
  }
  assert(height > 0);
  assert(height <= kMaxHeight);
  return height;
}

这就是 db/skiplist.h:241-252 中的实现。升层概率 1/4,所以节点期望层高是 1+1/4+1/16+…=4/31 + 1/4 + 1/16 + … = 4/3,而落到第 ll 层及以上的概率是 1/4(l−1)1/4^(l-1)——n 个节点里第 ll 层期望 n/4(l−1)n/4^(l-1) 个。这两个数我们会在后面实验中拿出来核对。

层高封顶在 kMaxHeight = 12(db/skiplist.h:102)。我们不妨算一下这笔账:第 12 层要有成员,期望需要 411≈4194^{11} ≈ 419 万个节点;而一代 MemTable 默认 write_buffer_size 才 4MB,按最小 entry(十来字节的 key + 8 字节 tag + value + 节点开销,约 30 多字节)估算,撑死二十万条——离 12 层的门槛差着 20 倍。12 不是紧巴巴的约束,是富余量极大的护栏;就算骰子异常幸运真的想冲 12 层,RandomHeight 也把它摁住了,搜索始终 O(logn)O(log n)。

构造函数(db/skiplist.h:324-334)中还有一个小细节:

template <typename Key, class Comparator>
SkipList<Key, Comparator>::SkipList(Comparator cmp, Arena* arena)
    : compare_(cmp),
      arena_(arena),
      head_(NewNode(0 /* any key will do */, kMaxHeight)),
      max_height_(1),
      rnd_(0xdeadbeef) {
  for (int i = 0; i < kMaxHeight; i++) {
    head_->SetNext(i, nullptr);
  }
}

rnd_(0xdeadbeef)——随机数发生器的种子是写死的。util/random.h 里的 Random 是教科书级的 Lehmer 线性同余发生器(乘 16807、模 231−12^{31}-1),质量谈不上好,但这里要的只是「分布正确」,不是「不可预测」——内存数据结构的层高没有对抗者,没人会恶意构造让骰子失灵的 key 序列。固定种子的副产品是确定性:同一串插入永远得到同一个结构,可复现、可测试。这个性质我们马上要拿来当实验工具用。

搜索是整个结构的心脏,FindGreaterOrEqual(db/skiplist.h:260-281):

template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node*
SkipList<Key, Comparator>::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)) {
      // Keep searching in this list
      x = next;
    } else {
      if (prev != nullptr) prev[level] = x;
      if (level == 0) {
        return next;
      } else {
        // Switch to next list
        level--;
      }
    }
  }
}

从当前最高层出发,能前进就前进,不能前进就降一层,降到最底层返回第一个 ≥ key 的节点。注意 prev 这个输出参数:顺路把每一层「最后一次停留的节点」——也就是目标位置在各层的前驱——记下来。Seek 传 nullptr 不用;Insert 靠它白嫖一次搜索,后面能看到。

动手验证:高度分布与插入顺序无关

「概率平衡」听起来美好,但实际运行起来怎么样,还是要看一眼真实数据。麻烦在于 RandomHeight 和层高都是 private,外部读不到。但上一节的固定种子给了一条明路:节点的层高完全由 rnd_ 的输出序列决定,而 rnd_ 从 0xdeadbeef 起步、只被 Insert 顺序驱动——层高其实是「第 i 次插入」的函数,与插进去的 key 是什么、整体按什么顺序插,统统无关。于是可以用同种子的 Random 在外面按插入顺序逐次重放 RandomHeight,得到每个节点的真实层高。

我们可以按 util/arena.cc 的分配规则(4096 字节一个 block、8 字节对齐、每 block 记账 4096+8)推演每个节点(16 + 8×(h-1) 字节)装箱后的总用量,与真实 arena.MemoryUsage() 比对——相等则说明重放与真实结构严丝合缝。工具 skiplist_probe.cc 代码实现在附录。先看有序插入一百万个 key 的层高分布:

$ ./.output/skiplist_probe dist seq 1000000
  arena usage: predicted 18738864, actual 18738864 -> MATCH (replay verified)
  inserted 1000000 keys (seq), max height = 11
  level      nodes>=l expected(n/4^)      ratio
  1           1000000      1000000.0      1.000
  2            251081       250000.0      1.004
  3             62378        62500.0      0.998
  4             15587        15625.0      0.998
  5              3917         3906.2      1.003
  6               940          976.6      0.963
  7               236          244.1      0.967
  8                62           61.0      1.016
  9                17           15.3      1.114
  10                7            3.8      1.835
  11                2            1.0      2.097

第一行 MATCH,重放可信。分布从第 1 层到第 8 层与理论值 n/4^(l-1) 的偏差都在 ±2% 内;顶部两层 ratio 偏离大,纯粹是小样本噪声——期望 3.8 个节点,实际 7 个,涨落几个太正常了。最高层到 11,没有触碰 12 的护栏,和我们之前估计的容量也对得上。

更有意思的是换几个姿势写入。逆序(dist rev)、随机打乱(dist rnd),甚至给打乱换不同的种子——直方图逐位相同,一行都不差。这不是巧合,是上面那条推论的直接后果:高度序列只认「第 i 次插入」这个序号,不认 key。这就是有名的结论:跳表没有最坏输入顺序。对比之下,普通二叉搜索树遇到递增插入会退化成链表,查找 O(n),必须靠 AVL/红黑树这类重平衡机制兜底——跳表则完全不同,因为输入(key 序列)压根不参与结构形状的决策。

分布不敏感,性能呢?测一下纯插入耗时(bench,单位纳秒/条):

$ ./.output/skiplist_probe bench seq 1000000
  insert 1000000 keys (seq): 291 ns/op
$ ./.output/skiplist_probe bench rev 1000000
  insert 1000000 keys (rev): 108 ns/op
$ ./.output/skiplist_probe bench rnd 1000000
  insert 1000000 keys (rnd): 2461 ns/op

可以看到。

  • 降序最快(108ns):每次插入的新 key 都是最小值,FindGreaterOrEqual 从 head 的最高层出发,每层第一步就比较失败直接降层,整趟搜索从不在 head 以外的节点上停留,比较时顺带读到的各层首节点,也都是刚插入不久的热数据——缓存友好得很。
  • 升序(291ns):每次插入的新 key 都是最大值,搜索沿「右脊柱」下行,每层期望前进三四步,碰的都是刚插入不久的节点,同样缓存友好。
  • 随机(2461ns)慢了一个量级:搜索路径长度与升序同量级,但每一步都跳到 18MB arena 里的随机位置,几乎每次指针追逐都是一次缓存不命中——算法没有变慢,是访存变慢了。

当然这只是推断,没跑过 perf 之类的计数器,但三种顺序的比较次数同量级、时间却差一个量级,差值大概率在访存头上。这个对比往后看(MemTable 换成 SSTable、指针换成顺序 IO)还会反复出现。

节点:一次分配的变长积木

跳表的骨架是 Node(db/skiplist.h:145-179):

template <typename Key, class Comparator>
struct SkipList<Key, Comparator>::Node {
  explicit Node(const Key& k) : key(k) {}

  Key const key;

  // Accessors/mutators for links.  Wrapped in methods so we can
  // add the appropriate barriers as necessary.
  Node* Next(int n) {
    assert(n >= 0);
    // Use an 'acquire load' so that we observe a fully initialized
    // version of the returned Node.
    return next_[n].load(std::memory_order_acquire);
  }
  void SetNext(int n, Node* x) {
    assert(n >= 0);
    // Use a 'release store' so that anybody who reads through this
    // pointer observes a fully initialized version of the inserted node.
    next_[n].store(x, std::memory_order_release);
  }

  // No-barrier variants that can be safely used in a few locations.
  Node* NoBarrier_Next(int n) {
    assert(n >= 0);
    return next_[n].load(std::memory_order_relaxed);
  }
  void NoBarrier_SetNext(int n, Node* x) {
    assert(n >= 0);
    next_[n].store(x, std::memory_order_relaxed);
  }

 private:
  // Array of length equal to the node height.  next_[0] is lowest level link.
  std::atomic<Node*> next_[1];
};

两个成员:

  • Key const key——const 修饰,key 一旦进节点,编译器就不允许再改,前面「节点不可变」的 invariant 是语言层面的硬约束;
  • std::atomic<Node*> next_[1]——声明长度 1、实际按层高伸缩的柔性数组,next_[0] 是最底层链。

与源码口径对齐:下文中说的「第 l 层链」指的是成员为「层高 ≥ l」的节点组成的链(next_[l-1]),与工具输出的口径一致。

节点分配逻辑在 NewNode(db/skiplist.h:181-187)中:

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);
}

sizeof(Node) = 8 字节 key 指针 + 8 字节 next_[0] = 16;层高 h 的节点再追加 8×(h-1)。一次 AllocateAligned 拿到全部内存,placement new 只初始化头部——连构造 next_ 数组的开销都省了(Insert 会马上逐层覆写)。变长 key 不内嵌节点,key 是 arena 里另一段字节,节点里只放指针,于是节点大小只由层高决定、与 key 长度解耦。AllocateAligned 背后还有着一个 4096 字节一块的极简分配器,是下一篇的主角,这里我们不过多讨论。

这里有两个 C++ 写法需要留意一下,它们正是「一次分配」的机关。

先看 next_[1] 这个声明。C 里有个老技巧叫柔性数组(flexible array member):把结构体的最后一个成员声明成数组,但实际分配内存时在结构体大小之外再多要一块,让数组紧贴着结构体尾部延伸——访问 next_[i] 就是在访问这段追加的内存,数组的真实长度不写进类型,由分配时说了算。C89 年代的程序员用 arr[1](声明长度 1)玩这个技巧,俗称 struct hack;C99 转正了空方括号写法 arr[];C++ 标准两者都没收,[1] 作为变通沿用至今——严格说,越过声明长度去访问在标准里是灰色地带,靠主流编译器的善意兑现。于是 sizeof(Node) 只把 next_[0] 一个槽位算进头部,NewNode 在后面追加的 8×(height-1) 字节,正好是 next_[1..height-1] 的地盘:

LevelDB-Node_Size.png

再看 new (node_memory) Node(key) 这个奇怪的写法,它叫 placement new(放置式 new)。普通的 new Node(key) 其实干两件事:向分配器要一块内存,然后在这块内存上执行构造函数;placement new 只干第二件——内存已经在 node_memory 处备好(arena 刚批发的裸内存),它只负责把对象「放置」到这个地址上、初始化成员。分配与构造就此拆开,正好接上 arena「只批发裸内存、不管对象」的供给方式;构造函数本来也不认识 next_[1..] 那段追加空间,索性连 next_ 都不初始化,留给 Insert 马上的逐层覆写——前面说的「构造开销省了」,省的就是这笔。

语言实现上的“小魔法”看完,我们回到跳表实现上。如果看过 Redis 的跳表实现,我们会发现这个节点里没有 backward 指针,也没有 span。跳表文献(和 Redis)里常见的这两件行李,这里一件都没带。代价体现在 Iterator::Prev(db/skiplist.h:212-221)中:

template <typename Key, class Comparator>
inline void SkipList<Key, Comparator>::Iterator::Prev() {
  // Instead of using explicit "prev" links, we just search for the
  // last node that falls before key.
  assert(Valid());
  node_ = list_->FindLessThan(node_->key);
  if (node_ == list_->head_) {
    node_ = nullptr;
  }
}

想要反向走一步,得靠 FindLessThan 从头重查,时间复杂度 O(logn)O(log n)。那这多出来的开销谁能感知到?flush 扫 MemTable 用的是 SeekToFirst 加 Next,一路都是正向;那么用户拿到的 Iterator,反向查询就是一条冷路径。用每节点 8 字节外加每次插入删除的全层维护成本,去优化一个冷路径,明显不划算——当然这是我的推测,源码中并没有明说,但取舍方向和 MemTable 的使用方式对得上。

Insert:把新节点自底向上发布

template <typename Key, class Comparator>
void SkipList<Key, Comparator>::Insert(const Key& key) {
  // TODO(opt): We can use a barrier-free variant of FindGreaterOrEqual()
  // here since Insert() is externally synchronized.
  Node* prev[kMaxHeight];
  Node* x = FindGreaterOrEqual(key, prev);

  // Our data structure does not allow duplicate insertion
  assert(x == nullptr || !Equal(key, x->key));

  int height = RandomHeight();
  if (height > GetMaxHeight()) {
    for (int i = GetMaxHeight(); i < height; i++) {
      prev[i] = head_;
    }
    // It is ok to mutate max_height_ without any synchronization
    // with concurrent readers.  A concurrent reader that observes
    // the new value of max_height_ will see either the old value of
    // new level pointers from head_ (nullptr), or a new value set in
    // the loop below.  In the former case the reader will
    // immediately drop to the next level since nullptr sorts after all
    // keys.  In the latter case the reader will use the new node.
    max_height_.store(height, std::memory_order_relaxed);
  }

  x = NewNode(key, height);
  for (int i = 0; i < height; i++) {
    // NoBarrier_SetNext() suffices since we will add a barrier when
    // we publish a pointer to "x" in prev[i].
    x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
    prev[i]->SetNext(i, x);
  }
}

我们逐段来看:

开头的 FindGreaterOrEqual(key, prev) 会顺手把每层前驱存进 prev。插入位置和搜索共用同一次下降过程,没有第二遍扫描。

下面的 assert 其实相当于做了一次合法性检查(assert 无重复——注意「不允许重复」指的是 internal key 层面,同一个 user key 换个 seq 就是不同的 key,覆盖写因此是插新节点而非原地改,多版本就这么来的,后面会展开讲)。

第二段处理「新节点比现有结构都高」的情况。超过当前 max_height_ 的那几层,前驱只能是 head_,补上;然后 max_height_.store(height, std::memory_order_relaxed)——注意是 relaxed,没有同步语义。为什么敢这么操作?代码注释(351-358 行)把推导过程写得很清楚:并发的读者如果看到了新的 max_height_,它从 head_ 的新层出发,看到的 next 要么还是 nullptr——那它立刻降层,行为正确;要么已经是新发布的节点——那它直接用上新节点,也正确。看不到就更没关系,少搜一层而已。一个 stale 都无害的数据,自然不配花屏障的钱。

这里我们还需要留意一下 C++ 的写法,把 std::memory_order_relaxed 这个“黑话”翻译一下。现代编译器和 CPU 都会重排内存访问——单线程里怎么排结果都一样,可两个线程隔着内存各排各的,「A 线程先写 X 再写 Y,B 线程却先看见了 Y」这种事就真会发生。C++ 的对策是给原子变量的每次读写附一个「内存序」参数,跟编译器和 CPU 约定护栏拉到多严。这篇只用到两档:

  • relaxed,最松也最便宜:只承诺这次读/写自身不可分割——8 字节指针不会读到半个旧半个新;对它前后其他内存访问的顺序不做任何承诺,基本不产生额外指令。
  • release 写与 acquire 读,成对使用,要求更严、代价也高一些:写线程在 release 写之前的所有内存写入,保证对「随后 acquire 读到这笔值」的读线程全部可见——相当于把之前的写入随这笔写一起「打包签收」(x86 这类强内存序的 CPU 往往白送,ARM 这类弱内存序的 CPU 上才真的要插屏障)。

拿这个框架回看 max_height_:它只是个提示值,读者看到新值旧值都能走出正确行为,用不着「打包签收」的承诺,自然选最便宜的 relaxed。

最后一段是发布仪式。NoBarrier_SetNext 先把新节点各层的 next 接上前驱的后继——这些 relaxed 写永远不会被外部直接观察到,因为紧接着 prev[i]->SetNext(i, x) 用 release 把新节点的指针发布出去:任何读者经由 acquire 读到这个指针时,看到的是 key 已设好、全部 next 已接好的完整节点。循环从 i=0(最底层)向上走——自底向上,读者若从高层瞥见 x,它的低层链路必然已就绪。至此「新节点要么完整可见、要么不可见」成立,没有中间态。

开头那行 TODO(opt) 也可以留意一下:既然 Insert 有外部同步,搜索理论上可以用无屏障版本,可以更快——作者知道这条路,但留着没修。这个 TODO 直到 1.23 还留在代码里,是个不错的信号:这里的性能余量大概一直不紧张(当然这是我的推测)。

单写多读:谁在保证什么

db/skiplist.h 的文件头注释(8-26 行)是全文件最重要的一段,把并发契约写得明明白白:

// Thread safety
// -------------
//
// 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.
//
// Invariants:
//
// (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.
//
// (2) The contents of a Node except for the next/prev pointers are
// immutable after the Node has been linked into the SkipList.
// Only Insert() modifies the list, and it is careful to initialize
// a node and use release-stores to publish the nodes in one or
// more lists.

三句话:写要外部同步;读只要保证结构没有被销毁,就不需要任何内部锁;支撑这一切的是两条 invariant——节点从不删除、节点内容除 next 外不可变。跳表本体一行锁都没有。那么在 LevelDB 的实际用法里,「外部同步」「结构存在」分别由谁兑现?

写侧的答案出人意料:并不是一把从头锁到尾的 mutex 大锁。回忆 04 篇的 writer 队列——DBImpl::Write 里队首的 leader 在写 WAL 和插 MemTable 的整个阶段会放开 mutex_(db/db_impl.cc:1224-1241):

    // Add to log and apply to memtable.  We can release the lock
    // during this phase since &w is currently responsible for logging
    // and protects against concurrent loggers and concurrent writes
    // into mem_.
    {
      mutex_.Unlock();
      status = log_->AddRecord(WriteBatchInternal::Contents(write_batch));
      ...
      if (status.ok()) {
        status = WriteBatchInternal::InsertInto(write_batch, mem_);
      }
      mutex_.Lock();

注释说得很清楚:此刻 &w 是队列的 leader,这个身份本身就挡住了所有并发写——别的 writer 还在条件变量上排队呢。于是最昂贵的两步(WAL 落盘、MemTable 插入)在无锁状态下执行,互斥却依然成立。skiplist 注释里那句「most likely a mutex」是泛泛的说法,真实用法比它更巧妙:互斥由协议(leadership)保证,而非由锁保证。

读侧看 DBImpl::Get(db/db_impl.cc:1115-1160):

  MutexLock l(&mutex_);
  ...
  MemTable* mem = mem_;
  MemTable* imm = imm_;
  Version* current = versions_->current();
  mem->Ref();
  if (imm != nullptr) imm->Ref();
  current->Ref();
  ...
  // Unlock while reading from files and memtables
  {
    mutex_.Unlock();
    // First look in the memtable, then in the immutable memtable (if any).
    LookupKey lkey(key, snapshot);
    if (mem->Get(lkey, value, &s)) {
      // Done
    } else if (imm != nullptr && imm->Get(lkey, value, &s)) {
      ...
    mutex_.Lock();
  }
  ...
  mem->Unref();

持锁窗口内只做三件事:取三个指针、Ref() 记引用、放锁。真正的跳表查找(mem->Get)在锁外裸跑。「结构不被析构」由引用计数兜底(db/memtable.h:30-39:Unref 到零才 delete this)——哪怕后台线程此刻正把写满的 MemTable 换代,只要读者还捏着一个引用,跳表就活着。

这套「读不加锁」的正确性不是拍脑袋定出来的,db/skiplist_test.cc 里有专门的压力测试盯着它。ConcurrentTest 类的注释(128-135 行)给出了精确的正确性定义:并发读可能多看到新插入的值(新节点恰好在 Iterator 越过之前发布),但绝不漏 Iterator 构造时已存在的值——因为节点不删除、内容不变,已发布的链路永远有效。测试用 4 个轮转的 key 配递增代次号和校验和跑长时间压测(Concurrent1 到 Concurrent5,skiplist_test.cc:362-366)。

关于内存序,收个尾,把全篇用到的几处放进 Insert 一节那个「两档」框架里过一遍:SetNext 的 release 与 Next 的 acquire 配对,是唯一的「打包签收」——负责「完整节点」的可见性,读者一旦经某层指针读到节点,key 和自己能走到的各层 next 就都已就位;GetMaxHeight 与 max_height_.store 的 relaxed,服务一个允许读到旧值的提示;NoBarrier_SetNext 的 relaxed,写的是「发布之前没有读者能到达」的字段——新节点自己的 next_ 先松着写;NoBarrier_Next 读前驱的 next 也敢松,靠的是另一个前提:写者只有一个,读自己写下的值,程序顺序天然作数。最后统一由那道 release 打包生效。理论没有铺开(铺开的话体量太大),想系统了解,可以参考 cppreference 的 std::memory_order 条目,以及 Anthony Williams《C++ Concurrency in Action》第 5 章「The C++ memory model and atomic operations」。这条线头后来专门写了一篇番外(E01《番外:内存屏障——同一个发布问题,四门方言》),从 C++、Java、Go、Rust 四门语言各自的内存模型一路讲到落在 CPU 上的真实指令,适合读完本篇后顺路一看。

MemTable::Add:entry 编码

跳表是骨架,entry 是血肉。MemTable::Add(db/memtable.cc:76-99):

void MemTable::Add(SequenceNumber s, ValueType type, const Slice& key,
                   const Slice& value) {
  // Format of an entry is concatenation of:
  //  key_size     : varint32 of internal_key.size()
  //  key bytes    : char[internal_key.size()]
  //  value_size   : varint32 of value.size()
  //  value bytes  : char[value.size()]
  size_t key_size = key.size();
  size_t val_size = value.size();
  size_t internal_key_size = key_size + 8;
  const size_t encoded_len = VarintLength(internal_key_size) +
                             internal_key_size + VarintLength(val_size) +
                             val_size;
  char* buf = arena_.Allocate(encoded_len);
  char* p = EncodeVarint32(buf, internal_key_size);
  std::memcpy(p, key.data(), key_size);
  p += key_size;
  EncodeFixed64(p, (s << 8) | type);
  p += 8;
  p = EncodeVarint32(p, val_size);
  std::memcpy(p, value.data(), val_size);
  assert(p + val_size == buf + encoded_len);
  table_.Insert(buf);
}

entry = varint32 的 internal_key 长度 + internal key + varint32 的 value 长度 + value。internal key = user key + 8 字节 tag,tag 是 (seq << 8) | type 打包成的 64 位——56 位序列号、8 位类型,一口气同时把「第几版」和「值还是墓碑」塞进一个整数。长度算好后一次 arena_.Allocate、一次拼装、table_.Insert(buf) 插进跳表的是这块 arena 内存的指针。

这个 type 应该眼熟——它就是 05 篇 WriteBatch 里每条 record 打头的那个 1 字节 tag:kTypeValue(0x1)或 kTypeDeletion(0x0),定义在 db/dbformat.h:54 的 ValueType 枚举里。WriteBatchInternal::InsertInto 解开每条 record 后按 tag 分发到回调,Put 回调以 kTypeValue、Delete 回调以 kTypeDeletion 调 mem_->Add(db/write_batch.cc:121-128)——数值一分未改,同一个值就这么从 batch 一路带进 entry 的 tag 低 8 位,将来还会跟着 entry 进 SSTable。需要区分的是 06 篇 WAL 物理 record 头里那个 type——FULL/FIRST/MIDDLE/LAST 说的是「这条物理分片与逻辑 record 的关系」,属于 log 格式自己的记账,留在日志文件里,不进 MemTable,跟这里的 ValueType 是两回事。

拿 05、06 篇一路用的三合一 batch(seq 1-3)手工推演一遍字节。seq 1,c:Ab3xK = "https://example.com/a-very-long-url"(35 字节):

0f                              internal_key_size = 7 + 8 = 15
63 3a 41 62 33 78 4b            "c:Ab3xK"
01 01 00 00 00 00 00 00         tag = (1 << 8) | kTypeValue = 0x0101(小端)
23                              value_size = 35
68 74 74 70 73 3a 2f 2f ...     "https://example.com/a-very-long-url"

合计 1+7+8+1+35 = 52 字节。seq 2 和 3 同理:u:robin:c:Ab3xK 15 字节 user key,internal_key_size 23(varint 17),tag 0x0201,value 1,共 26 字节;c:Ab3xK:click 13 字节,tag 0x0301,value 0,共 24 字节。和 06 篇 hexdump 里 WriteBatch 的编码逐字段对得上——同一批数据,换了件外衣(batch 是 type + varint(key) + varint(value),entry 是 varint(key+tag) + varint(value),多出的就是那个 8 字节 tag)。

覆盖写呢?没有任何特殊待遇:seq 17 再写 c:Ab3xK = "https://example.com/updated",就是一条全新的 entry(44 字节,tag 0x1101)插进跳表,和 seq 1 那条并存,新旧版本的排序关系交给比较器。没有任何节点被修改、被删除——「更新」在这个结构里不存在,只有「追加」。

读回来时,MemTableIterator(db/memtable.cc:46-72)负责把 entry 解包:key() 用 GetLengthPrefixedSlice 跳过长度前缀取出 internal key,value() 再顺藤摸瓜取第二段。Seek 则用 EncodeKey 给目标加 varint 长度前缀——为什么查找也要这么包一下,下一节揭晓,这其实是个挺妙的设计。

查找:LookupKey 与比较器链

跳表只认一个比较器。MemTable::KeyComparator(db/memtable.cc:28-34)先把两个 const char* 解成长度前缀 Slice,转手交给 InternalKeyComparator::Compare(db/dbformat.cc:47-63):

int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const {
  // Order by:
  //    increasing user key (according to user-supplied comparator)
  //    decreasing sequence number
  //    decreasing type (though sequence# should be enough to disambiguate)
  int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey));
  if (r == 0) {
    const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8);
    const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8);
    if (anum > bnum) {
      r = -1;
    } else if (anum < bnum) {
      r = +1;
    }
  }
  return r;
}

两级排序:user key 升序;相同 user key 内部,tag 数值大的排前面——即序列号大的(新的)在前。于是同一个 user key 的多个版本在链上聚成一段,最新的打头。这条规则是 MemTable、SSTable、Compaction 共用的铁律,后面讲 internal key 时会再遇到它,这里先了解一下即可。

查找的钥匙是 LookupKey,构造函数在 db/dbformat.cc:117-134:

LookupKey::LookupKey(const Slice& user_key, SequenceNumber s) {
  size_t usize = user_key.size();
  size_t needed = usize + 13;  // A conservative estimate
  char* dst;
  if (needed <= sizeof(space_)) {
    dst = space_;
  } else {
    dst = new char[needed];
  }
  start_ = dst;
  dst = EncodeVarint32(dst, usize + 8);
  kstart_ = dst;
  std::memcpy(dst, user_key.data(), usize);
  dst += usize;
  EncodeFixed64(dst, PackSequenceAndType(s, kValueTypeForSeek));
  dst += 8;
  end_ = dst;
}

拼出来的是 varint(usize+8) + user key + tag,其中 tag 用 (snapshot << 8) | kValueTypeForSeek。对比一下 entry 的前半段:varint(usize+8) + user key + tag——一模一样。这就是前面卖的关子:LookupKey 的 memtable_key() 恰好是一条「没有 value 的 entry」的前缀,所以 MemTable::Get(db/memtable.cc:101-135)可以直接 iter.Seek(memkey.data()),比较器解长度前缀时完全不用区分「真 entry」和「查找钥匙」;MemTableIterator::Seek 里那个 EncodeKey 加前缀的动作,干的是同一件事。构造函数里还能看到 space_[200]——短 key 的查找钥匙直接落在栈上,连 new 都省了。

tag 里那个 kValueTypeForSeek 有个专门的注释(db/dbformat.h:55-61),解释为什么偏偏选编号最大的 kTypeValue:同一 user key 内按 tag 降序排,type 在 tag 的低 8 位,要找到「序号 ≤ snapshot 的第一个版本」,查找钥匙必须排在这一版的前面、又排在所有更大 seq 版本的后面——取最大的 type 编号恰好保证这一点。(seq 相同而 type 不同的两条记录理论上不区分先后,实际也不存在:05 篇看过,同一 batch 里每个操作分得不同的 seq。)

最后是 MemTable::Get 的返回语义,一个布尔带出三种结局:

bool MemTable::Get(const LookupKey& key, std::string* value, Status* s) {
  Slice memkey = key.memtable_key();
  Table::Iterator iter(&table_);
  iter.Seek(memkey.data());
  if (iter.Valid()) {
    // ...
    // Check that it belongs to same user key.  We do not check the
    // sequence number since the Seek() call above should have skipped
    // all entries with overly large sequence numbers.
    const char* entry = iter.key();
    uint32_t key_length;
    const char* key_ptr = GetVarint32Ptr(entry, entry + 5, &key_length);
    if (comparator_.comparator.user_comparator()->Compare(
            Slice(key_ptr, key_length - 8), key.user_key()) == 0) {
      // Correct user key
      const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8);
      switch (static_cast<ValueType>(tag & 0xff)) {
        case kTypeValue: {
          Slice v = GetLengthPrefixedSlice(key_ptr + key_length);
          value->assign(v.data(), v.size());
          return true;
        }
        case kTypeDeletion:
          *s = Status::NotFound(Slice());
          return true;
      }
    }
  }
  return false;
}

Seek 落在 user key 相同(注释说了,不用检查 seq,Seek 的排序已经把 seq 超过 snapshot 的版本跳过去了),看 tag 低 8 位:kTypeValue 返回 true、value 填好;kTypeDeletion 返回 true 但 Status 是 NotFound——墓碑在这代 MemTable 命中即止,告诉调用方别再往下找了;Seek 落在 user key 不同(或表为空),返回 false——这个 key 不在我这代,去别处找。回到 DBImpl::Get(db/db_impl.cc:1140-1148),三种状态正好接上漏斗:mem->Get 为 false 时问 imm->Get,还为 false 时问 current->Get(SSTable)。「读路径逐级下探」的完整故事留给后面的文章,这里我们只看到第一级就好。

短链的跳表:17 个 key 的实景

原理说完,再回到我们的短链服务,把真实数据插进去看一看。skiplist_probe sketch 重建一个跳表:先来 05 篇三合一 batch 的三条(seq 1-3),再补上 c:K00000001 到 c:K00000012(seq 4-15)和一条用户索引(seq 16),最后对 c:Ab3xK 做一次覆盖写(seq 17)——共 17 条:

$ ./.output/skiplist_probe sketch
sketch: 17 keys, arena usage: predicted 4104, actual 4104 -> MATCH (replay verified)

L0 order (insertion index. key @seq -> height):
  #0. i16 c:Ab3xK                @17 h=3
  #1. i0 c:Ab3xK                @1  h=2
  #2. i2 c:Ab3xK:click          @3  h=1
  #3. i3 c:K00000001            @4  h=1
  ...
  #8. i8 c:K00000006            @9  h=2
  ...
  #14. i14 c:K00000012            @15 h=2
  #15. i1 u:robin:c:Ab3xK        @2  h=1
  #16. i15 u:robin:c:K00000001    @16 h=1

per-level chains (member = insertion index):
  L1 : i16 i0 i2 i3 i4 i5 i6 i7 i8 i9 i10 i11 i12 i13 i14 i1 i15
  L2 : i16 i0 i8 i14
  L3 : i16

(完整输出 17 行,中间的 c:K… 都是 h=1,省略。i 编号是插入顺序:i0 是 seq 1 那条,i16 是 seq 17 覆盖写。)画成图,第 l 层链的成员是层高 ≥ l 的节点:

LevelDB-Skiplist_Example.png

图例(沿链序):A17 = c:Ab3xK@17(覆盖写,h=3)、A1 = c:Ab3xK@1(h=2)、C = c:Ab3xK:click@3、K1..K12 = c:K00000001..12(其中 K6、K12 是 h=2,其余 h=1)、U1 = u:robin:c:Ab3xK@2、U2 = u:robin:c:K00000001@16。L1 与 L0 是同一条链(所有节点至少 h=1),图里就不重复画了。

对着图我们再回头去看前面的所有论断:

层高比例。第 2 层 4 个节点,期望 17/4 ≈ 4.25;第 3 层 1 个,期望 17/16 ≈ 1.06。对上了。i16 抽到 h=3 纯粹是这个固定种子第 17 次「抛硬币」连中三下的结果,跟它是不是覆盖写、key 是什么都无关——把 17 个 key 全换成 zzz,层高一模一样。

覆盖写的位置。A17 和 A1 同 user key、在链上紧挨着,新 seq 在前——Get("c:Ab3xK", snapshot=17) 的 Seek 落在 A17,拿到 updated;换个旧 Snapshot(seq 落在 1-16 之间),查找钥匙的 tag 更小、排得更靠后,Seek 越过 A17 落在 A1,拿到旧值。两条 entry 并存,谁也没覆盖谁——多版本读取的全部机关就在这一段的排序上。

搜索的跳法。以 Get("u:robin:c:Ab3xK", snapshot=17) 为例:查找钥匙 17 | u:robin:c:Ab3xK | tag(17)。从 L3 出发,A17(c: 开头)小于目标,前进到 A17;它 L3 的 next 是 null,降到 L2;沿 L2 走 A1、K6、K12(都是 c: 开头,小于目标);K12 的 L2 next 是 null,降到 L1;下一个是 U1——user key 相同,U1 的 tag(0x0201)数值上小于查找钥匙的 tag(0x1101),而 tag 大者排前,U1 排在查找钥匙之后,不再前进,逐层降到底,落点正是 U1。全程 4 次前进,其中 L2 一层就飞越了 12 个 c:K 节点——快车道的效果肉眼可见。

再来看看 arena。head 104 字节(16 + 8×11)+ i16 的 32 + i0 的 24 + 其余 15 个节点各 16 = 400 字节,一个 4096 字节的 block 装完,MemoryUsage() 记 4104。数字不大,但我们需要了解记账的方式(按 block、含指针开销),后面会经常用到。

对比参照:Redis 的 zskiplist

同样的数据结构,Redis 的 zskiplist 带了两件 LevelDB 没带的行李:每个节点一个 backward 指针(外加 zskiplist 结构的 tail 指针),支持从尾部反向遍历——ZREVRANGE 就靠它;每层一个 span(本节点到 next 之间的跨度),各级 span 沿途累加就是排名——ZRANK、ZRANGEBYRANK 的底座。代价是每次插入、删除都要全层维护这两个字段,节点也需要占用更多空间。

为什么 LevelDB 一个都不需要?还是那句话:约束不同,设计不同。

功能面不同。Redis 的 zset 是一等产品,反向区间、按排名取集合是高频 API,backward 和 span 是刚需;LevelDB 的 MemTable 只服务三个动词——插入、正向遍历、点查,Prev 是几乎没人走的冷路径(走也就重查一遍,O(logn)O(log n) 换 8 字节/节点和一身维护逻辑,不划算)。

生命周期不同,这也是更本质的一条原因。zset 是常驻结构:活几分钟到几个月,ZADD/ZREM 持续地在中间增删——删除一个节点要回填前后指针和全层 span,这些复杂度躲不掉。MemTable 是一代而亡的结构:从空开始只插不删(「删除」是插一条墓碑 entry),长到 4MB 就被冻结、整体 flush、整体释放。「节点删除」这个最麻烦的操作在 MemTable 的生命周期里根本不存在——不是被优化了,是在设计上就被优化掉了。

还有一处结构形态的差异:Redis 用双结构——小 zset 走 ziplist/listpack 省内存,大了才升级 skiplist + dict(dict 管点查,skiplist 管范围);MemTable 永远是单独一个跳表。因为 MemTable 压根活不到需要「为长期驻留做形态切换」的那天,4MB 就是它的一生,简单结构从头用到尾。

工程启示

用好 LevelDB:

write_buffer_size 计的是 arena 字节而非用户数据量:entry 除 key 与 value 外还有 varint 前缀、8 字节 tag、节点开销与 block 碎片,4MB 的默认写缓冲装不下 4MB 用户数据,value 越小、key 越短,固定开销占比越高。实测可用 DBImpl::GetProperty("approximate-memory-usage")(db/db_impl.cc:1431-1443,返回 block cache 加上 mem/imm 两个 MemTable 的 arena 用量)估算——这笔账我们下一篇会详细算。

设计借鉴:

  1. 单写者 + 原子发布 + 无锁读,是一个比「全无锁」简单一个数量级、覆盖面却极广的并发模式。跳表本身零锁零等待,互斥被推到上层协议(writer 队列的 leadership),读写之间的协调压缩成一对 release/acquire。识别出「其实只有一个写者」的场景,往往是并发设计里收益最大的一步。

  2. 「不可变 + 不删除」是简化并发的利器。代价也明确:内存只增不减、旧版本与新版本并存。LevelDB 把这笔代价整体外移给生命周期管理——arena 只管批发、换代时整块回收,连析构单个节点的代码都不用写。很多「并发对象怎么安全删除」的难题,根源是对象活得太久;让一批对象同生共死,问题会自己消失。

  3. 选数据结构看生命周期与功能面,不只看复杂度。跳表和红黑树同为 O(logn)O(log n),纸面打平;但「新节点独立装配、只改前驱指针」这个性质,在无锁读场景下是决定性的,而「不需要 rank、不需要反向遍历」的功能面又让 span/backward 无处发挥。把约束列成需求画像再选型,比背复杂度表更可靠。


回到开篇的问题。为什么是跳表?因为 MemTable 要的是有序、变长 key、单写多读、一代而亡——跳表的每一项结构性质都精确对上了一条需求:概率平衡对上「无最坏输入」,一次分配的独立新节点对上「无锁读」,底层有序链表对上「flush 正向扫描」,不删除对上「整体淘汰」。

但「不删除」三个字现在听来有点悬了:c:Ab3xK 被覆盖写一万次,就有一万条 entry 越积越多,内存只涨不跌,谁受得了?这些字节什么时候、由谁、按什么规则回收?MemTable 写满换新之后,旧的那一代在哪里等待、什么时候真正死去?还有那个反复出现的 arena——4096 字节一块、每块记 4104 的分配器,凭什么敢把内存管理做这么糙?

下一篇,Arena 与 MemTable 的生与死。


附录:本篇示例与实验代码

skiplist_probe.cc:跳表观察工具。核心思路是把层高「重放」出来——RandomHeight 是 private,但层高完全由固定种子 rnd_(0xdeadbeef) 与插入顺序决定,用同种子的 Random 逐次复刻即可;再用 ArenaPredictor 按 arena.cc 的装箱规则推演内存用量,与真实 arena.MemoryUsage() 对账(MATCH)证明重放忠实。dist 输出层高分布与理论值对照,bench 测三种插入顺序的耗时,sketch 按 MemTable::Add 的 entry 编码插入 17 条短链(含一次覆盖写),打印每个节点的 key/seq/层高与每层链的成员。

// skiplist_probe.cc:观察真实 SkipList 的层高分布与结构——
//   dist seq <n>         顺序插入 0..n-1,输出层高分布与逐层节点数;
//   dist rev <n>         逆序插入 n-1..0;
//   dist rnd <n> [seed]  随机打乱后插入(默认 seed=1000);
//   bench seq|rev|rnd <n> [seed]  只测插入耗时(ns/op),对照结构开销;
//   sketch               插入 05 篇"三合一"batch 及若干短码(含一次覆盖写),
//                        逐节点打印 user key/seq/层高,并给出每层的后继链。
//
// 层高观测原理:RandomHeight 是 private,外部读不到。但每个节点的层高
// 完全由 SkipList 构造时固定的 rnd_(0xdeadbeef)(skiplist.h:330)与插入
// 顺序决定——用同一个 util/random.h 的 Random、同 seed、同插入顺序重放
// RandomHeight(skiplist.h:242-252 的逐行复刻),得到的层高即真实层高。
// 为证明重放忠实,工具按 arena.cc 的分配规则(4096B block、8 对齐、
// 每 block 记 4096+8 字节,arena.cc:9、58-64)推演逐节点
// (sizeof(Node)+8*(h-1),skiplist.h:184-185)装箱后的 Arena 总用量,
// 与真实 arena.MemoryUsage() 对账:一致则重放与真实结构严丝合缝。
//
// 用法示例:
//   ./.output/skiplist_probe dist seq 1000000
//   ./.output/skiplist_probe dist rnd 1000000
//   ./.output/skiplist_probe bench seq 1000000
//   ./.output/skiplist_probe sketch
// 编译(仓库根目录,需要额外 -I leveldb-1.23 以使用内部头文件):
//   clang++ -std=c++17 -O2 -I leveldb-1.23 -I leveldb-1.23/include \
//           examples/shorturl/skiplist_probe.cc .output/build/libleveldb.a \
//           -o .output/skiplist_probe
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>

#include "db/skiplist.h"
#include "util/arena.h"
#include "util/coding.h"
#include "util/random.h"

namespace {

// ---- 与 skiplist.h 一致的两个常量(skiplist.h:102、244)----
constexpr int kMaxHeight = 12;
constexpr unsigned int kBranching = 4;

// 逐行复刻 SkipList::RandomHeight(skiplist.h:242-252)。
// SkipList 构造函数把 rnd_ 固定初始化为 0xdeadbeef(skiplist.h:330),
// 用同 seed 的 Random 按插入顺序重放,得到的层高与真实结构一致。
int ReplayHeight(leveldb::Random* rnd) {
  int height = 1;
  while (height < kMaxHeight && ((rnd->Next() % kBranching) == 0)) {
    height++;
  }
  return height;
}

// 按 arena.cc 推演装箱:所有节点尺寸都是 8 的倍数、最大 104B < 1024B,
// 只会走"装不下就开新 4096B block"的常规路径(arena.cc:20-36)。
// 每个 block 计 4096 + sizeof(char*) = 4104 字节(arena.cc:61-63)。
class ArenaPredictor {
 public:
  void Alloc(size_t bytes) {
    if (bytes > alloc_bytes_remaining_) {
      memory_usage_ += 4096 + sizeof(char*);
      alloc_bytes_remaining_ = 4096;
    }
    alloc_bytes_remaining_ -= bytes;
  }
  size_t usage() const { return memory_usage_; }

 private:
  size_t memory_usage_ = 0;
  size_t alloc_bytes_remaining_ = 0;
};

// uint64 key 的比较器(与 skiplist_test.cc:23-33 相同)
struct U64Comparator {
  int operator()(const uint64_t& a, const uint64_t& b) const {
    if (a < b) return -1;
    if (a > b) return +1;
    return 0;
  }
};

using NumList = leveldb::SkipList<uint64_t, U64Comparator>;

// 节点尺寸:Key(8B) + next_[1](8B) + 8*(h-1)(skiplist.h:176-185)
size_t NodeBytes(int height) { return 16 + 8 * (height - 1); }

// 建真实跳表 + 重放层高 + 对账,返回每个节点的层高(按插入顺序)
std::vector<int> BuildAndReplay(NumList* list, const std::vector<uint64_t>& keys,
                                leveldb::Arena* arena, bool verify) {
  leveldb::Random rnd(0xdeadbeef);
  ArenaPredictor predictor;
  predictor.Alloc(NodeBytes(kMaxHeight));  // head_ 节点,固定 12 层
  std::vector<int> heights;
  heights.reserve(keys.size());
  for (uint64_t k : keys) {
    list->Insert(k);
    int h = ReplayHeight(&rnd);
    heights.push_back(h);
    predictor.Alloc(NodeBytes(h));
  }
  if (verify) {
    size_t predicted = predictor.usage();
    size_t actual = arena->MemoryUsage();
    std::printf("  arena usage: predicted %zu, actual %zu -> %s\n", predicted,
                actual, predicted == actual ? "MATCH (replay verified)"
                                            : "MISMATCH!");
    if (predicted != actual) std::exit(1);
  }
  return heights;
}

int DoDist(const std::string& mode, long n, unsigned seed) {
  std::vector<uint64_t> keys(n);
  for (long i = 0; i < n; i++) keys[i] = static_cast<uint64_t>(i);
  if (mode == "rev") {
    std::reverse(keys.begin(), keys.end());
  } else if (mode == "rnd") {
    // Fisher-Yates 打乱,随机源用 util/random.h 的 Uniform
    leveldb::Random r(seed);
    for (size_t i = keys.size(); i > 1; i--) {
      size_t j = r.Uniform(static_cast<int>(i));
      std::swap(keys[i - 1], keys[j]);
    }
  }

  leveldb::Arena arena;
  U64Comparator cmp;
  NumList list(cmp, &arena);
  auto heights = BuildAndReplay(&list, keys, &arena, /*verify=*/true);

  std::vector<long> at_level(kMaxHeight + 1, 0);  // at_level[l] = 层高>=l 的节点数
  int max_h = 0;
  for (int h : heights) {
    for (int l = 1; l <= h; l++) at_level[l]++;
    if (h > max_h) max_h = h;
  }
  std::printf("  inserted %ld keys (%s), max height = %d\n", n, mode.c_str(),
              max_h);
  std::printf("  %-8s %10s %14s %10s\n", "level", "nodes>=l", "expected(n/4^)", "ratio");
  for (int l = 1; l <= kMaxHeight; l++) {
    if (at_level[l] == 0) break;
    double expect = static_cast<double>(n) / std::pow(4.0, l - 1);
    std::printf("  %-8d %10ld %14.1f %10.3f\n", l, at_level[l], expect,
                at_level[l] / expect);
  }
  return 0;
}

int DoBench(const std::string& mode, long n, unsigned seed) {
  std::vector<uint64_t> keys(n);
  for (long i = 0; i < n; i++) keys[i] = static_cast<uint64_t>(i);
  if (mode == "rev") {
    std::reverse(keys.begin(), keys.end());
  } else if (mode == "rnd") {
    leveldb::Random r(seed);
    for (size_t i = keys.size(); i > 1; i--) {
      size_t j = r.Uniform(static_cast<int>(i));
      std::swap(keys[i - 1], keys[j]);
    }
  }
  leveldb::Arena arena;
  U64Comparator cmp;
  NumList list(cmp, &arena);
  auto t0 = std::chrono::steady_clock::now();
  for (uint64_t k : keys) list.Insert(k);
  auto t1 = std::chrono::steady_clock::now();
  double ns =
      std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count() /
      static_cast<double>(n);
  std::printf("  insert %ld keys (%s): %.0f ns/op\n", n, mode.c_str(), ns);
  return 0;
}

// ---- sketch:按 MemTable::Add 的 entry 编码插入短码,观察结构 ----

// 与 memtable.cc:14-19 相同:解长度前缀 Slice
leveldb::Slice GetLengthPrefixedSlice(const char* data) {
  uint32_t len;
  const char* p = leveldb::GetVarint32Ptr(data, data + 5, &len);
  return leveldb::Slice(p, len);
}

// 与 MemTable::KeyComparator -> InternalKeyComparator 同语义:
// user key 升序、(seq<<8|type) 降序(memtable.cc:28-34、dbformat.cc:47-63)。
// 注意 user key 是去掉尾部 8 字节 tag 的前缀(dbformat.h:95-98)
struct InternalKeyCmp {
  int operator()(const char* a, const char* b) const {
    leveldb::Slice ka = GetLengthPrefixedSlice(a);
    leveldb::Slice kb = GetLengthPrefixedSlice(b);
    size_t ua = ka.size() - 8, ub = kb.size() - 8;
    int r = memcmp(ka.data(), kb.data(), std::min(ua, ub));
    if (r == 0) {
      if (ua < ub)
        r = -1;
      else if (ua > ub)
        r = +1;
    }
    if (r == 0) {
      uint64_t na = leveldb::DecodeFixed64(ka.data() + ua);
      uint64_t nb = leveldb::DecodeFixed64(kb.data() + ub);
      r = (na > nb) ? -1 : (na < nb) ? +1 : 0;
    }
    return r;
  }
};

using StrList = leveldb::SkipList<const char*, InternalKeyCmp>;

// 与 MemTable::Add 相同的 entry 拼装(memtable.cc:76-99),但 entry 本体
// 存在 std::string 里(本实验只关心节点层高,entry 是否入 arena 不影响)
std::string EncodeEntry(uint64_t seq, uint8_t type, const std::string& user_key,
                        const std::string& value) {
  size_t internal_key_size = user_key.size() + 8;
  std::string buf;
  leveldb::PutVarint32(&buf, internal_key_size);
  buf.append(user_key);
  char tag[8];
  leveldb::EncodeFixed64(tag, (seq << 8) | type);
  buf.append(tag, 8);
  leveldb::PutVarint32(&buf, value.size());
  buf.append(value);
  return buf;
}

int DoSketch() {
  struct Item {
    uint64_t seq;
    std::string key, val;
  };
  // 05 篇三合一 batch(seq 1-3)+ 若干后续写入;第 17 条是对 c:Ab3xK
  // 的覆盖写(新 seq 排在同 user key 前面,10 篇的主角在此露一小脸)
  const std::vector<Item> items = {
      {1, "c:Ab3xK", "https://example.com/a-very-long-url"},
      {2, "u:robin:c:Ab3xK", "1"},
      {3, "c:Ab3xK:click", "0"},
      {4, "c:K00000001", "https://example.com/00000001"},
      {5, "c:K00000002", "https://example.com/00000002"},
      {6, "c:K00000003", "https://example.com/00000003"},
      {7, "c:K00000004", "https://example.com/00000004"},
      {8, "c:K00000005", "https://example.com/00000005"},
      {9, "c:K00000006", "https://example.com/00000006"},
      {10, "c:K00000007", "https://example.com/00000007"},
      {11, "c:K00000008", "https://example.com/00000008"},
      {12, "c:K00000009", "https://example.com/00000009"},
      {13, "c:K00000010", "https://example.com/00000010"},
      {14, "c:K00000011", "https://example.com/00000011"},
      {15, "c:K00000012", "https://example.com/00000012"},
      {16, "u:robin:c:K00000001", "1"},
      {17, "c:Ab3xK", "https://example.com/updated"},  // 覆盖写:新 seq 排前
  };

  leveldb::Arena arena;
  InternalKeyCmp cmp;
  StrList list(cmp, &arena);

  leveldb::Random rnd(0xdeadbeef);
  ArenaPredictor predictor;
  predictor.Alloc(NodeBytes(kMaxHeight));  // head_:Key 为 const char*,同为 16+8*11

  std::vector<std::string> storage;
  std::vector<int> heights;
  std::vector<const char*> ptrs;
  for (const auto& it : items) {
    storage.push_back(EncodeEntry(it.seq, 1 /*kTypeValue*/, it.key, it.val));
    const char* entry = storage.back().data();
    list.Insert(entry);
    int h = ReplayHeight(&rnd);
    heights.push_back(h);
    predictor.Alloc(NodeBytes(h));
    ptrs.push_back(entry);
  }
  size_t predicted = predictor.usage();
  size_t actual = arena.MemoryUsage();
  std::printf("sketch: %zu keys, arena usage: predicted %zu, actual %zu -> %s\n",
              items.size(), predicted, actual,
              predicted == actual ? "MATCH (replay verified)" : "MISMATCH!");
  if (predicted != actual) return 1;

  // 按 L0 顺序输出节点(key/seq/层高),并给每层后继链(用序号)
  StrList::Iterator iter(&list);
  std::vector<const char*> ordered;
  for (iter.SeekToFirst(); iter.Valid(); iter.Next()) ordered.push_back(iter.key());

  auto idx_of = [&](const char* p) -> int {
    for (size_t i = 0; i < ptrs.size(); i++)
      if (ptrs[i] == p) return static_cast<int>(i);
    return -1;
  };

  std::printf("\nL0 order (insertion index. key @seq -> height):\n");
  int i = 0;
  for (const char* p : ordered) {
    int insert_idx = idx_of(p);
    leveldb::Slice ik = GetLengthPrefixedSlice(p);
    uint64_t tag = leveldb::DecodeFixed64(ik.data() + ik.size() - 8);
    std::string user_key(ik.data(), ik.size() - 8);
    std::printf("  #%d. i%d %-22s @%-2llu h=%d\n", i++, insert_idx,
                user_key.c_str(), (unsigned long long)(tag >> 8),
                heights[insert_idx]);
  }

  std::printf("\nper-level chains (member = insertion index):\n");
  // 每层链 = 层高>=l 的节点按 key 顺序,可直接由上表读出,
  // 这里按层打印成员(插入序号),便于手工画图核对。
  for (int l = 1; l <= kMaxHeight; l++) {
    std::vector<int> members;
    int j = 0;
    for (const char* p : ordered) {
      int insert_idx = idx_of(p);
      if (heights[insert_idx] >= l) members.push_back(insert_idx);
      j++;
    }
    if (members.empty()) break;
    std::printf("  L%-2d:", l);
    for (int m : members) std::printf(" i%d", m);
    std::printf("\n");
  }
  return 0;
}

}  // namespace

int main(int argc, char* argv[]) {
  if (argc < 2) {
    std::fprintf(stderr,
                 "usage: %s dist seq|rev|rnd <n> [seed]\n"
                 "       %s bench seq|rev|rnd <n> [seed]\n"
                 "       %s sketch\n",
                 argv[0], argv[0], argv[0]);
    return 1;
  }
  const std::string cmd = argv[1];
  if (cmd == "sketch") return DoSketch();
  if (argc < 4) {
    std::fprintf(stderr, "need <mode> <n>\n");
    return 1;
  }
  const std::string mode = argv[2];
  long n = std::atol(argv[3]);
  unsigned seed = (argc > 4) ? static_cast<unsigned>(std::atol(argv[4])) : 1000;
  if (cmd == "dist") return DoDist(mode, n, seed);
  if (cmd == "bench") return DoBench(mode, n, seed);
  std::fprintf(stderr, "bad command\n");
  return 1;
}