我先说一个我实际遇到的场景:某后端服务的容器内存告警半夜把我叫醒,top里RES稳定在几十G,而我明确知道这个服务只做存储转发,理论上不应该有这么高的内存。查了一圈,服务的核心存储组件用的是RocksDB,日志里也没有OOM,GC也没异常,于是我把怀疑对象锁定在RocksDB的内存“不释放”上。
很多人在这个节点会下意识得出“RocksDB内存泄漏了”的结论,但我建议先冷静一步:RocksDB的内存“不释放”和真正的内存泄漏是两回事,甚至大部分时候不是泄漏,而是“内存没有归还给操作系统”或“缓存按设计占着内存”。这篇文章我从现象、内存去向、定位链路、常见成因、参数解法几个维度完整拆一遍,希望能帮遇到同类问题的人少走弯路。
1. 先看现象:“内存只涨不降”真的等于泄漏吗
线上服务最常见的表象是这样的:RocksDB 进程 RSS 持续走高,或者在一个大写入之后突然上涨,之后一直维持在一个高位,怎么等都不降。一些监控图会显示“锯齿状上升”或者“台阶式上升”,每次触发一次大 Compact 后 RSS 会上一个台阶。
但这里有个关键区别:
- 对象确实没释放:代码或底层逻辑持有内存,比如迭代器没析构、Snapshot 没释放、Block Cache 容量不断膨胀。
- 对象已经释放,但分配器没还给 OS:内存从 RocksDB 的角度已经“free”了,但 jemalloc/glibc malloc 把内存留在进程的 arena 里,为了后续复用。这是非常常见的一种“假不释放”。
第二种情况不能算泄漏,因为进程内存不会无限增长,通常会稳定在一个峰值附近。第一种情况才是需要优先排查的。
所以拿到“内存不释放”的报告,先别急着翻代码去找谁泄漏了。先回答三个问题:
- RSS 是无限增长,还是涨到某个水位后稳定?
- 增长和写入量、查询量、Compaction 之间有没有明显的时间相关性?
- RocksDB 自身统计的内存占用(block cache、memtable、table reader)有没有同步上涨?
如果第三个问题的数据一直是稳定低值,但 RSS 高,那大概率是分配器层面的“持有”问题,后面我会专门讲。
2. RocksDB的内存去向拆解:谁在占内存,占的是什么内存
要定位,首先得知道 RocksDB 大体上把内存花在了哪。我用一个比喻来理解这件事:RocksDB 的进程内存就像一个储物间,里面分成几个柜子,每个柜子都有自己的“放东西规则”和“清理规则”。你看到储物间满了,得先搞清楚是哪个柜子堆满了,而不是直接把储物间炸了重盖。
RocksDB 内存主要分以下几块,它们的生命周期和释放条件完全不一样。
2.1 Block Cache:读路径的内存大头
Block Cache 是 RocksDB 读路径上最核心的缓存,默认是 LRU 策略。缓存的内容包括 SST 文件中的数据块(Data Block)、索引块(Index Block)、布隆过滤器(Filter Block),以及压缩字典等。
这里有个非常容易踩的坑:cache_index_and_filter_blocks这个选项默认是 false。什么意思呢?索引块和过滤器块不会进入 Block Cache,而是单独被加载到堆内存里,并且一旦某个 SST 文件成为“活跃”状态,它的索引和过滤器就可能长期留在内存里。很多服务查询量大、打开的 SST 文件多,这部分内存累加起来非常可观。
如果你把cache_index_and_filter_blocks设为 true,索引和过滤器就会纳入 Block Cache 的统一 LRU 管理,内存紧张时可以被淘汰,整体内存会平滑很多。代价是每次读可能需要重新构建索引占用的内存,或者需要从磁盘重新加载索引块,换取一些 CPU 和延迟。
Block Cache 的总容量由block_cache_size或创建Cache对象时传入的容量决定。它不会无限增长,但如果你给了很大的容量,内存自然会保持在高位。这不是泄漏,而是你给它的“柜子”就是这么大。
2.2 MemTable与Arena:写路径的临时内存
写路径上,每条写入先进 MemTable。每个 Column Family 至少有一个活跃的 MemTable,当它超过write_buffer_size后变成不可变 MemTable,等待后台 flush 成 SST 文件。
MemTable 的内存分配不是一个个 malloc,而是使用 RocksDB 自研的 Arena 分配器。Arena 会预申请大块连续内存,然后在这个内存块里做线性分配。这样的好处是单条写入的开销极低、CPU cache 友好,坏处是——即使 MemTable 已经 flush 完成,这块大内存释放后也不会立刻变成“进程可用内存”的直观下降,而是先回到分配器手里。
更麻烦的情况是:如果写入速度超过 flush/compaction 速度,不可变 MemTable 会堆积。max_write_buffer_number如果设置得很大,比如 8 个,内存峰值可以轻易达到write_buffer_size * max_write_buffer_number * ColumnFamily数量。
所以看到内存上升到某个值后保持住,两三成概率是写缓冲堆积,而不是 Block Cache。
2.3 SST文件句柄与Index/Filter:常驻式内存
RocksDB 打开一个 SST 文件后,会在 TableCache 中保留对应的 TableReader 对象,其中包含文件句柄、索引块、过滤器块等。这个缓存的上限由max_open_files控制。注意,默认情况下它可能是-1,表示所有 SST 文件都保持打开状态。
当你的库有几千个 SST 文件时,每个文件的索引和过滤器就算只有几 MB,加起来也相当可观。RocksDB 有个属性叫estimate-table-readers-memory,可以直接看到这块内存的估算值。
比如 5000 个 SST 文件、平均每个文件 2MB 索引与过滤器,那光是 TableReader 相关内存就是 10GB 级别。这种场景下进程 RSS 高几乎是必然的,和“泄漏”两个字完全不沾边。
我把这三块核心内存整理成一张对照表,方便做初步判断:
| 内存去向 | 主要包含内容 | 什么时候会释放 | 常见“不释放”原因 |
|---|---|---|---|
| Block Cache | Data Block/Index/Filter | 容量不足触发 LRU 淘汰 | 容量设太大,或索引过滤器不纳入淘汰 |
| MemTable | 活跃/不可变写缓冲 | 后台 flush 完成后 | 写入落后,flush/compaction 跟不上 |
| TableReader | 打开的 SST 文件句柄+索引 | 表被淘汰或 DB 关闭 | max_open_files=-1,文件数堆积 |
3. 一套靠谱的定位链路:从RSS到RocksDB内部
我习惯按“进程层 → RocksDB 层 → 分配器层 → 复现验证”的顺序排查,每一步都能筛掉一批可能性。不要一上来就调参数,先确认原因。
3.1 先看进程级数据,框定嫌疑范围
第一步是先把被怀疑对象的“基于内存数据”收集起来:
cat /proc/<pid>/smaps:看Heap、可执行文件映射等区域变化,重点观察进程堆是否不断变大。top/htop:观察 RSS 是否持续增长还是走平。- 用
pmap -x <pid>:看成片大片 64MB/4MB 的内存段是否反复出现。这种很可能是 Arena 或 Jemalloc 提前预留的 chunk。
同时把最近时间点的写放大、Compaction、读放大事件时间线拉出来。如果 RSS 上涨和大量flush/compaction平行发生,基本可以确定是 RocksDB 在忙后台任务,而不是外部堆泄漏。
在进程层阶段,如果 RSS 仍然保持“每个小时增长,永不走平”,真的要小心;如果只是“从一个高位稳定到另一个高位”,不必慌了,问题多半在内层。
3.2 用RocksDB自带指标缩短排查距离
RocksDB 提供了非常实用的 property 接口,在 C++ 里可以直接取到:
std::string v; db->GetProperty("rocksdb.block-cache-usage", &v); db->GetProperty("rocksdb.cur-size-all-mem-tables", &v); db->GetProperty("rocksdb.estimate-table-readers-memory", &v); db->GetProperty("rocksdb.size-all-mem-tables", &v); db->GetProperty("rocksdb.live-sst-files-size", &v);对应到命令行工具,比如ldb、db_bench场景,也可以通过调试接口或日志输出这些指标。
如果你用的是不同语言 Bindings,也基本都有封装,比如 Java 的getProperty()、Go 的GetProperty()。
拿到这组数据后,我习惯做一个小脚本每 10 秒采集一次,同时记录 RSS。然后观察:
- RSS 涨了 5GB,
block-cache-usage也跟着涨了 5GB —— 问题在缓存配置,要么容量给大,要么缓存换入换出策略导致内部堆积。 - RSS 涨了 5GB,
cur-size-all-mem-tables增长明显 —— 写缓冲堆积,去查 flush 线程和写入速率。 - RSS 涨了 5GB,这三个属性都没怎么变 —— 那内存可能压根不在 RocksDB 的对象统计范围里。可能是分配器持有,也可能是其他模块占用。
这一步做完,判断区间能缩小到具体“柜子”。
3.3 区分“对象没释放”和“分配器没归还”
这一步是很多人卡住的地方。RocksDB 报告 MemTable 内存已经从 8GB 掉到 800MB,但进程 RSS 纹丝不动,于是觉得统计接口有问题,或者认为 RocksDB 在骗人。
真相是:MemTable 的 Arena 内存被释放后,底层的内存分配器未必会把整块内存归还给操作系统。进程 RSS 的单位是“页”,分配器为了提高性能,会把空闲的页留在自己的 arena 里。只有当内核需要内存、或者分配器发现空闲内存超过阈值并触发 trim 时,某些页才可能被清理。
我给出两个现场判断方法:
- 在操作系统层面测试归还:对 glibc malloc,可以临时在测试环境调
malloc_trim(0),观察 RSS 是否显著下降。如果下降幅度巨大,证明内存其实已经释放,只是被分配器留在手里。 - 使用 jemalloc 时,查看
stats.arenas.<i>.dirty/muzzy等字段。jemalloc 把内存分为 active、dirty、muzzy,只有 dirty 和 muzzy 才有可能被归还。如果 RSS 和 active 相差很大,说明多个 chunk 被保留,等待复用。
这里强调一下:分配器“不归还”不是缺陷,是设计取舍。归还缺页给 OS 会产生较大开销,如果下一次写入立刻又需要内存,反而增加延迟。所以不要看到现象就追杀分配器。
3.4 用堆分析与可控复现钉死结论
如果内部指标和分配器层都指向“真有对象没释放”,那就需要换工具了。我的经验是:
- 使用 jemalloc 时,构建带
--enable-prof,通过JE_MALLOC_CONF=prof:true,prof_active:false启动,运行过程中用 mallctl 动态开启采样。对比两个时间点的 heap profile,查看 top 增长栈。 - 如果线上不能重启,配合
LD_PRELOAD替换 Redis/其他服务的分配器也很常见,但替换到 RocksDB 相关服务时务必在压测环境先验证。 - 纯用户态对象泄漏也可以用
gdb挂上去看是否有异常数量的活跃迭代器、snapshot、column family handle。不过更简单的是在代码里定期统计db->GetSnapshot()数量,排查是否有人拿了 snapshot 不释放。
一个比较典型的复现实验是这样做的:
- 压测环境跑固定 workload,比如每小时写入 100GB。
- 分别在写入前、写入中、写入后采样 RSS 与
db->GetProperty。 - 写入停止后继续观察 1 小时,如果 RSS 仍然不回落,先执行一次
malloc_trim(0)看现象。 - 如果回落到接近基线,说明你看到的是分配器持有的释放内存;如果完全不动,再进入堆分析。
4. 常见成因与对应解法:这块内存到底怎么处理
确认原因后,具体操作才有意义。下面按我在生产里见到的频率逐个说。
4.1 Block Cache容量和索引/过滤器的管理
Block Cache 类问题的解法,第一步是核对容量配置。如果你给 Block Cache 分配了物理内存的 60%,那峰值高是完全正常的,因为 LRU 的职责就是“尽量把容量占满”。误区是把缓存容量当成“上限占用”,实际上 LRU 缓存会想方设法填到接近上限。
第二步是检查cache_index_and_filter_blocks。如果你的场景读多写少、SST 文件多,我会建议手动打开这个选项,让索引和过滤器也能被统一淘汰。这一条往往能省下大规模内存。
在代码里大致是这样的:
rocksdb::BlockBasedTableOptions table_options; table_options.block_cache = rocksdb::NewLRUCache(4 * 1024 * 1024 * 1024ULL); table_options.cache_index_and_filter_blocks = true; table_options.pin_l0_filter_and_index_blocks_in_cache = false;pin_l0_filter_and_index_blocks_in_cache这个参数要小心:如果设为 true,会把 L0 层文件的索引和过滤器钉在缓存里,内存表现会更稳定,但也意味着真正的“不淘汰”内存更多。通常不建议为了性能一刀切开true,除非你对 L0 的文件数量有严格上限。
如果你要临时降低 Block Cache 内存,在运行时也可以做:
auto cache = table_options.block_cache; cache->SetCapacity(1 * 1024 * 1024 * 1024ULL);SetCapacity会触发 LRU 淘汰,把多余 block 释放。不过这个操作会影响读性能,一般只作为应急手段,不建议作为常规配置。
4.2 MemTable堆积与后台flush滞后
MemTable 内存居高不下,根因是写速度远高于 flus 速度。要么写入模型太差,比如一次性灌入非常大的批量,要么磁盘太慢,flush 本身来不及。
我遇到过一个具体场景:某服务每分钟写入 2GB,但write_buffer_size还是默认的 64MB,结果大量不可变 MemTable 排队等 flush。RSS 很快冲到 20GB,而之间没有实际的泄漏。
这种场景下,调大write_buffer_size往往比调大 flush 线程更有效。因为 flush 线程再多,如果每个 memtable 太小,会产生大量小 SST,反而加剧写放大和压缩开销。把write_buffer_size从 64MB 调大到 256MB,并保持max_background_flushes不低于 4,通常能显著降低堆积数量。
同时要检查max_write_buffer_number。这个值默认是 2,可很多生产配置会随手调到 8 甚至更大,导致内存预算被遗忘。建议是:把max_write_buffer_number当作“内存上限”而不是“性能参数”来设计,并为每个 Column Family 预留冲出一个 buffer 的空间。
4.3 迭代器、Snapshot与打开的表句柄
这块属于典型的“代码层面没释放”问题。
不少应用习惯在请求里创建迭代器,用完后不析构,导致迭代器持有的 Block 和 TableReader 滞留在缓存里。不要小看这个,只要同时有几个长时间运行的迭代器,几 GB 内存就没了。
Snapshot 同理。GetSnapshot()之后必须配套ReleaseSnapshot(snapshot),否则它会让某些 MemTable 和版本内的旧文件不能被清理。尤其是你用了事务或者长时间读一致性的场景,一定给 snapshots 上一个生命周期跟踪。
另外关注max_open_files。如果不设,等于所有 SST 文件都打开。对于文件数上万的库,即使每个文件只挂了几 MB 的索引,内存也是恐怖的。可以把max_open_files设为比如 10000,然后看rocksdb.estimate-table-readers-memory的变化。注意 L0 的文件有时候强制需要 open,但这个参数还是能严格限制绝大多数 TableReader 的驻留量。
4.4 内存分配器策略与释放行为
分配器层面的“不释放”,处理方式取决于你用的是哪一套。
- glibc malloc:可以通过环境变量
MALLOC_ARENA_MAX=4限制多核场景下 arena 数量。arena 太多会让内存碎片增加,峰值更高。线上如果并发线程很高,这个值值得调。 - jemalloc:关注
dirty_decay_ms和muzzy_decay_ms。默认值通常是 10000ms,意味着脏页 10 秒后才会尝试归还。你可以原地调低,比如MALLOC_CONF="dirty_decay_ms:1000,muzzy_decay_ms:1000",让内存更“敏感”,但代价是分配性能下降,建议压测后选平衡点。 - tcmalloc:通常看
tcmalloc_release_rate等参数,可以根据源码接入。
如果确认是大量空闲内存被分配器持有,最安全的临时做法是向 OS 发起“建议归还”。C/C++ 里直接:
#if defined(__GLIBC__) malloc_trim(0); #endifmalloc_trim(0)会扫描 glibc 堆里的空闲 chunk,并尽量归还给 OS。不过它不适用于 jemalloc/tcmalloc。jemalloc 里对应的操作是调用mallctl:
mallctl("arena.<i>.purge", NULL, NULL, NULL, 0);或者运行时设置:
MALLOC_CONF="dirty_decay_ms:0,muzzy_decay_ms:0"我一般不推荐线上频繁执行这类操作,除非你确认公司内的内存规格很紧张,且这种“归还”能换来稳定性。
5. 一套可落地的内存预算与监控配置
在解决完手头问题后,我建议给所有用到 RocksDB 的服务加一套显式的内存预算设计和监控门槛。没有预算,就等于允许进程内存无规则疯涨,最终靠硬性 OOM 一刀切。
5.1 给RocksDB定一个总内存预算
设计 RocksDB 内存时需要遵循一个非常简单的关系:
期望RSS = Block Cache 容量 + 所有 MemTable 预期峰值 + TableReader 预期占用量 + 压缩/写缓冲等临时内存余量 + 分配器持有余量我习惯按服务可用内存的三到四成做总预算。举例:容器可用内存 32GB,我会给 RocksDB 规划 12GB 左右,其中:
- Block Cache 8GB
- MemTable 总缓冲 2GB
- TableReader 可控在 1GB
- 剩余 1GB 作为临时内存余量
这种分配能保证后台 Compaction 时不会出现“RSS 瞬时超过容器限制”的尴尬。
5.2 关键参数推荐与联动关系
下面这些参数通常需要一起看,才是最佳的调节组合:
| 参数 | 作用 | 调节建议 |
|---|---|---|
write_buffer_size | 单个 MemTable 大小 | 64MB~256MB,参考单次写入峰值 |
max_write_buffer_number | 每个 ColumnFamily 的最大写缓冲数 | 3~6,记得乘上 CF 数量 |
max_background_flushes | 后台 flush 线程数 | 4~6,不要只靠这一项抗压 |
cache_index_and_filter_blocks | 索引/过滤器是否纳入 Block Cache | 建议 true |
max_open_files | 最多保持打开的 SST 文件数 | 根据文件总量控制 |
bloom_locality/block_size | 索引/过滤器的内存密度 | 小数据块更耗内存,大数据块更耗读 IO |
有个常见误区:以为把max_background_compactions调得很大就能解决内存问题。实际上 Compaction 过程中需要读入多个 SST 的 index/filter,也会产生临时内存放大。Compaction 并发越高,峰值内存越高。
5.3 线上监控与告警门槛
监控项我不主张只盯 RSS,一定要同时盯 RocksDB 内部属性。建议至少采集:
rocksdb.block-cache-usagerocksdb.cur-size-all-mem-tablesrocksdb.estimate-table-readers-memoryrocksdb.live-sst-files-sizerocksdb.num-running-compactionsrocksdb.num-running-flushes
告警规则可以参考这个思路:
- RSS > 容器内存的 70%,持续 10 分钟,告警。
estimate-table-readers-memory单次采样 > 整体内存预算的 30%,告警。cur-size-all-mem-tables连续 3 次采样不下降,且写入量没有明显增长,告警。- RSS 高位持续同时
block-cache-usage低于下限,比如 Block Cache 只用了不到 10%,说明可能有其他模块吃内存,需要人工排查。
这套指标对齐以后,以后内存告警再响,你不用半夜爬起来猜,而是直接看监控图上哪条曲线在涨。
6. 最后再补充几个实践中的小结论
文章已经很长,我最后再啰嗦几条从实战里得来的判断经验。
第一,遇到“RocksDB 内存不释放”,优先怀疑配置,而不是源码。RocksDB 本身经过大规模场景验证,源码级内存泄漏存在的概率不如“参数没配好”高。我处理过的案例中,真正落到业务代码没释放迭代器的有三例,剩下全是 Block Cache / TableReader / MemTable 堆积。
第二,千万不要用“频繁重启进程”来缓解内存。重启只能让问题短暂清零,而且每次重启后缓存都是冷的,读延迟会飙升。问题不解决,重启几次以后内存又会涨回原地。
第三,如果想快速验证“分配器持有内存”这一层,可以用一个很土但有效的方法:在测试环境抓取 RSS,调用一次malloc_trim(0),看 RSS 掉多少。这个方法比改一堆配置都快,能帮你立刻确认是否需要往分配器方向深挖。
第四,RocksDB 的Close并不保证把每一块内存都立刻归还 OS。如果做优雅退出并测量,会发现 RSS 仍然高。这时候 OS 会在进程彻底退出后回收,不属于“泄漏”。所以做这类实验时,最好通过子进程方式运行,观察进程退出后主机内存能否收回。
最后再分享一个我自己的习惯:每次部署前都会在压测环境跑一次“峰值写入→停止写入→静置观察”的三段式内存曲线。只要静置阶段曲线能明显回落,就说明内存是可释放的,只是释放需要点时间;如果静置阶段完全不回落,才值得投入精力做堆分析和代码审查。用这个标准筛下来,我至少省了十几次无意义的告警排查。