LevelDB 是 Google 开源的一款轻量级嵌入式 KV 存储引擎,设计精巧,代码优美。但在生产环境中跑过 LevelDB 的人都知道一个痛点——你几乎不知道它内部在发生什么。当 LevelDB 出现性能毛刺时,排查手段极其有限:没有性能计数器,没有延迟直方图,没有逐层 compaction 统计,连 compaction 本身的 IO 开销都无从查起。你能做的,大概就是盯着 LOG 文件里寥寥几行输出,然后靠经验猜。
Mark Callaghan 在他的 benchmark 文章中一针见血地指出了这一点:RocksDB 提供了丰富的统计报告能力,而这些数据对于解释性能表现的好坏至关重要——而这一特性在 LevelDB 中并不存在。(参见 Small Datum: Comparing LevelDB and RocksDB, take 2)
RocksDB 从 Facebook(现 Meta)的生产需求出发,将可观测性作为一等公民来对待。它在代码中内置了极其丰富的监控基础设施,形成了一套完整的、多层次的可观测性体系。本文将深入剖析这套体系的四大支柱:Statistics、Perf Context / IO Stats Context、Compaction Stats 以及 DB Properties,并最终介绍 ToplingDB 在图形化展示方面的探索。
Statistics 是 RocksDB 可观测性体系的基石,定义在 include/rocksdb/statistics.h 中。它提供了两大类指标:
DB_GET、DB_WRITE、DB_SEEK 的延迟分布,COMPACTION_TIME 的耗时分布等。启用 Statistics 非常简单:
#include "rocksdb/statistics.h"
Options options;
options.statistics = rocksdb::CreateDBStatistics();
DB::Open(options, "/tmp/testdb", &db);
// 读取某个计数器
uint64_t cache_hits = options.statistics->getTickerCount(BLOCK_CACHE_HIT);
uint64_t cache_misses = options.statistics->getTickerCount(BLOCK_CACHE_MISS);
double hit_rate = (double)cache_hits / (cache_hits + cache_misses);
// 获取直方图数据
HistogramData hist_data;
options.statistics->histogramData(DB_GET, &hist_data);
// hist_data 包含 median, p95, p99, average, count 等
Statistics 也会自动按 options.stats_dump_period_sec(默认 600 秒)的周期将汇总数据输出到 LOG 文件中,无需额外代码。
统计不是免费的。RocksDB 提供了 StatsLevel 来让用户在精度和开销之间做权衡:
| StatsLevel | 说明 | 典型场景 |
|---|---|---|
kDisableAll | 关闭所有统计 | 极致性能场景 |
kExceptHistogramOrTimers | 仅采集 Ticker | 生产环境,低开销监控 |
kExceptDetailedTimers | Ticker + 直方图,但跳过细粒度计时(默认值) | 大多数生产场景 |
kExceptTimeForMutex | 跳过 mutex 等待计时 | 怀疑非锁竞争问题时 |
kAll | 采集所有指标 | 开发调试、性能剖析 |
通常的最佳实践是:生产环境使用默认的 kExceptDetailedTimers,观测到异常时临时调高到 kAll 进行诊断。
StatisticsImpl 的一个精妙之处在于它使用了 per-core 的存储策略来最小化竞争。每个 CPU 核心维护独立的 Ticker 和 Histogram 数组,写入时使用 memory_order_relaxed 的原子操作,无锁完成。只有在读取时,才会获取 aggregate_lock_ 并汇总所有核心的数据。
这一设计使得 Statistics 在高并发场景下的写入开销极低(通常观测到 5%~10% 的整体性能影响),同时保证读取的一致性。
以下是一些最值得关注的 Ticker 和 Histogram:
缓存效率:BLOCK_CACHE_HIT 与 BLOCK_CACHE_MISS 的比值直接反映 block cache 的有效性。命中率低于 90% 通常意味着需要调大缓存。
Bloom Filter 收益:BLOOM_FILTER_USEFUL 表示 bloom filter 成功避免的无效文件读取次数。对于读密集型负载,这个数字应该显著高于零。
写停顿:STALL_MICROS 记录了因 compaction 跟不上写入速度而导致的写停顿总微秒数。这是排查写入毛刺的第一指标。
写放大:通过 COMPACT_WRITE_BYTES 除以用户实际写入字节数,可以计算出写放大系数。
操作延迟分布:DB_GET、DB_WRITE 的 P99 延迟是衡量尾延迟的核心指标。
Statistics 是全局聚合的指标,适合宏观监控。但当你发现某些查询异常慢时,你需要的是一把手术刀——精确到单次操作级别的性能拆解。这就是 Perf Context 和 IO Stats Context 的用武之地。
二者使用相同的机制,区别在于关注的维度不同:Perf Context 度量 RocksDB 内部函数调用,IO Stats Context 度量底层 I/O 操作。
#include "rocksdb/perf_context.h"
#include "rocksdb/iostats_context.h"
// 设置剖析级别(线程级别生效)
rocksdb::SetPerfLevel(rocksdb::PerfLevel::kEnableTimeExceptForMutex);
// 重置计数器
rocksdb::get_perf_context()->Reset();
rocksdb::get_iostats_context()->Reset();
// 执行操作
db->Get(read_options, "my_key", &value);
// 读取结果
std::cout << rocksdb::get_perf_context()->ToString() << std::endl;
std::cout << rocksdb::get_iostats_context()->ToString() << std::endl;
PerfContext 结构体中包含数十个计数器,以下是几个最实用的诊断场景:
诊断慢查询根因:get_from_memtable_time 和 get_from_output_files_time 直接告诉你一次 Get 操作的时间消耗在 memtable 还是 SST 文件上。如果 SST 读取耗时很高,再看 block_read_count 和 block_read_time 来确认是否因为需要从磁盘读取过多 block。
评估 Bloom Filter 效果:每一级(Level)都有独立的 bloom_filter_useful 计数。如果某一级的 bloom filter 未能过滤掉查询,那可能是 filter 的 bits per key 设置不足,或者 prefix extractor 配置不当。
发现 tombstone 问题:internal_delete_skipped_count 告诉你迭代器跳过了多少墓碑(tombstone)。如果这个数字很大,说明存在大量已删除但未被 compaction 清理的 key,可能是 compaction 没跟上或者 range delete 过于频繁。
写路径拆解:write_wal_time、write_memtable_time、write_delay_time 分别度量写 WAL、写 memtable 以及因限速导致的延迟。write_pre_and_post_process_time 则包含了在 write group 中等待排队的时间。
IO Stats Context 提供的是更底层的 I/O 维度:
bytes_read / bytes_written:文件系统级的读写字节数read_nanos / write_nanos:pread() / pwrite() 系统调用的耗时open_nanos:文件 open() 调用耗时fsync_nanos:fsync 调用耗时结合 Perf Context,你可以构建出从 RocksDB 语义层到系统调用层的完整延迟链路。
线程级剖析的开销不可忽视。常见的生产实践是基于采样启用——比如每 1000 次请求开启一次 kEnableTimeExceptForMutex,收集完立即恢复到 kDisable。这样既能获得有统计意义的分布数据,又不会显著影响整体吞吐。
Compaction 是 LSM-Tree 引擎的核心后台操作,直接影响读写性能、空间占用和 I/O 模式。RocksDB 每隔 stats_dump_period_sec 秒(默认 600 秒)会在 LOG 文件中输出一次完整的 compaction 统计。你也可以在程序中通过 db->GetProperty("rocksdb.stats", &stats) 主动获取。
输出的格式如下(简化版):
** Compaction Stats [default] **
Level Files Size(MB) Score Read(GB) Rn(GB) Rnp1(GB) Write(GB) Wnew(GB) W-Amp Rd(MB/s)
----- ----- -------- ----- -------- ------ -------- --------- -------- ----- --------
L0 2/0 15 0.5 0.0 0.0 0.0 32.8 32.8 0.0 0.0
L1 22/0 125 1.0 163.7 32.8 130.9 165.5 34.6 5.1 25.6
L2 227/0 1276 1.0 262.7 34.4 228.4 262.7 34.3 7.6 26.0
L3 1634/0 12794 1.0 259.7 31.7 228.1 254.1 26.1 8.0 20.8
Sum 3704/0 29342 0.0 690.1 100.8 589.3 718.7 129.4 21.9 22.5
关键字段解读:
Score:层级的紧迫度。对于 L1 及以上,Score = 当前大小 / 目标大小。Score > 1 意味着该层需要 compaction。对于 L0,Score = 当前文件数 / 触发阈值。
W-Amp(Write Amplification):该层的写放大因子。L1 的 5.1 意味着每写入 1 字节用户数据,L1 的 compaction 需要写 5.1 字节。Sum 行的 W-Amp 是整体写放大。
Rn(GB) 与 Rnp1(GB):compaction 从当前层和下一层分别读取的数据量。Rnp1 远大于 Rn 通常说明下一层数据量大,compaction 需要重写大量已有数据。
Stall(cnt):写停顿次数。非零值是需要立即关注的信号。
写放大过高:如果总体 W-Amp 超过 20~30,考虑切换 compaction 策略(从 Leveled 改为 Universal)或调整 max_bytes_for_level_multiplier。
L0 文件堆积:如果 L0 的文件数持续接近或超过 level0_slowdown_writes_trigger,说明 flush 产出速度超过了 L0 到 L1 的 compaction 速度。增加 max_background_compactions 或降低 write_buffer_size 可以缓解。
某层 Score 持续 > 1:说明 compaction 带宽不足以消化该层的数据增长,需要增加后台线程或检查磁盘 I/O 是否已饱和。
DB::GetProperty() 提供了一种轻量级的方式来查询数据库的实时运行状态。相比 Statistics 的累计指标和 Compaction Stats 的周期性快照,DB Properties 更适合回答「此刻数据库处于什么状态」这类问题。
std::string value;
// 估算的 key 总数
db->GetProperty("rocksdb.estimate-num-keys", &value);
// 等待 compaction 的预估字节数
db->GetProperty("rocksdb.estimate-pending-compaction-bytes", &value);
// 当前是否有 compaction 在排队
db->GetProperty("rocksdb.compaction-pending", &value);
// 当前活跃 memtable 大小
db->GetProperty("rocksdb.cur-size-active-mem-table", &value);
// SST 文件 reader 占用的预估内存
// (不含 block cache 中的 filter 和 index block)
db->GetProperty("rocksdb.estimate-table-readers-mem", &value);
// 完整统计信息(等价于 LOG 文件中的周期输出)
db->GetProperty("rocksdb.stats", &value);
// Block cache 使用量
db->GetProperty("rocksdb.block-cache-usage", &value);
// 每一层的文件读取延迟直方图
db->GetProperty("rocksdb.cf-file-histogram", &value);
// 当前 LSM 版本号(每次 flush/compaction 后递增)
db->GetProperty("rocksdb.current-super-version-number", &value);
DB Properties 非常适合接入外部监控系统。一个常见模式是启动一个后台线程,定期采集关键 Properties 并推送到 Prometheus / Grafana:
void monitor_loop(DB* db) {
while (running) {
std::string val;
db->GetProperty("rocksdb.estimate-pending-compaction-bytes", &val);
prometheus_gauge_set("rocksdb_pending_compaction_bytes", std::stol(val));
db->GetProperty("rocksdb.block-cache-usage", &val);
prometheus_gauge_set("rocksdb_block_cache_usage", std::stol(val));
db->GetProperty("rocksdb.cur-size-active-mem-table", &val);
prometheus_gauge_set("rocksdb_active_memtable_bytes", std::stol(val));
std::this_thread::sleep_for(std::chrono::seconds(15));
}
}
尽管 RocksDB 的监控基础设施已经非常丰富,但它仍然停留在「原始数据」层面——输出的是文本格式的统计表格和数字。要想获得直观的可视化,用户必须自行搭建 Prometheus + Grafana 管线,编写采集脚本,配置 dashboard。对于嵌入式使用场景(RocksDB 作为库链接到应用程序中),这套工具链并不轻量。
ToplingDB 是基于 RocksDB 的增强分支,在可观测性方面做了一个非常有意义的探索:内置嵌入式 Web Server,让用户通过浏览器即可查看几乎所有数据库运行状态。
ToplingDB 的 SidePlugin 机制允许通过 YAML 配置文件定义所有 DB 组件(Cache、TableFactory、MemTableFactory 等),并通过一个嵌入式 HTTP 服务器暴露所有对象的运行时状态。配置示例如下:
http:
document_root: /dev/shm/rocksdb_resource
listening_ports: '127.0.0.1:2011'
Statistics:
stat:
class: Statistics
params:
stats_level: kExceptDetailedTimers
启动后,在浏览器中访问 http://127.0.0.1:2011 即可看到:
此外,ToplingDB 还内置了 Prometheus metrics 端点,可以直接被 Prometheus 抓取,无需用户编写额外的 exporter 代码。
ToplingDB 的做法代表了嵌入式存储引擎可观测性的一个演进方向:从「提供数据」到「提供洞察」。用户不再需要理解 rocksdb.stats 输出中每一列的含义,而是可以通过可视化界面直接看到 LSM-Tree 的形态是否健康、compaction 是否跟得上、哪一层是瓶颈。
理解了 RocksDB 的四大可观测性支柱后,我们可以构建一套分层的监控策略:
第一层——常态监控(基于 Statistics + DB Properties):部署持续采集脚本,将 block cache 命中率、写停顿时间、pending compaction bytes 等核心指标推送到时序数据库。设置告警阈值:写停顿超过 N 微秒、pending compaction bytes 超过 M GB 时触发。
第二层——异常诊断(基于 Compaction Stats):定期检查 LOG 文件中的 compaction 统计,关注 W-Amp 趋势和各层 Score。当告警触发时,这些数据是第一手线索。
第三层——深度剖析(基于 Perf Context):当定位到具体的慢查询模式时,通过采样启用 Perf Context,拆解延迟到 memtable 查找、block 读取、bloom filter 命中等粒度,精准定位根因。
这三个层次从粗到细,逐步深入,覆盖了从日常巡检到故障排查的完整场景。