上一篇结尾停在写路径的最后一站: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 时要全量顺序扫一遍落盘。
归纳起来三个动词:插入、按序遍历、点查,外加一个硬约束:写的时候读不能停。
然后我们用这三个归纳出来的动词去看结构:
哈希表点查 最快,但完全无序。flush 是常态路径,每次都要先全量拿出来排序—— 的额外开销挂在最频繁的后台操作上,出局。当然可以做「哈希 + 排序缓存」的混合结构,但复杂度上去了,维护难度也跟着上去了。
红黑树(或 AVL)有序、点查、插入都是 ,看起来符合需求。但有两点潜在问题。
- 平衡靠旋转:插入触发旋转时要改动已有节点的父子指针,一个正在无锁读的遍历者可能刚拿到节点引用,节点就被人重构了——要么给节点加锁(读写锁),要么做复杂的版本协调,两条路都无法实现「读不加锁」这个最想要的性质。
- 树节点上 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 层的跳表示意图:
读法:HEAD 是哨兵节点,每层链都从它出发;L1 是那条完整的有序链表,所有节点都在场;「住几层」是每个节点自己的属性——9 和 45 住两层,21 住三层,于是 L2 的成员是 L1 的抽样,L3 又是 L2 的抽样。要找 33:从最高层起步,L3 一步跨到 21;L2 上望一眼,下一个是 45,太远,降层;L1 上精确落脚。这个「先冲再降」的走法为什么是 、各层成员又是谁定的,往下看。
一条有序单链表,查找的复杂度是 ;给其中大约四分之一的节点加一层「快车道」,查找就能先走快车道、快到头再降一层;给快车道里大约四分之一的节点再加一层「特快」,如此向上。如果每层的节点恰好是下一层的四分之一,n 个节点的结构大约有 层,每层前进的期望步数不超过 4,查找期望 ——和平衡树同阶。
难点在「恰好四分之一」怎么维持。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,所以节点期望层高是 ,而落到第 层及以上的概率是 ——n 个节点里第 层期望 个。这两个数我们会在后面实验中拿出来核对。
层高封顶在 kMaxHeight = 12(db/skiplist.h:102)。我们不妨算一下这笔账:第 12 层要有成员,期望需要 万个节点;而一代 MemTable 默认 write_buffer_size 才 4MB,按最小 entry(十来字节的 key + 8 字节 tag + value + 节点开销,约 30 多字节)估算,撑死二十万条——离 12 层的门槛差着 20 倍。12 不是紧巴巴的约束,是富余量极大的护栏;就算骰子异常幸运真的想冲 12 层,RandomHeight 也把它摁住了,搜索始终 。
构造函数(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、模 ),质量谈不上好,但这里要的只是「分布正确」,不是「不可预测」——内存数据结构的层高没有对抗者,没人会恶意构造让骰子失灵的 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] 的地盘:
再看 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 从头重查,时间复杂度 。那这多出来的开销谁能感知到?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 的节点:
图例(沿链序):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 是几乎没人走的冷路径(走也就重查一遍, 换 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 用量)估算——这笔账我们下一篇会详细算。
设计借鉴:
-
单写者 + 原子发布 + 无锁读,是一个比「全无锁」简单一个数量级、覆盖面却极广的并发模式。跳表本身零锁零等待,互斥被推到上层协议(writer 队列的 leadership),读写之间的协调压缩成一对 release/acquire。识别出「其实只有一个写者」的场景,往往是并发设计里收益最大的一步。
-
「不可变 + 不删除」是简化并发的利器。代价也明确:内存只增不减、旧版本与新版本并存。LevelDB 把这笔代价整体外移给生命周期管理——arena 只管批发、换代时整块回收,连析构单个节点的代码都不用写。很多「并发对象怎么安全删除」的难题,根源是对象活得太久;让一批对象同生共死,问题会自己消失。
-
选数据结构看生命周期与功能面,不只看复杂度。跳表和红黑树同为 ,纸面打平;但「新节点独立装配、只改前驱指针」这个性质,在无锁读场景下是决定性的,而「不需要 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;
}