用只插入的跳表组织 MemTable 版本记录
LevelDB 把用户键、序号和记录类型编码为内部键,放入跳表;同一用户键的较新版本排在前面,读取按快照序号定位。
工程问题#
写入缓冲区既要不断接收新记录,也要支持点查找和有序扫描。覆盖旧值会破坏已有快照,而给每次读写都加同一把锁又会增加争用。
实际实现#
MemTable::Add 在 Arena 中构造不可变记录,再调用 SkipList::Insert。内部键先按用户键升序、再按序号和类型降序比较。跳表随机升层,以高层链接跳过区间;插入先初始化新节点,再用 release store 发布前驱链接,读取用 acquire load。节点在整个 MemTable 存活期间不单独删除,因此读者不需要处理节点即时回收。
处理流程#
- 写入批次为记录分配序号,MemTable::Add 编码用户键、序号、类型和值;删除写入的是删除标记。
- FindGreaterOrEqual 从最高层前进并逐层下降,同时为插入记录各层前驱。
- 随机决定节点高度,先设置新节点的后继,再逐层发布前驱到新节点的链接。
- MemTable::Get 用含快照序号的 LookupKey 执行 Seek,跳过对该快照过新的版本,随后核对用户键并解释值或删除标记。
- MemTable 的引用归零后整体释放 Arena;快照与落盘文件的完整生命周期还由数据库上层维护。
源码片段#
固定版本 · 原文摘录84 size_t key_size = key.size();85 size_t val_size = value.size();86 size_t internal_key_size = key_size + 8;87 const size_t encoded_len = VarintLength(internal_key_size) +88 internal_key_size + VarintLength(val_size) +89 val_size;90 char* buf = arena_.Allocate(encoded_len);91 char* p = EncodeVarint32(buf, internal_key_size);92 std::memcpy(p, key.data(), key_size);93 p += key_size;94 EncodeFixed64(p, (s << 8) | type);95 p += 8;96 p = EncodeVarint32(p, val_size);97 std::memcpy(p, value.data(), val_size);98 assert(p + val_size == buf + encoded_len);99 table_.Insert(buf);100}先在 Arena 中编码记录,其中 tag 携带序号和记录类型;最后交给 table_.Insert。table_ 在 memtable.h 中定义为 SkipList<const char*, KeyComparator>。
47int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const {48 // Order by:49 // increasing user key (according to user-supplied comparator)50 // decreasing sequence number51 // decreasing type (though sequence# should be enough to disambiguate)52 int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey));53 if (r == 0) {54 const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8);55 const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8);56 if (anum > bnum) {57 r = -1;58 } else if (anum < bnum) {59 r = +1;60 }61 }62 return r;63}先比较用户键,再反向比较序号与类型组成的 64 位 tag。这个顺序让 MemTable::Get 的 Seek 能跳过比快照更新的版本,而不是在跳表里原地覆盖旧值。
259typename SkipList<Key, Comparator>::Node*260SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key,261 Node** prev) const {262 Node* x = head_;263 int level = GetMaxHeight() - 1;264 while (true) {265 Node* next = x->Next(level);266 if (KeyIsAfterNode(key, next)) {267 // Keep searching in this list268 x = next;269 } else {270 if (prev != nullptr) prev[level] = x;271 if (level == 0) {272 return next;273 } else {274 // Switch to next list275 level--;276 }277 }278 }279}在当前层向右跳过小于目标的节点;不能继续前进时记录前驱并下降一层。第 0 层返回第一个不小于目标的节点,查找与插入位置定位复用同一逻辑。
149 // Accessors/mutators for links. Wrapped in methods so we can150 // add the appropriate barriers as necessary.151 Node* Next(int n) {152 assert(n >= 0);153 // Use an 'acquire load' so that we observe a fully initialized154 // version of the returned Node.155 return next_[n].load(std::memory_order_acquire);156 }157 void SetNext(int n, Node* x) {158 assert(n >= 0);159 // Use a 'release store' so that anybody who reads through this160 // pointer observes a fully initialized version of the inserted node.161 next_[n].store(x, std::memory_order_release);162 }Next 的 acquire load 与发布链接的 release store 配合,使沿已发布链接读取的线程看到初始化完成的节点。原子链接不替代外部写入串行化与生命周期保护。
359 x = NewNode(key, height);360 for (int i = 0; i < height; i++) {361 // NoBarrier_SetNext() suffices since we will add a barrier when362 // we publish a pointer to "x" in prev[i].363 x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));364 prev[i]->SetNext(i, x);365 }366}新节点先继承各层后继,再通过前驱的 SetNext 发布。低层先接入,即使读者暂时看不到新高层链接,也仍能沿低层继续查找。
8// Thread safety9// -------------10//11// Writes require external synchronization, most likely a mutex.12// Reads require a guarantee that the SkipList will not be destroyed13// while the read is in progress. Apart from that, reads progress14// without any internal locking or synchronization.15//16// Invariants:17//18// (1) Allocated nodes are never deleted until the SkipList is19// destroyed. This is trivially guaranteed by the code since we20// never delete any skip list nodes.21//22// (2) The contents of a Node except for the next/prev pointers are23// immutable after the Node has been linked into the SkipList.24// Only Insert() modifies the list, and it is careful to initialize25// a node and use release-stores to publish the nodes in one or26// more lists.原文件明确要求写入外部同步、读者保护对象生命周期,且节点直到整个跳表销毁才删除。这些约束是简化读取的前提。
102bool MemTable::Get(const LookupKey& key, std::string* value, Status* s) {103 Slice memkey = key.memtable_key();104 Table::Iterator iter(&table_);105 iter.Seek(memkey.data());106 if (iter.Valid()) {107 // entry format is:108 // klength varint32109 // userkey char[klength]110 // tag uint64111 // vlength varint32112 // value char[vlength]113 // Check that it belongs to same user key. We do not check the114 // sequence number since the Seek() call above should have skipped115 // all entries with overly large sequence numbers.116 const char* entry = iter.key();117 uint32_t key_length;118 const char* key_ptr = GetVarint32Ptr(entry, entry + 5, &key_length);119 if (comparator_.comparator.user_comparator()->Compare(120 Slice(key_ptr, key_length - 8), key.user_key()) == 0) {用携带快照序号的内部查找键 Seek,跳过更新的版本,再核对是否属于同一个用户键。
122 const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8);123 switch (static_cast<ValueType>(tag & 0xff)) {124 case kTypeValue: {125 Slice v = GetLengthPrefixedSlice(key_ptr + key_length);126 value->assign(v.data(), v.size());127 return true;128 }129 case kTypeDeletion:130 *s = Status::NotFound(Slice());131 return true;132 }133 }134 }135 return false;用户键相同后按记录类型解释结果:返回保存的值,或将删除标记转换为 NotFound;不是同一个键则由调用方继续查找其他层。
各段均为固定提交连续原文。DBImpl::Write 的写入队列保证同一时刻只有获准批次修改 MemTable;不是在调用 Add 的整个期间一直持有数据库 mutex。
收益与取舍#
工程分析带来的收益
- 一套有序索引同时支持点查找、从指定键开始扫描与生成有序落盘数据。
- 多版本作为不同内部键共存,更新不必原地改写读者正在访问的值。
- 节点不单独删除,减少了并发读取时的回收协议;Arena 也减少逐节点分配与释放。
付出的代价
- 只插入意味着旧版本和删除标记会继续占用当前 MemTable,空间回收依赖切换、落盘及后续清理。
- 跳表查找和插入是概率意义上的高效,不能宣称最坏 O(log n);此实现最高 12 层,层高上限也限制了无限规模下的渐近推论。
- 写入仍要求外部串行化;读者必须保证 MemTable 生命周期。单个跳表无需内部读锁,不等于整个 LevelDB API 无锁。
可比较的选择
平衡搜索树也能支持有序查询,并有确定的树高界;但旋转以及并发读者可见的结构修改需要另一套同步设计。
哈希表适合点查询,但有序扫描、按快照寻找同键版本和输出排序数据需要额外组织。
版本与适用范围#
固定提交 bb74ef739973 的 MemTable 跳表。层高每次以 1/4 的概率增加,最大为 12;复杂度还受键比较长度影响。快照可见性依赖内部键比较顺序和上层序号选择,不能仅凭原子 next 指针证明数据库事务语义。持久化、SSTable 查询与 compaction 不在本例展开。
参考来源#
源码、官方文档与原始论文db/memtable.cc · MemTable::Add / MemTable::Get
db/skiplist.h · SkipList::Insert / FindGreaterOrEqual
db/dbformat.cc · InternalKeyComparator::Compare
db/write_batch.cc · MemTableInserter
db/memtable.h · MemTable::Table
相关实现与比较#
4 个相关案例Redis 有序集合既要按成员查分数,也要按分数和名次遍历;较大集合通过两种索引服务这些访问方式。
nginx 将小额分配变成池内游标推进,并以池的生命周期统一释放;较大的分配另建记录管理。
FRRouting 的 OSPF 守护进程用 Dijkstra 计算区域内最短路径,并为路由表生成下一跳。
ClickHouse 先为输入行找到对应分组的聚合状态地址,再通过地址数组批量执行各个聚合函数。