深入解析 Pebble arenaskl:Go 实现的无锁竞技场跳表与 memtable 并发基石
2026/9/18 3:52:13 网站建设 项目流程

深入解析 Pebble arenaskl:Go 实现的无锁竞技场跳表与 memtable 并发基石

【免费下载链接】inngestThe leading workflow orchestration platform. Run stateful step functions and AI workflows on serverless, servers, or the edge.项目地址: https://gitcode.com/GitHub_Trending/in/inngest

导读

arenaskl是 Pebble 存储引擎中用于内存表(memtable)的并发跳表实现:它以固定大小的竞技场(arena)统一管理节点、键与值的内存,以无锁(lock-free)CAS 原子操作取代互斥锁,从而在多核场景下获得随核心数线性扩展的写入吞吐,并原生支持正向与反向双向迭代。本文以 vendor/github.com/cockroachdb/pebble/v2/internal/arenaskl/README.md 为主体,结合仓库源码逐层剖析其设计取舍、插入算法、迭代器实现与基准测试数据,帮助你理解 Pebble LSM 内存层为何选择这一结构,以及如何在类似场景中复用它的思路。

一、概述:什么是 arenaskl

arenaskl是一个用 Go 编写的、基于竞技场(arena)内存分配、无锁并发安全的跳表(skiplist)实现,核心特性是支持双向迭代(既可向后遍历,也可向前遍历)。

它被用于 Pebble 的 memtable 实现:在 mem_table.go 中,一个 memtable 就是"构建在无锁竞技场跳表之上"的内存 LSM 层,arena 是一段固定大小的连续内存(大小由Options.MemTableSize决定),因此 memtable 的内存消耗在创建时就已固定;而竞技场跳表同时维护前向、反向链接,使正反两个方向的迭代速度一致。

二、设计优势:为什么 arena + lock-free

README 总结了 arenaskl 相比其他跳表实现的四项核心优势:

  1. 高性能且随核心数线性扩展:内存全部从固定大小的 arena 分配,并且全程不使用锁(Arena与跳表节点都依赖原子操作),并发插入不会因锁竞争而退化。
  2. 迭代器可分配在栈上、可按值克隆Iterator只是一个包含listndkvlower/upper等字段的普通结构体,复制结构体即可克隆当前迭代状态(iterator.go 注释明确说明"current state ... can be cloned by simply value copying the struct"),避免堆分配与 GC 压力。
  3. 低开销的竞争检测模型:无锁设计配合testing标志(skl.go),在测试模式下通过runtime.Gosched()人为插入延迟,放大插入中间态,便于用-race检测异常竞争条件。
  4. 支持反向迭代:节点 tower 中同时保存nextOffsetprevOffset,形成双向链表结构(node.go),Iterator因而提供Prev/Last方法。

三、代价与局限:arena 的硬性边界

上述优势以两个限制为代价,使 arenaskl不能作为通用跳表使用:

  • arena 大小是硬上限:所有节点、键、值的总大小被 arena 容量严格约束,连已"删除"的节点、键、值也包含在内(它们不会归还空间)。当空间耗尽时,Add返回ErrArenaFull(arena.go)。
  • 不支持删除:跳表层不提供 delete 操作,高层代码必须自行写入**删除墓碑(tombstone)**并在读取时处理这些墓碑。这一策略在 mem_table.go 中有明确印证:memtable 是"mutable, but append-only",删除通过墓碑实现。

与之相对,Pebble 中的 batchskl 是同一血统的非并发变体:它移除了删除与并发支持、键在外部存储、节点存储可任意增长,适用于批量写入场景。

四、血统:从 RocksDB 到 Pebble 的演化

README 明确记录了该实现的传承脉络,这对理解代码中的设计决策很有帮助:

  1. 当前代码基于 Andy Kimball 的 arenaskl 代码;
  2. Andy Kimball 的 arenaskl 又基于 Badger(Go 版 KV 存储)中的skl跳表;
  3. Badger 的跳表则源自为 Facebook RocksDB 构建的 C++ 内联跳表(memtable 组件)。

在 skl.go 的文件头注释中,还能看到逐代的差异说明:相比 RocksDB/LevelDB 内联跳表,arenaskl去掉了顺序插入优化(无 "prev")、去掉了自定义比较器、去掉了 Splices、不再做指针运算;相比 Badger,它增加了前向指针(prev 指针)形成双向链表、迭代器带有修改函数。注意双向链表插入存在中间态(A 已指向 B 而 B 尚未指回 A),需要高层代码处理,这正是测试模式放大竞争的原因。

五、核心数据结构与常量

5.1 跳表结构Skiplist

Skl.go 中的 Skiplist 由以下字段组成:

  • arena *Arena:节点、键、值的唯一内存来源;
  • cmp base.Compare:用户键比较函数;
  • head/tail:哨兵节点,初始化时二者在全部maxHeight层互相链接(Reset);
  • height atomic.Uint32:当前跳表高度(1 <= height <= maxHeight),用 CAS 维护。

关键常量(skl.go):

常量含义
maxHeight20塔(tower)的最大层数
pValue1 / math.E每层晋升概率,取欧拉数倒数
linksSizeunsafe.Sizeof(links{})单个方向链接的字节数

Skiplist.Add(key, value)在键已存在时返回ErrRecordExists(skl.go):由于跳表层不支持覆盖,重复键需由用户自行追加唯一版本后缀处理。

5.2 竞技场Arena

Arena 本身是无锁的,只包含两个字段:

type Arena struct { n atomic.Uint64 // 已分配字节数(原子递增) buf []byte // 底层连续缓冲区 }

设计要点:

  • offset 0 保留为"nil 指针":arena 不从位置 0 存数据,n初始化为 1(arena.go),这样getBytes(0)/getPointer(0)均返回 nil,天然表达"无节点"。
  • 对齐分配alloc(size, alignment, overflow)先做n.Add(padded)的原子递增,再计算对齐后的偏移,返回ErrArenaFull表示空间不足(arena.go);overflow参数用于为某些"结构体大于请求尺寸"的分配预留尾随字节。
  • 指针与偏移互换getPointer(offset)getPointerOffset(ptr)在 arena 缓冲区基址与 32 位偏移之间转换——用 32 位偏移而非 64 位指针,既节省内存又规避了 Go 指针逃逸问题。
  • 容量约束NewArena会拒绝超过MaxUint32OrInt的缓冲区(arena.go),保证偏移量始终可表示为uint32

5.3 节点与塔的内存优化

节点结构 中,键的元信息(keyOffset/keySize/keyTrailer)与valueSize是不可变字段,无需加锁即可读取;tower [maxHeight]links中每个links含两个原子字段:

type links struct { nextOffset atomic.Uint32 prevOffset atomic.Uint32 }

两个值得注意的内存优化:

  1. 塔高度按需截断:大多数节点不会用满 20 层(晋升概率指数衰减),因此newRawNode计算unusedSize并从节点大小中扣除,arena 只为实际使用的高度分配内存(node.go),同时用overflow参数保证截断后的结构体仍能安全访问完整tower数组。
  2. _ [4]byte对齐填充:保证 32 位与 64 位架构下node的内存布局一致,使测试可断言固定的结构体大小(node.go)。

键、值与节点本体在 arena 中是连续布局的:节点末尾紧跟键字节、再跟值字节(nd.keyOffset = nodeOffset + nodeSize),一次alloc完成整块分配。

六、无锁插入算法:从 splice 定位到自底向上 CAS

6.1 预计算概率与随机高度

randomHeight只生成一个随机数,通过与预计算概率表比较得到高度(skl.go)。概率表在init()中按p *= 1/e逐层预计算(skl.go),这样既省去多次随机数生成,又能精确使用最优晋升概率1/e(与 C++ 内联跳表一致)。

6.2 Inserter 与 splice 缓存

Add的路径是Add → addInternal(key, value, &ins)(skl.go)。其中Inserter携带[maxHeight]splice缓存(splice仅含prev/next两个节点指针,见 iterator.go),让连续插入可以复用上一次的定位结果

  • findSplice先检查缓存高度与列表高度是否一致、缓存前后指针是否仍有效、键是否仍被 splice 夹在中间,命中则直接复用,否则重新逐层定位(skl.go);
  • 插入成功后乐观地把各层spl[i].prev更新为新节点;若某层因 CAS 失败重算过 splice,则整体失效缓存(ins.height = 0)(skl.go)。

6.3 自底向上的无锁链接

插入始终从第 0 层开始逐层向上(skl.go)。对每一层:

  1. 将新节点ndnextOffset/prevOffset初始化为指向prev/next
  2. 检查next.prevOffset != prevOffset时的两种可能:另一线程刚插入新节点(此时帮忙补上next的 prev 链接),或prev已不再指向next("publication safety" 模式);
  3. prev.casNextOffset(i, nextOffset, ndOffset)竞争插入,成功后用next.casPrevOffset补回 prev 链接,进入下一层;CAS 失败则重算本层 splice 重试。

新节点若增高了列表高度,会通过height.CompareAndSwap原子提升Skiplist.height(skl.go)。全程只有sync/atomic的 Load/Store/CAS,没有任何互斥锁——这正是无锁性能的来源。

七、迭代器:双向遍历与边界优化

Skiplist.NewIter(lower, upper)返回的Iterator实现了base.InternalIterator接口(iterator.go),并从sync.Pool复用实例以降低分配开销:

  • 定位方法SeekGE(找第一个 ≥ key 的条目)、SeekLT(找最后一个 < key 的条目)、First/LastNext/PrevNextPrefix通过SeekGE(succKey, TrySeekUsingNext)实现(iterator.go)。
  • 边界语义lower/upper边界由调用方负责——SeekGE/First不检查下界,SeekLT/Last不检查上界,迭代器只在命中边界时返回 nil(iterator.go)。
  • 边界节点缓存lowerNode/upperNode惰性记录"越过边界"的任意节点,后续迭代命中该节点即可判定到达边界而免去键比较,在反复SeekGE+ 上界场景下收益明显(iterator.go)。
  • TrySeekUsingNext 优化SeekGE收到该标志时,先尝试从当前位置最多向前走 5 步(numNexts = 5)判断是否已越过目标键,避免总是从头跳表搜索(iterator.go)。

此外还有专用的flushIterator(由NewFlushIter创建,skl.go):它只实现First/Next,其余方法直接panic(flush_iterator.go),专门用于 flush 时从 memtable 顺序搬数据到 SSTable,且Next做了内联手写以贴近性能。

八、在 Pebble 中的真实角色:memtable 三跳表结构

arenaskl 在 Pebble 中并非孤立存在。打开 mem_table.go 可以看到一个 memtable 内部实际维护了三张竞技场跳表

var pointSkl arenaskl.Skiplist // 点数据(key-value) var rangeDelSkl arenaskl.Skiplist // 范围删除(range deletion)墓碑 var rangeKeySkl arenaskl.Skiplist // 范围键(range keys)

它们共享一个由NewArena创建的 16 KB 初始缓冲区(memtable 的空载大小由memTableEmptySize预先测量)。写入路径分两阶段:

  1. prepare(batch):非线程安全,需外部同步,按memTableEntrySize(即arenaskl.MaxNodeSize(keyBytes+8, valueBytes)悲观预留空间,O(1) 完成;
  2. apply(batch):可与其他 apply并发执行,复杂度 O(n log m)(n 为批次记录数,m 为 memtable 记录数),由 commitPipeline 串行化 prepare、并发化 apply(mem_table.go)。

arena 耗尽时返回的arenaskl.ErrArenaFull正是 memtable 触发 flush 换新表的信号之一(mem_table.go)。可以说,arenaskl 的无锁高吞吐直接决定了 Pebble memtable 的并发写入能力

九、基准测试:文档记录的实测数据

README 记录了该模块自带的基准测试结果(见internal/arenaskl包内的基准代码,可通过go test -bench复现)。测试为并行执行的读写混合,运行名中frac_X表示 X% 的操作是读操作。

并发场景(8 路并行,时间越低越好):

name time/op ReadWrite/frac_0-8 470ns ±11% ReadWrite/frac_10-8 462ns ± 3% ReadWrite/frac_20-8 436ns ± 2% ReadWrite/frac_30-8 410ns ± 2% ReadWrite/frac_40-8 385ns ± 2% ReadWrite/frac_50-8 360ns ± 4% ReadWrite/frac_60-8 386ns ± 1% ReadWrite/frac_70-8 352ns ± 2% ReadWrite/frac_80-8 306ns ± 3% ReadWrite/frac_90-8 253ns ± 4% ReadWrite/frac_100-8 28.1ns ± 2%

纯读(frac_100)仅 28.1ns,纯写(frac_0)470ns,读写混合随读比例升高线性下降——这正是无锁设计随核心数扩展的直接体现。

单线程场景(README 提示:与batchskl对比时使用这组数字,因为 batchskl 是非并发实现):

name time/op ReadWrite/frac_0 1.53µs ± 1% ReadWrite/frac_10 1.46µs ± 2% ReadWrite/frac_20 1.39µs ± 3% ReadWrite/frac_30 1.28µs ± 3% ReadWrite/frac_40 1.21µs ± 2% ReadWrite/frac_50 1.11µs ± 3% ReadWrite/frac_60 1.23µs ±17% ReadWrite/frac_70 1.16µs ± 4% ReadWrite/frac_80 959ns ± 3% ReadWrite/frac_90 738ns ± 5% ReadWrite/frac_100 81.9ns ± 2%

正反向迭代同样高效:

name time/op IterNext 3.97ns ± 5% IterPrev 3.88ns ± 3%

注意IterNextIterPrev耗时几乎一致——这是双向 prev 指针设计的直接收益。README 同时说明,这些结果显著优于 Go 社区常见的skiplistslist实现;上述数字为文档记录的特定环境(8 路并发)下的结果,实际性能应结合硬件与 Go 版本自行复测。

十、适用场景与阅读建议

综合 README 的定位与源码实现,arenaskl 适合写入密集、并发高、数据量受控、需要快速双向扫描的内存数据结构场景(Pebble memtable 即典型代表);而若数据总大小不可控、需要删除或需要通用跳表语义,则应另寻他路(如 Pebble 的batchskl用于非并发批量场景,或通用跳表库)。

想深入源码的读者可按以下顺序阅读:

  1. README.md:总览设计取舍与基准数据;
  2. arena.go:无锁竞技场分配与 offset-0-nil 约定;
  3. node.go:节点内存布局与塔截断优化;
  4. skl.go:splice 缓存、自底向上 CAS 插入、概率表;
  5. iterator.go 与 flush_iterator.go:双向迭代与边界优化;
  6. mem_table.go:看它如何作为 Pebble LSM 的内存层被实际调用。

小结

arenaskl 的价值在于把"arena 内存管理、无锁 CAS 并发、双向链表迭代"三者融为一体,以牺牲删除能力与内存弹性为代价,换来了可预测的内存占用和随核心数线性扩展的并发性能。无论是研读 Pebble 源码,还是设计自己的高性能内存索引,它都是一份值得反复咀嚼的参考实现。

【免费下载链接】inngestThe leading workflow orchestration platform. Run stateful step functions and AI workflows on serverless, servers, or the edge.项目地址: https://gitcode.com/GitHub_Trending/in/inngest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询