要论 RocksDB 性能分析,我折腾了不短时间,最大的感受是:工具越多,越容易原地打转。perf_context、perf、ftrace、systemtap 个个都被挂在嘴边,但真遇到线上读放大、CPU 飙高、P99 延迟不稳的时候,经常不知道该先看哪一个。后来我把这几样东西的定位彻底想清楚了——perf_context 是 RocksDB 自己的"内功",负责告诉你一次操作在 DB 内部每一步花了多少时间;perf、ftrace、systemtap 是自外向内的"颜值",负责从操作系统层面告诉你进程到底在干什么。这篇文章把这几样工具怎么开启、怎么读数据、各自解决什么问题、怎么串成一条完整的分析链路讲清楚,适合正在给 RocksDB 做压测、排查延迟尖刺,或者单纯想搞懂性能数据到底怎么来的同学参考。
1. 性能分析为什么要有"内功"和"颜值"之分
1.1 一个让我绕弯子的场景
之前我给一个跑了好久的 RocksDB 实例做健康检查,发现 Get 操作的平均延迟比刚上线时高了将近一倍。我的第一反应是打开 perf top 看 CPU 热点,结果满屏都是哈希查找和内存拷贝相关的函数,看着很热闹,却根本说不清楚问题出在 RocksDB 的哪条路径上——是布隆过滤器失效了?是 block cache 命中率掉了?还是 SST 文件读得太频繁?这些信息 perf 给不了,因为从外面看,整个进程就是一团黑盒。
后来我换了个思路,先打开 RocksDB 自带的 perf_context 统计同一个 Get 操作,一下就看到了关键数据:block_cache_miss_count 比预期高了一个数量级,get_from_table_files 的耗时占比超过 80%。这时候才知道,问题根本不在 CPU 计算,而在于读 SST 文件太多。后续再用 ftrace 去确认 IO 行为,用 systemtap 去量化每次读文件的大小分布,整条链才彻底闭合。
1.2 内部视角与外部视角的边界
这两类工具最大的区别是透视方向不同。perf_context 是 RocksDB 内部的计量仪表,它埋在各个核心函数的入口和出口,能精确统计用户 key 比较次数、block cache 命中次数、WAL 写入耗时、memtable 插入耗时这些"只有引擎自己才知道"的数据。它的优点是精度高、语义清晰、和 RocksDB 的实现一一对应;缺点是它看不见内核,比如一次文件读取在内核里经历了页缓存、IO 调度、块设备驱动各花了多久,perf_context 只能给一个总的"读 SST 文件耗时"。
外部工具恰好补上这一层。perf 能采样 CPU 指令热点、统计缓存命中率和 IPC;ftrace 能追踪内核函数的调用关系,看到 read 系统调用进入内核后到底走了哪条路径;systemtap 则可以在内核函数和用户态函数的任意位置插入自定义探针,你想统计什么就统计什么。这些工具的共性问题是它们站在系统视角,不知道"哈希查找"和"内存拷贝"到底对应 RocksDB 的哪个逻辑环节,所以单独使用容易出现"看不清因果"的尴尬。
我后来总结了一句话:perf_context 负责"精确地知道哪里慢",外部工具负责"理解为什么慢"。两者不是二选一的关系,而是一条完整排查链路的前半段和后半段。下面先把每个工具单独讲透,再串成一个实战案例。
2. perf_context 内功:怎么让 RocksDB 开口讲故事
2.1 开启与读取:PerfLevel 级别和线程视角
perf_context 不是默认开启的,开启方式是通过 SetPerfLevel 函数设置一个全局线程局部变量,然后通过 get_perf_context() 拿到当前线程的统计结构体。这里有两个容易忽略的细节:一是它是线程局部的,你在一个线程里开启,只会记录那个线程后续操作的数据,全进程共享的配置不存在;二是有四个级别,级别越高记录得越多,但对性能的影响也越大。
级别定义可以简单理解成一张表:
| 级别 | 含义 | 适用场景 |
|---|---|---|
| kDisable | 什么都不记 | 线上默认,零开销 |
| kEnableCount | 只记录次数类指标,不记耗时 | 想快速看命中率、比较次数时用 |
| kEnableTimeExceptForMutex | 记录除互斥锁等待时长外的所有耗时 | 常规压测分析推荐 |
| kEnableTime | 连互斥锁等待、条件变量等待都记录 | 排查锁竞争时用,开销最大 |
推荐的做法是在压测环境先用 kEnableCount 排查计数类异常,再用 kEnableTimeExceptForMutex 做详细耗时分解。kEnableTime 能看 db_mutex_lock_micros 和 db_condition_wait_micros,这两个指标对锁竞争非常敏感,但开启后每一处锁操作都会额外读一次时钟,高并发下会对性能产生可感知的影响,不建议长时间开。
读取和清理的代码如下:
// 每次测量前先设置级别并清零 rocksdb::SetPerfLevel(rocksdb::PerfLevel::kEnableTimeExceptForMutex); rocksdb::get_perf_context()->Reset(); // 执行你关心的操作,比如一次 Get rocksdb::Status s = db->Get(read_options, key, &value); // 操作结束后读取指标 const rocksdb::PerfContext* ctx = rocksdb::get_perf_context(); fprintf(stderr, "block_cache_hit_count=%lu, block_cache_miss_count=%lu\n", ctx->block_cache_hit_count, ctx->block_cache_miss_count); fprintf(stderr, "get_from_table_files_nanos=%lu\n", ctx->get_from_table_files_nanos);关键心得是:每次测量只能包一个操作。因为 perf_context 是累加器,如果你在一个循环里连续执行一万次 Get 再统一读数,那么得到的是一个"平均值",无法判断第一次和最后一次的差异。我在做 P99 分析时,通常是取单个 key 连续 Get 十几次,算完再取另一个 key,这样能看出热数据、冷数据的显著差别。
2.2 读路径上必看的指标
读路径是 perf_context 里指标最丰富的地方,也是大多数性能问题暴露得最明显的地方。我每次排查都会先扫几个关键字段:
- user_key_comparison_count:比较用户 key 的次数。这个数字异常高,说明你在做范围遍历或者 memtable/SST 里的二分查找在很多层次上反复发生,往往指向 SST 文件层次太多或者键长度不合理。
- block_cache_hit_count 与 block_cache_miss_count:这两个字段直接决定"你的数据到底有没有被缓存"。命中率低的时候,先别急着调什么参数,要确认是容量不够还是 key 顺序太分散,前者加 cache 容量有效,后者可能要调整压缩或预取策略。
- get_from_table_files_nanos:从 SST 文件读数据的累计耗时。如果这项占 Get 总耗时的比例很高,基本可以断定读放大严重,block cache 没有覆盖到热数据。
- index_block_read_cost 和 filter_block_read_cost:读取索引块和布隆过滤器块的耗时。这两个值单独看很有迷惑性,它们只在索引块/过滤器块未被缓存时才会产生,一旦出现高频未命中,说明 cache_index_and_filter_blocks 这个选项没有打开,或者打开后容量不够导致换出频繁。
读路径的判断逻辑我一般这样走:先看 block_cache_miss_count 是否异常,如果异常,再看 get_from_table_files_nanos 的占比是否很高,如果也高,再看 index/filter 的读取成本。三步下来基本能定位是"数据块没缓存"还是"索引块/过滤器块没缓存",这两类问题的调参方向完全不同,前者要解决 block cache 容量或者淘汰策略,后者要解决索引和过滤器块的驻留问题。
2.3 写路径上必看的指标
写路径的指标比读路径少一些,但同样有主次之分。RocksDB 的一条写入要经历 WAL 落盘、写入 memtable、切换 memtable、后台 flush 到 SST 等多个环节,perf_context 主要记录的是前两步:
- write_wal_time:写 WAL 的耗时。这个值高,要考虑是否开启了 WAL 的 sync 模式,以及存储介质的 fsync 延迟。SSD 上单次 fsync 一般在几十微秒到几毫秒之间,如果 write_wal_time 动不动就上十几毫秒,磁盘大概率有性能问题或者被其他线程抢占了。
- write_memtable_time:写入 memtable 的耗时。这个值高通常意味着 memtable 的并发插入有锁竞争,或者写入的 value 非常大导致内存拷贝耗时上升。
- write_delay_time:因为写入速率过快被流控(write stall)所延迟的时间。这个值一旦出现,说明写入速率已经逼近后台 flush 的能力上限,是明显的容量规划问题。
写路径的分析要小心一点:perf_context 记录的是用户线程在写入过程中花费的时间,不包括后台线程做 flush/compaction 的时间。所以如果你看到 write_delay_time 频繁出现,真正的瓶颈往往在后台,要用外部工具去看 compaction 线程的 CPU 和 IO,而不能只盯着 perf_context。
2.4 最容易误读的三个数字
perf_context 用多了,有几个坑是必须提醒的:
第一,耗时类指标本身有观测者效应。开启 kEnableTime 之后,RocksDB 会在每个统计点的前后各读一次时钟,高并发场景下这会让关键路径上的性能下降 5% 到 15%。所以为了测量准确,应该在压测时先跑一轮"未开启统计"的基线,再跑一轮"开启统计"的对照,两者差出来的部分就是观测开销,别把基线数据的下降误判成业务回退。
第二,不同线程的指标不能直接相加。perf_context 是线程局部的,两个线程各自执行 Get 时,它们记录的 block_cache_miss_count 是各自独立的。如果你想统计整个进程的总 miss 次数,得先在线程退出之前主动把数据收集起来,或者用外部计数器对齐,直接在代码里把两个线程的值相加没有实际意义,因为时间窗口可能完全不同。
第三,perf_context 看不到系统调用内部的等待。比如一次 Get 需要从磁盘读 SST 文件,它会计入 block_read_time,但不会告诉你这次读取在内核中的页缓存命中情况、IO 排队时间、设备响应时间。这就是为什么我始终坚持"内外结合"——内功负责告诉你耗时分布,颜值负责告诉你内核里到底发生了什么。
3. perf、ftrace、systemtap 的颜值:三个方向看同一台机器
3.1 perf:采样和计数,先看有没有"烧错CPU"
perf 是 Linux 平台上最常用的性能剖析工具,它的核心能力是两类:事件计数和采样。事件计数用 perf stat,可以统计指定进程在某个时间段内发生了多少次 cache miss、多少条指令、多少次上下文切换;采样用 perf record,每隔固定数量的事件(或固定时间间隔)记录一次当前正在执行的函数地址,最后用 perf report 把这些样本聚合成热点函数排名。
对 RocksDB 这种 CPU 密集和 IO 密集混合的负载,我一般先用 perf stat 看几个宏观指标:
perf stat -e cycles,instructions,cache-misses,cache-references,context-switches -p <pid> -- sleep 10重点关注两点:一是 instructions 和 cycles 的比值,叫 IPC,如果 IPC 长期低于 0.5,说明 CPU 大部分时间在等待内存或者乱序执行的停顿,指令级并行度很差;二是 cache-misses 除以 cache-references 的未命中率,如果超过 20%,说明数据访问局部性不好,对象可能大范围在内存间跳跃,这往往和 RocksDB 的缓存结构、SST 索引的遍历方式有关。
采样类工具适合定位到底哪个函数在消耗 CPU,用法是:
perf record -F 99 -g -p <pid> -- sleep 10 perf report这里的 -F 99 表示每秒采样 99 次,也就是 10 毫秒一次。选 99 而不是 100 是为了避免和内核定时器的频率产生共振,导致采样点全部落在固定的相位上。这是采样分析的常识,用的频率通常取 49、99、199 这类质数附近的值。
perf 对排查"CPU 占用率骤升"这类问题非常有效,但注意它只能暴露"CPU 在执行什么",对于"CPU 在等待什么"无能为力——当你看到 perf report 里的热点函数占比都很低、大量样本落在内核的 idle 函数时,说明瓶颈根本不在 CPU,而在 IO 或者锁,这时候就要转向 ftrace 和 systemtap。
3.2 ftrace:内核调用链的"透明面板"
ftrace 是内核自带的追踪框架,不需要额外安装,对生产环境的影响也极小。它的作用非常直接:记录某个内核函数被调用时的调用链、次数和耗时,让你看到一次 read 系统调用进入内核后,从 VFS 层到文件系统层再到块设备层的完整路径。
使用 ftrace 的常规流程是:
# 挂载 tracefs,新版内核直接挂到 /sys/kernel/tracing mount -t tracefs tracefs /sys/kernel/tracing cd /sys/kernel/tracing # 把当前追踪器切换为 function_graph echo function_graph > current_tracer # 只追踪我们关心的函数 echo vfs_read > set_ftrace_filter # 开启追踪后观察输出 echo 1 > tracing_on cat trace echo 0 > tracing_onfunction_graph 的输出非常直观,每行显示一个函数的进入和退出,缩进层级表示嵌套调用关系,函数名右侧的耗时是以微秒为单位。比如你看到 vfs_read 下面依次展开了 ext4_file_read_iter、generic_file_read_iter、page_cache_sync_readahead,就能判断出读取路径是在做页缓存预读,还是直接落到磁盘 IO。
ftrace 的缺点是不能追用户态函数,对 RocksDB 这种"用户态逻辑复杂、内核态调用相对简单"的负载来说,单靠它只能确认内核层的行为,无法把内核行为归因到 RocksDB 的某个具体动作。所以我在实际使用中,ftrace 主要用来验证"是不是发生了预期中的 IO 行为",真正做因果定位还是靠 perf_context 或者 systemtap。
3.3 systemtap:没有现成探针就自己造一个
systemtap 和 ftrace 一样也是基于内核追踪框架,但它的灵活性远高于 ftrace。systemtap 提供了一套类 C 的脚本语言,你可以用它动态地在内核函数或用户态函数的入口和出口插入探针,还能定义全局变量、计算直方图、按条件过滤。代价是使用门槛高:需要 root 权限,需要安装内核调试符号包,第一次运行时要编译生成内核模块。
一个很实用的例子是统计进程在读文件时每次请求的大小分布,这在分析 RocksDB 读放大时非常有用:
global io_size probe syscall.read { io_size <<< $count } probe end { print(@hist_log(io_size)) }运行方式是用 stap 命令加载脚本,同时绑定目标进程。这个脚本会收集目标进程所有 read 系统调用的请求字节数,最后输出一个对数直方图。如果你看到大量请求集中在 4KB 和 32KB 两个桶,说明 RocksDB 正在按固定块大小做预读,如果集中在一个 128KB 的大桶,说明读取逻辑有合并行为。
systemtap 还支持用户态探针,可以直接对 RocksDB 二进制文件里的某个函数打点。比如你想看 Get 操作里 BlockBasedTable::Get 这个内部函数的耗时分布,就可以用 process("/path/to/binary").function("BlockBasedTable::Get") 这样的探针。这个能力比 perf_context 更自由,因为你不需要修改 RocksDB 的代码,也不需要重新编译。
不过 systemtap 在动态编译内核模块的时候会让系统短暂卡顿,千万不能直接在生产环境上跑,最好在压测环境提前把脚本和调试符号都准备好,编译一次之后后续就快了。
3.4 三个工具怎么选
| 工具 | 观测面 | 典型用途 | 主要代价 |
|---|---|---|---|
| perf | CPU 事件、采样热点、缓存命中 | 定位 CPU 热点、判断 IPC 和缓存性能 | 采样存在误差,看不到单次函数耗时分布 |
| ftrace | 内核函数调用链 | 看清系统调用在内核中的路径 | 不能追踪用户态,输出量大需要过滤 |
| systemtap | 任意内核/用户态函数 | 自定义指标、IO 大小分布、单函数耗时 | 门槛高,首次编译可能阻塞系统 |
选型逻辑不复杂:先问自己要解决什么问题。CPU 热点就用 perf,内核实锤就用 ftrace,需要自定义指标就用 systemtap。三者不是互斥的,我经常先跑 perf stat 看宏观,再跑 ftrace 看一次典型 IO 的内核路径,最后用 systemtap 做一轮长时间的数据分布统计。
4. 一次读延迟恶化的完整排查:内外结合的标准套路
4.1 现象与第一刀:perf stat 告诉你 CPU 在等什么
某个压测环境的场景是这样的:对两千万 key 的数据库做随机 Get,QPS 在 20000 左右的时候,P99 延迟从正常的 800 微秒涨到了 3 毫秒。这个数据本身不足以说明问题,我决定按套路一步步排查。
第一刀是 perf stat 看宏观:
perf stat -e cycles,instructions,cache-misses,cache-references -p <pid> -- sleep 5结果很有意思:IPC 只有 0.3,cache-misses 占了 cache-references 的 35% 左右。说明 CPU 大量时间卡在内存访问上,缓存命中率很低。但这是内存带宽问题还是 IO 问题?perf 回答不了,因为 CPU 可能在等内存,也可能在等磁盘 IO 完成后唤醒。
第二步要确认是不是真的在频繁读磁盘,这需要看 block 层的 tracepoint。perf 其实也能用 tracepoint 事件,但我觉得 ftrace 的输出更适合做这类验证。
4.2 内部归因:perf_context 把 Get 的时间账算清楚
在确认了系统层可能有 IO 压力之后,我回到 RocksDB 内部,用 perf_context 对单次 Get 做精确归因。采样脚本的逻辑是:随机挑 1000 个 key,对每个 key 连续 Get 三次,取第一次(likely cold)和第三次(likely warm)分别统计指标。
这里我重点读了几个字段:
| 字段 | 第一次 Get | 第三次 Get | 判断 |
|---|---|---|---|
| block_cache_hit_count | 0 | 1 | 数据块缓存未生效 |
| block_cache_miss_count | 1 | 0 | 首次必然 miss,符合预期 |
| get_from_table_files_nanos | 3.2ms | 0.01ms | 首次读取 SST 耗时极大 |
| index_block_read_cost | 390ns | 0 | 索引块需要从文件读 |
从这些数据可以得出一个清晰的结论:问题出在"热数据无法驻留在 block cache 中"。同一把 key 连续读三次,第三次能命中,说明数据块本身可以被缓存;但延迟高发生在第一次,也就是第一次冷读时要从 SST 文件里把数据拉上来。真正值得深究的是,为什么冷读时 index 块也要现读?正常情况下,索引块应该从一开始就被缓存在内存里的。
4.3 外部验证:ftrace 看 IO、systemtap 量大小
为了确认第一次 Get 是不是真的触发了磁盘 IO,我用 ftrace 追踪了一次 Get 操作对应的 read 系统调用路径:
echo function_graph > current_tracer echo vfs_read > set_ftrace_filtertrace 输出里能看到一个明显的 vfs_read 调用,它下面的 function_graph 展开了两层:一层是 page_cache_sync_readahead,另一层是 ext4_mpage_readpages。这两个函数的出现基本可以确定:这一次读取走了真正的文件系统预读路径,而不是命中页缓存。也就是说,block cache miss 之后,RocksDB 发起了对 SST 文件的磁盘读取。
接下来用 systemtap 量化一下读请求的大小分布,确认是否存在明显的读放大:
global read_size probe syscall.read { if(pid() == target()) read_size <<< $count } probe end { print(@hist_log(read_size)) }几分钟后输出的直方图显示,读请求大量集中在 4KB 到 16KB 的区间,说明每次 Get 都只读一小块数据,但读的次数非常多。这个数据基本推翻了"顺序预读"的可能,也佐证了 random point lookup 场景下 block cache 效率的重要性。
4.4 优化动作与效果对比
把三份证据拼在一起,根因就很清楚了:block cache 容量设置得偏小,热数据虽然能驻留,但在随机读场景下,索引块和过滤器块也参与缓存竞争,导致 index/filter 块被频繁换出,一旦某个 key 对应的 index 块不在缓存里,RocksDB 就需要从磁盘读一次索引块再读一次数据块,一次 Get 变成两次磁盘 IO。
优化动作主要有三个:一是把 block_cache 容量从原来的 2GB 提到 6GB;二是打开 cache_index_and_filter_blocks 选项,让索引块和过滤器块优先驻留缓存;三是调整 index_type 为 kHashSearch(针对点查场景)并配合 hash_index_allow_collision,虽然这会多占一点内存,但能显著减少索引块的访问次数。
调整后重新跑同一轮压测,P99 从 3 毫秒降回 700 微秒左右,perf stat 里的 cache-misses 也从 35% 降到了 11%。这说明问题确实不在硬件而在缓存策略,内外结合的分析方式直接帮我把问题从"系统层可能有问题"收敛到了"RocksDB 缓存配置需要调整"。
5. 实战中的坑与测量习惯
5.1 perf_context 是线程局部的,别相信"设了一次就全局生效"
开发同学最容易踩的坑是:在初始化代码里调用了 SetPerfLevel,以为全局都生效了,结果发现压测线程里的统计值全是零。因为 SetPerfLevel 设置的是当前线程的 thread local 变量,其他线程如果没执行过这个调用,就维持 kDisable。解决办法是在每个工作线程的开头显式调用一次,或者把它封装进数据库初始化的线程回调中。还有一个坑是 Reset 时机:如果你把 Reset 放在操作之前,而操作中间有回调或异步逻辑,某些指标可能被其他线程的统计覆盖,导致数据串线。稳妥的做法是操作结束后立刻把 PerfContext 里的关键指标复制出来,再做解析。
5.2 观测者效应:打开的统计本身就在拖慢系统
我用 systemtap 跟踪一次高并发写入实验时,发现只要 stap 脚本处于运行状态,写入 P99 就会从正常的 1.2 毫秒跳升到 4 毫秒以上。原因是 systemtap 的内核探针会在每个被探测函数入口停下并执行一段上下文逻辑,这个开销虽然单次只有几百纳秒,但在高频率调用下会被放大。perf record 的采样也有同样的问题,只不过幅度小得多。所以测量一定要遵守三条原则:第一,优先在压测环境做;第二,测量目的明确后再开工具,避免长时间开着全局采样;第三,用 A/B 对照,先测一轮无工具的基线,再测一轮带工具的数据,两者差值就能估算观测开销。
5.3 采样数据要放在一起看,别单看一个指标
perf_context 的耗时指标、perf 的采样热点、ftrace 的函数调用链,每一样单独拿出来都可能产生误导。举例来说,perf_context 显示 get_from_table_files_nanos 高,并不一定意味着磁盘慢,也可能是 block cache 命中率低导致读盘次数多;perf 显示某个哈希函数热点高,不一定是哈希函数本身太慢,可能是 key 分布太差、比较次数暴增引起的。我的习惯是:先用 perf stat 看系统层的宏观指标(IPC、cache miss、context switch),再用 perf_context 把 RocksDB 内部的时间账算清楚,如果发现读写路径上有可疑高耗时,再用 ftrace/systemtap 去验证内核层是否匹配。四份数据能互相解释,这个结论才可靠。
5.4 一份能直接抄的测量检查清单
最后整理一份我在做 RocksDB 性能分析时固定执行的清单,每一条都踩过坑:
- 确认压测环境与生产环境的硬件、内核版本、文件系统一致,页缓存大小和 NUMA 拓扑尽量接近。
- 用 perf stat 记录基线:IPC、cache-misses、context-switches,之后每次调整参数都重新记录,用同一份数据对比。
- 用 perf_context 做单操作归因时,务必把 Reset 放在操作之前、立刻读取放在操作之后,一次操作一次读数。
- 遇到读路径问题,先查 block_cache_hit/miss 和 get_from_table_files_nanos,再查 index/filter 块读取成本,不要跳过中间环节直接调 block cache 容量。
- 遇到写路径问题,先看 write_wal_time 和 write_memtable_time,再看 write_delay_time,后台 flush/compaction 问题需要转到外部工具观察。
- 用 ftrace 确认内核路径、用 systemtap 确认 IO 大小分布时,永远要在压测环境先试跑一遍脚本,避免在生产环境首次编译阻塞系统。
- 所有测量结束后,回归一轮不开启任何工具的完整测试,确认观测开销没有影响最终结论。
这套东西整理完,我心里踏实了很多。RocksDB 本身就是一个"内部逻辑极其复杂、外部系统调用又高度密集"的组件,单靠任何一个工具都会陷入盲人摸象的局限。perf_context 负责告诉你"它在内部哪一步花的时间多",perf、ftrace、systemtap 负责告诉你"它为什么会在这个环节花时间",前后咬合才能把问题一次看清。建议所有做存储引擎性能分析的同学,把这几项工具的搭建和基础用法先在压测环境过一遍,真到线上出问题的时候,你才能从容地把"内功"和"颜值"组合起来打一套完整的拳。