scan4all 依赖库 Badger v1.6.2 中 skl 无锁跳表的内存设计:Benchmark 对比、节点池化与 Arena 分配原理

发布时间:2026/9/17 21:13:27
scan4all 依赖库 Badger v1.6.2 中 skl 无锁跳表的内存设计:Benchmark 对比、节点池化与 Arena 分配原理 scan4all 依赖库 Badger v1.6.2 中 skl 无锁跳表的内存设计Benchmark 对比、节点池化与 Arena 分配原理【免费下载链接】scan4allOfficial repository vuls Scan: 15000PoCs; 23 kinds of application password crack; 7000Web fingerprints; 146 protocols and 90000 rules Port scanning; Fuzz, HW, awesome BugBounty( ͡° ͜ʖ ͡°)...项目地址: https://gitcode.com/GitHub_Trending/sca/scan4all本文以 scan4all 仓库 vendored 依赖 badger/skl/README.md 为主体解读 Badger 内存层跳表skl 包相对传统 skiplist/map读写锁的性能优势并结合 skl.go 与 arena.go 源码还原节点池化Arena的落地机制最后说明该组件在 scan4all 本地 KV 缓存中的实际角色与适用前提。读完后你应能理解skl 跳表如何通过 Arena 内存池、位段编码与全 CAS 无锁写路径实现比 mapRWMutex 快约 8 倍的读写性能以及 pprof 前后对比如何证明节点池化把分配热点从逐次 Put 收敛到内存池创建处。一、文档定位scan4all 为什么带有这份 skl 说明scan4all 在 go.mod 中以github.com/dgraph-io/badger v1.6.2引入了 Badger 作为本地嵌入式 KV 存储用于把扫描过程中产生的中间结果指纹、爬取结果等落盘缓存。实际封装见 lib/util/kvDb.goNewKvDbOp()默认在.DbCache目录打开一个 Badger 实例并通过泛型辅助函数PutAny/GetAny做 JSON 序列化读写。Badger 是典型的 LSM-Tree 结构写请求先进入内存 MemTableMemTable 满后刷盘生成 SSTable。而这个 MemTable 的底层数据结构正是sklskiplist包。本文引用的 vendor/github.com/dgraph-io/badger/skl/README.md 是该包随源码分发的性能说明文档记录了两组关键信息skl 跳表相对传统skiplist/slist实现、以及map 读写锁方案的 Benchmark 数据Node Pooling节点池化优化前后两次 pprof 内存剖析对比。需要说明的前提该 README 中的 Benchmark 与 pprof 数据源自 Badger 上游在特定硬件README 使用/usr/bin/time -l为 macOS 风格命令上的测量仅用于说明优化方向与量级不代表在当前机器上可复现的绝对数值。二、Benchmarkskl 比 map RWMutex 快多少README 第一组数据是BenchmarkReadWrite系列覆盖frac_0到frac_10十个参数档位frac_N是原作者用于刻画混合负载的参数档位随档位升高单次操作耗时单调下降说明该档位下的负载形态对 skl 越有利BenchmarkReadWrite/frac_0-8 3000000 537 ns/op BenchmarkReadWrite/frac_1-8 3000000 503 ns/op BenchmarkReadWrite/frac_2-8 3000000 492 ns/op BenchmarkReadWrite/frac_3-8 3000000 475 ns/op BenchmarkReadWrite/frac_4-8 3000000 440 ns/op BenchmarkReadWrite/frac_5-8 5000000 442 ns/op BenchmarkReadWrite/frac_6-8 5000000 380 ns/op BenchmarkReadWrite/frac_7-8 5000000 338 ns/op BenchmarkReadWrite/frac_8-8 5000000 294 ns/op BenchmarkReadWrite/frac_9-8 10000000 268 ns/op BenchmarkReadWrite/frac_10-8 100000000 26.3 ns/op作为对照组README 给出最简单可行的替代方案——mapsync.RWMutexBenchmarkReadWriteMapBenchmarkReadWriteMap/frac_0-8 2000000 774 ns/op BenchmarkReadWriteMap/frac_1-8 2000000 647 ns/op BenchmarkReadWriteMap/frac_2-8 3000000 605 ns/op BenchmarkReadWriteMap/frac_3-8 3000000 603 ns/op BenchmarkReadWriteMap/frac_4-8 3000000 556 ns/op BenchmarkReadWriteMap/frac_5-8 3000000 472 ns/op BenchmarkReadWriteMap/frac_6-8 3000000 476 ns/op BenchmarkReadWriteMap/frac_7-8 3000000 457 ns/op BenchmarkReadWriteMap/frac_8-8 5000000 444 ns/op BenchmarkReadWriteMap/frac_9-8 5000000 361 ns/op BenchmarkReadWriteMap/frac_10-8 10000000 212 ns/op逐档对比可以看出在中间档位如frac_9skl 为 268 ns/opmap 为 361 ns/op快约 1.35 倍在最优档位frac_10skl 26.3 ns/op 对 map 212 ns/op快约 8 倍且执行轮次更多1 亿次 vs 1000 万次说明缓存/锁竞争上的差距被进一步放大。map 方案的瓶颈在于写操作必须独占写锁所有并发读写都要经过 RWMutex 的串行化而 skl 全程无锁下文第四节会展示其 CAS 写路径读操作几乎不与写竞争。README 开头那句 This is much better than skiplist and slist 则是相对更早的跳表实现的结论但本仓库 vendor 版本中未附带对应 Benchmark 源码此处仅转述文档原始表述。三、数据结构与内存布局node 如何编码理解性能数据的前提是理解节点布局。skl 的核心差异是节点、key、value 三者都不走 Go 堆分配而是全部存放在一块连续字节缓冲Arena里节点之间用偏移量而非指针连接。3.1 node 字段布局见 skl.go#L52-L74value uint64value 在 Arena 中的偏移量低 32 位 大小第 32~47 位合并编码成一个 uint64。这样读 value 时只需一次atomic.LoadUint64无需加锁keyOffset uint32/keySize uint16key 不可变写入后永不修改因此读取 key 也不需要任何锁// Immutable. No need to lock to access key.height uint16节点塔高。源码注释明确解释了节点池化的内存动机——每一层塔高出现的概率指数衰减绝大多数节点用不到满塔高因此当节点在 arena 中分配时其内存占用被刻意截断不包含未使用的塔元素tower [maxHeight]uint32每层下一个节点存的是Arena 偏移量uint320 表示 nil而非*node指针。getNext/casNextOffset全部通过atomic.LoadUint32/atomic.CompareAndSwapUint32操作这些偏移量。maxHeight固定为 20skl.go#L44-L47节点塔高上限由此确定MaxNodeSize通过unsafe.Sizeof(node{})在编译期算出满高节点的字节数是 Arena 分配的计算基准。顶层结构Skiplist只有四个字段skl.go#L77-L82type Skiplist struct { height int32 // 当前塔高1 height maxHeight通过 CAS 更新 head *node // 哨兵节点 ref int32 // 引用计数用于决定何时回收 Arena arena *Arena }引用计数语义在DecrRefskl.go#L89-L103中体现引用归零时调用arena.reset()并置空head由于 head 引用着 arena 的 buf只要 head 存在GC 就无法回收 buf——注释原文。这与 Badger 上层旧 MemTable 被刷盘后仍需服务迭代器的 GC 生命周期配套迭代器持有引用期间MemTable 的 Arena 不会被复用。3.2 为什么 key 大小只存 uint16keySize uint16意味着单个 key 最长 65535 字节valSize同为 uint16见encodeValue/decodeValueskl.go#L116-L124。这是用 64 位字段打包offset 32 位 size 16 位换来的原子读写能力也是 scan4all 这类缓存场景key 通常是 URL/指纹短串value 是序列化 JSON能安全使用的前提限制超过 64KB 的 value 无法直接经Put写入 skl。四、Arena 与节点池化README Node Pooling 一节的源码印证README 的 Node Pooling 一节记录了一次完整的内存剖析实验。复现命令README 原文rm -Rf tmp /usr/bin/time -l ./populate -keys_mil 10即写入 1000 万个 keypopulate 程序为上游配套工具本 vendor 目录未包含。README 同时诚实说明Results seem to vary quite a bit between runs多次运行结果波动较大。以下两组数据完整摘自 README。4.1 池化前Before node pooling1311.53MB of 1338.69MB total (97.97%) Dropped 30 nodes (cum 6.69MB) Showing top 10 nodes out of 37 (cum 12.50MB) flat flat% sum% cum cum% 523.04MB 39.07% 39.07% 523.04MB 39.07% github.com/dgraph-io/badger/skl.(*Skiplist).Put 184.51MB 13.78% 52.85% 184.51MB 13.78% runtime.stringtoslicebyte 166.01MB 12.40% 65.25% 689.04MB 51.47% github.com/dgraph-io/badger/mem.(*Table).Put 165MB 12.33% 77.58% 165MB 12.33% runtime.convT2E 116.92MB 8.73% 86.31% 116.92MB 8.73% bytes.makeSlice 62.50MB 4.67% 90.98% 62.50MB 4.67% main.newValue 34.50MB 2.58% 93.56% 34.50MB 2.58% github.com/dgraph-io/badger/table.(*BlockIterator).parseKV 25.50MB 1.90% 95.46% 100.06MB 7.47% github.com/dgraph-io/badger/y.(*MergeIterator).Next 21.06MB 1.57% 97.04% 21.06MB 1.57% github.com/dgraph-io/badger/table.(*Table).read 12.50MB 0.93% 97.97% 12.50MB 0.93% github.com/dgraph-io/badger/table.header.Encode 128.31 real 329.37 user 17.11 sys 3355660288 maximum resident set size 2203080 page reclaims 764 page faults 599922 involuntary context switches分配热点第一名是skl.(*Skiplist).Put自身的 flat 523MB占总分配 39.07%——即每次 Put 都在 Go 堆上单独 malloc 节点/值对象GC 压力直接落在写路径上。4.2 池化后After node pooling1963.13MB of 2026.09MB total (96.89%) Dropped 29 nodes (cum 10.13MB) Showing top 10 nodes out of 41 (cum 185.62MB) flat flat% sum% cum cum% 658.05MB 32.48% 32.48% 658.05MB 32.48% github.com/dgraph-io/badger/skl.glob..func1 297.51MB 14.68% 47.16% 297.51MB 14.68% runtime.convT2E 257.51MB 12.71% 59.87% 257.51MB 12.71% runtime.stringtoslicebyte 249.01MB 12.29% 72.16% 1007.06MB 49.70% github.com/dgraph-io/badger/mem.(*Table).Put 142.43MB 7.03% 79.19% 142.43MB 7.03% bytes.makeSlice 100MB 4.94% 84.13% 758.05MB 37.41% github.com/dgraph-io/badger/skl.newNode 99.50MB 4.91% 89.04% 99.50MB 4.91% main.newValue 75MB 3.70% 92.74% 75MB 3.70% github.com/dgraph-io/badger/table.(*BlockIterator).parseKV 44.62MB 2.20% 94.94% 44.62MB 2.20% github.com/dgraph-io/badger/table.(*Table).read 39.50MB 1.95% 96.89% 185.62MB 9.16% github.com/dgraph-io/badger/y.(*MergeIterator).Next 135.58 real 374.29 user 17.65 sys 3740614656 maximum resident set size 2276566 page reclaims 770 page faults 597049 involuntary context switches如何解读这次对比结合 arena.go 源码分配点迁移池化后第一分配点是skl.glob..func1包内闭包对应 Arena 底层 buffer 的创建路径skl.(*Skiplist).Put从 flat 榜消失——Put 不再向 GC 堆申请内存节点写入改为在预分配的buf []byte上做 bump 分配Arena 是一个无锁 bump 分配器newArena(n)一次性make([]byte, n)arena.go#L43-L51之后putNode/putKey/putVal全部通过atomic.AddUint32(s.n, l)推进游标切块arena.go#L63-L104无锁、无 GC 追踪开销偏移 0 保留为假 nilnewArena初始游标n: 1注释说明位置 0 不放数据是为了把 offset0 保留为一种 nil 指针getNode(0)返回真正的 nilarena.go#L108-L11464 位对齐nodeAlign常量保证即使在 32 位架构上node.value字段也按 64 位对齐因为atomic.LoadUint64要求 64 位对齐arena.go#L26-L34。putNode在返回游标前用^uint32(nodeAlign)对齐节点池化的本质是按实际塔高截断分配putNode计算unusedSize : (maxHeight - height) * offsetSize然后只分配MaxNodeSize - unusedSize nodeAlign字节arena.go#L63-L78。由于randomHeight生成的塔高绝大多数远小于 20这一截断显著降低了每个节点的平均占用——这正是 node 结构注释中 memory footprint is deliberately truncated 的落地实现总量反而变大的解释池化后 total 分配 2026MB 池化前 1338MB这不是回归——Arena 采用一次申请整块 复用/整块释放策略reset()只是把游标原子归零arena.go#L57-L59峰值 RSS 由分配次数换取了 GC 停顿与指针追逐的减少。README 亦提醒多次运行波动大因此结论应理解为分配模式从散点堆分配收敛为集中内存池而非总量指标。五、无锁写路径Put 的完整调用链skl.go#L283-L346 的Put是全包并发正确性的核心分三步从顶层向底层定位插入缝prev[listHeight] head逐层调用findSpliceForLevel(key, prev[i1], i)skl.go#L254-L277。该函数沿第 i 层向右走直到满足before.key key next.key或找到等键节点。利用上层已定位的prev作为下一层起点是跳表 O(1/层) 期望复杂度的关键等键命中则原地覆盖prev[i] next[i]表示 key 已存在直接prev[i].setValue(arena, v)返回。setValueskl.go#L147-L151把新 value 编码成 uint64 后一次atomic.StoreUint64完成覆盖。文件头注释说明了与 RocksDB/LevelDB 的关键差异Support overwrites……In-place updates of values would be more efficient——skl 不做版本链同 key 直接原地改值未命中则新建节点并逐层 CAS 挂链randomHeight()skl.go#L168-L174用z.FastRand()决定塔高heightIncrease math.MaxUint32 / 3意味着每层升高的概率约为 1/4保证期望塔高为常数的几何分布。随后若新节点塔高超过当前s.height先用atomic.CompareAndSwapInt32(s.height, ...)无锁抬升整表高度从第 0 层向上对每一层执行prev[i].casNextOffset(i, nextOffset, xOffset)CAS 失败说明有并发插入挤进了 prev/next 之间就重新执行findSpliceForLevel再试形成经典的重试循环。源码注释还处理了一个边角有人在我们之前插入了完全相同的 key这只可能发生在 base level此时断言层号并退化为原地覆盖。读路径Getskl.go#L374-L391对应findNear(key, false, true)找命中后再用y.SameKey做等值确认最后从 key 尾部解析出时间戳版本vs.Version y.ParseTs(nextKey)——这是 Badger key 携带 8 字节时间戳后缀的 MemTable 级约定。迭代器方面NewIterator会IncrRef()Close时DecrRef()skl.go#L394-L414与第三节提到的 Arena 生命周期闭环Seek/SeekForPrev/SeekToFirst/SeekToLast全部复用findNear与findLastPrev则是findNear(s.Key(), true, false)即找严格小于当前 key 的右节点。UniIteratorskl.go#L464-L517是 Badger 统一迭代接口的薄封装reversed布尔值把Next/Rewind/Seek分别映射到正向或反向语义。六、在 scan4all 中的实际角色从 db.go#L294、db.go#L839 可以看到Badger 每次打开或切换 MemTable 时都执行db.mt skl.NewSkiplist(arenaSize(opt))Arena 容量由arenaSize(opt)db.go#L848依据选项计算——这解释了第二节 Benchmark 与第四节 pprof 中的对象正是 scan4all 缓存目录背后同一份代码。scan4all 侧的初始化见 lib/util/kvDb.go#L43-L57opts : badger.DefaultOptions(szDb) opts.CompactL0OnClose true // 关闭前压实 L0避免遗留过多 MemTable 层 opts.EventLogging false opts.Logger nil opts.LevelOneSize 256 10 // L1 目标 256KB适配小规模本地缓存 opts.LevelSizeMultiplier 20 // 逐层放大倍数 db, err : badger.Open(opts)其中LevelOneSize/LevelSizeMultiplier控制的是 LSM 各层 SSTable 规模与 MemTableskl满后刷盘的节奏相关LevelOneSize 256 10把 L1 压得很小意味着 MemTable 刷盘、L0 压实的粒度都更细适合缓存目录小、读写短促的扫描器场景CompactL0OnClose保证进程退出时 L0 被压实降低下次启动的重放成本。Init的失败提示也印证了单写者约束cannot open multiple processes at the same time——MemTable 的 Arena 无锁设计只保证单进程内多线程安全多进程互斥依赖 Badger 的文件锁。适用前提与限制小结本文所有源码结论基于 vendored 的badger v1.6.2见 go.mod 第 21 行非上游最新版skl 单 key/value 上限受uint16尺寸字段约束64KB超限需走 Badger 上层 value log 机制README 的 Benchmark/pprof 数值来自上游测量环境仅说明优化方向不构成当前机型的性能承诺节点池化的收益体现在 GC 压力与写路径分配次数上而非分配总量见 4.2 的解读。七、小结skl/README.md 用两组数据回答了为什么 MemTable 用跳表而不是 mapBenchmarkReadWrite显示 skl 在最优负载档位比 mapRWMutex 快约 8 倍根源在于 map 方案读写都要争 RWMutex而 skl 的读路径Get/迭代与写路径Put的 CAS 重试环互不阻塞Node Pooling 的 pprof 前后对比则回答了如何把写路径的 GC 压力压下去——Arena 一次成池、bump 分配、按塔高截断节点、偏移 0 即 nil使(*Skiplist).Put从占 39% 的 flat 分配热点变为内存池内写入。在 scan4all 中这套机制支撑着.DbCache目录下缓存层的每一次PutAny/GetAny配合 kvDb.go 的小型化 LSM 参数构成了扫描器本地持久化缓存的内存底座。【免费下载链接】scan4allOfficial repository vuls Scan: 15000PoCs; 23 kinds of application password crack; 7000Web fingerprints; 146 protocols and 90000 rules Port scanning; Fuzz, HW, awesome BugBounty( ͡° ͜ʖ ͡°)...项目地址: https://gitcode.com/GitHub_Trending/sca/scan4all创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询