Hotdry.

Article

Keybench: 可脚本化的 KV 存储性能测试框架设计与实践

介绍 keybench 的设计理念与工程实践,涵盖 Lua 工作负载定义、多引擎对比、延迟直方图输出及可复现测试配置。

2026-06-07systems

传统 KV 存储基准测试工具往往面临一个根本性问题:工作负载逻辑与测试框架紧密耦合。当你需要对比 RocksDB 与 TidesDB 在特定业务场景下的表现时,通常不得不为每个引擎编写不同的测试代码,或者受限于框架预设的读写比例。这种耦合导致测试结果反映的是 "框架 + 引擎" 的组合性能,而非引擎本身的真实能力。

keybench 采用了一种解耦架构来解决这个问题。它用 Lua 脚本定义工作负载,通过统一的八动词接口(put/get/del/range/scan/mget/mput/mdel)操作底层存储引擎,确保同一套测试逻辑可以无修改地运行在任意引擎上。这种设计让性能对比真正聚焦于存储引擎本身,而非测试代码的差异。

架构设计:五层可替换组件

keybench 将测试流程拆分为五个独立层,每层均可替换或扩展:

引擎层负责并发控制。keybench 在启动时创建指定数量的工作线程,将操作预算均分给每个线程,但不在引擎调用前后加锁。这意味着线程安全的引擎可以并行执行,而串行引擎会如实反映其串行特性。每个工作线程拥有独立的 Lua 状态,脚本本身无需关心锁机制。

工作负载层通过 Lua 表定义。一个工作负载文件返回包含 name、可选的 load 函数和必需的 run 函数的表。load 用于在正式计时前填充数据, harness 会同时在所有工作线程上执行 load,每个线程填充自己的分片,实现并行数据加载。run 是实际计时的测试单元, harness 会持续调用直到操作预算或时间预算耗尽。两个函数都接收包含 users、items、ops、seed、batch、thread、threads、iter 等字段的 ctx 表。

存储层提供统一的动词接口。无论底层是内存跳表、RocksDB 还是 TidesDB,脚本都通过全局 kv 表调用相同的八个动词。keybench 在调用前后计时,将样本归入对应操作类型的直方图,并统计原始操作数。

后端层采用自注册插件机制。每个引擎位于 backends/ 目录下,通过实现 kv_backend 虚表并调用 KV_REGISTER_BACKEND 注册自己。添加新引擎只需新建目录和实现文件,无需修改核心代码。

报告层作为数据接收器。一次测试运行会将其元数据、探测点、聚合数据和实时样本广播给所有配置的报告器。控制台报告器输出人类可读的表格,TSV 报告器生成适合电子表格或绘图工具的数据行,时间线报告器则记录每个实时样本用于后续分析。

核心指标:区分工作负载单位与原始操作

keybench 报告两类吞吐量指标,这一区分对理解批处理和复合操作至关重要:

wu/s(workload units per second) 表示每秒执行的工作负载单元数。一个单元是脚本 run 函数的一次调用,可能对应 "查看购物车" 或 "结算" 这样的业务操作,无论其中包含多少次底层键值操作。

ops/s(primitive operations per second) 表示每秒执行的原始操作数。一个原始操作是单次 put、get、del、range、scan,或 mget/mput/mdel 中处理的每个键。range 或 scan 无论返回多少行都计为一次操作。

当每个工作负载单元恰好执行一个原始操作时,两个数值相等,报告只显示一行吞吐量。当单元执行多个原始操作(如批量写入 B 个键,或购物车结算时的扫描 + 删除序列)时,报告分别显示 wu/s 和 ops/s。此时 ops/s 恰好是 wu/s 的 B 倍,因为每个单元触发了 B 次键操作。这种区分让批处理的摊销效应清晰可见:随着批大小增加,wu/s 下降(固定调用开销被更多键分摊),而 ops/s 上升。

延迟数据按操作类型分别记录为分布直方图,而非简单平均值。put、get、del、range、mget、mput、mdel 各有独立的直方图,scan 的耗时归入 range 直方图。报告给出每个操作类型的 p50、p99、p99.9 和最大值,让你看清尾延迟情况。

网格化参数扫描与对比测试

keybench 支持通过逗号列表将单次运行扩展为参数网格。以下命令对比两个引擎在不同线程数下的扩展性:

./keybench --backend skiplist,rocksdb --threads 1,4,16 --repeat 3 \
           --data-dir /mnt/disk workloads/mixed.lua

这会执行 2×3=6 个测试点,每个点重复 3 次取中位数。网格是引擎列表、线程列表、批大小列表的笛卡尔积。只有声明支持批处理的工作负载(返回 batched=true)才会在批大小维度上扫描,其他工作负载只在批大小为 1 时运行一次。

这种设计特别适合回答 "引擎 A 和引擎 B 在 8 线程下哪个吞吐量更高" 或 "批大小从 1 增加到 1024 时,延迟如何变化" 这类问题。

配置化与可复现性

测试的可复现性在性能调优中至关重要。keybench 支持从 INI 配置文件加载运行参数:

[bench]
backend  = rocksdb,tidesdb
ops      = 2000000
threads  = 1,8,16,32,64
batch    = 1,64,256,512,1024
repeat   = 3
report_dir = ./results
data_dir = /mnt/disk
workload = workloads/mixed.lua
workload = workloads/cart.lua

[rocksdb]
write_buffer_size = 64M
compression = kNoCompression

[tidesdb]
write_buffer_size = 64M
compression = none
enable_bloom_filter = 1

引擎配置段支持大小写后缀(K/M/G/T 表示 1024 的幂次),RocksDB 的选项直接透传给其选项字符串解析器,TidesDB 的选项则由 keybench 逐个映射。使用 --report-dir 时,keybench 会自动生成包含控制台报告、TSV 数据、时间线数据和重播配置文件的目录,方便后续分析和复现。

工作负载编写实践

编写工作负载时需要注意几个要点:

键的格式化:存储层按原始字节比较排序,因此整数字符串需要固定宽度零填充(如 "k:%012d"),否则 "k:2" 会排在 "k:10" 之后。

并行加载:load 函数在所有工作线程上同时执行,每个线程应只填充自己的分片。使用 for i = ctx.thread, ctx.items - 1, ctx.threads 模式确保键空间均匀分布。

状态隔离:run 在独立的 Lua 状态执行,load 中设置的 upvalue 在 run 中不可见。每次从 ctx 读取键空间大小,而非依赖闭包捕获的值。

扫描限制:kv.scan 的回调函数内部不能调用 kv.put 或 kv.del,某些引擎在扫描时持有读锁,重入写操作会导致死锁。

内置的 mixed、cart、scan、batch 四个示例工作负载展示了常见模式:mixed 是均匀随机的读写混合基线;cart 模拟电商购物车场景(按用户前缀范围扫描);scan 展示流式范围读取;batch 演示多键动词的批处理摊销。

局限与注意事项

keybench 目前仅支持 POSIX 系统。使用持久化引擎时必须显式指定 --data-dir,且路径应位于你要测试的实际磁盘上。框架拒绝在没有 data-dir 的情况下运行持久化引擎,并在路径指向 RAM 文件系统(如 /tmp 在某些系统上是 tmpfs)时发出警告,避免无意中测试内存而非磁盘性能。

添加新引擎需要实现 put、get、del、range、close 五个必需函数,以及可选的 version、stats、datadir、putbatch、delbatch。批量操作函数如果为 NULL,存储层会回退到逐个调用单键操作。

资料来源

  • GitHub: guycipher/keybench — 项目源码与完整文档

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com