1. 刨根问底:RocksDB 内存“不释放”到底是怎么回事
最近好几个朋友拿着内存监控图来找我,说自家服务的常驻内存(RSS)持续在高位徘徊,用ps一看,RSS已经占了好几个G,怎么都降不下来。一查代码,用的是RocksDB,上生产跑了一段时间,内存曲线像被焊死在某个水位线上,gc、restart都试过,最后还是“稳如泰山”。于是大家的第一反应就是:RocksDB内存泄漏了。但说实话,我排查过不少这类问题,真正属于内存泄漏的少之又少,绝大多数是“假性不释放”,也就是RocksDB把内存“管理”起来了,操作系统看不到它归还的动作而已。
要解释这个问题,得先搞明白RocksDB的内存到底花在哪几个地方。很多朋友对RocksDB的印象还停留在“一个嵌入式的KV存储”,觉得它不过是一个写磁盘的库,内存应该没多少开销。但实际生产环境跑下来,内存大头至少在三个区域:Block Cache(数据块缓存)、MemTable(内存表)、以及索引/过滤器等元数据。这三块加起来,一个数据量在百G级别的实例,RSS轻松上几个G甚至十几个G都很正常。
而且很多人有一个根深蒂固的误解:内存“应该”像Java堆或临时对象一样,用完就还给系统。但RocksDB的设计哲学完全不同——它会主动拥抱你给它分配的每一个字节,缓存数据、索引、过滤器、写缓冲,全都要占内存。如果不对这些区域做细致的量化分析,光看RSS曲线就判断“内存泄漏”,很容易造成误判,也容易在生产环境里做出过于激进的裁断。
这篇文章我会从问题定位开始,把RocksDB的内存占用构成拆开揉碎,再结合我在实际项目里排查和调优的经验,聊一聊那些“内存不释放”现象背后的真实原因、排查手段和调优方向。如果你正在被RSS居高不下折磨,那么这篇文章应该能帮你少走不少弯路。
2. 先看现象:RSS居高不下的典型场景
2.1 RSS从不回落,是“不释放”还是“没统计对”
先给一个最简单的场景。某项目组给我看了他们的监控,一个RocksDB实例部署在容器里,配额8G,结果部署第三天RSS就冲到了接近7G。之后无论读写流量怎么变化,内存曲线都很平滑,既不涨也不跌。大家当时很紧张,觉得是不是Key太多导致索引爆炸,或者存在隐性问题。
我让他们做的第一件事不是看代码,而是看RocksDB官方提供的统计信息。先去RocksDB的日志目录翻LOG文件,再看db_bench或者应用侧打的GetProperty。通过rocksdb.db.aggregated-properties等接口,可以直接拿到分类型的内存占用。这里最关键的是用
rocksdb::DB::GetApproximateMemoryUsageByType或者直接读取DB::GetProperty里的rocksdb.cur-size-all-mem-tables、rocksdb.block-cache-usage、rocksdb.estimate-table-readers-mem等字段。每个字段对应的含义,我会在下一节说清楚。
我当时让同事把这几项全部打出来之后,发现Block Cache占了将近4.5G,MemTable占了几百M,而索引/过滤器加起来也占了1G多。也就是说,真正“被RocksDB掌控但操作系统感知不到”的内存,占了绝大部分。Block Cache容量设置的是4G,那它用完4G是非常正常的,因为它本来就是“有多少额度用多少额度”的机制。这不叫泄漏,这叫容量用满。
2.2 内存类型被误读的陷阱
还要提醒一下:用free -m或者top列出来的内存,包含Page Cache的部分。RocksDB写入数据后,数据先写WAL和SST,但写文件的时候,操作系统也会把这些文件内容映射到Page Cache里。如果你们用的是Direct IO,这块会小很多;如果默认走Buffered IO,那RSS虽然不计入Page Cache,但系统的cache部分会涨。可是容器内看内存,往往会把Page Cache也算进容器使用量(如果没做特殊隔离的话),这就进一步加剧了“内存怎么一直不释放”的错觉。
所以在看监控前,先把统计口径统一好:RSS、Cache、Page Cache、RocksDB内部各类型内存,必须分清楚再说“释放”的问题。否则就像去医院看病,拿着体温计非说血压高,根本对不上号。
3. 拆解RocksDB内存组成:它到底把内存花在哪了
3.1 第一块内存:Block Cache,最典型的“只进不出”
Block Cache是RocksDB读取路径上最重要的缓存层。它负责缓存SST文件中的数据块(Data Block)、索引块(Index Block)和过滤器块(Filter Block)。默认情况下,它使用LRU淘汰策略,容量由block_cache_size这个参数控制。
很多人以为LRU就是“内存不够了就释放给系统”,这个理解需要纠正:LRU指的是在Block Cache内部把最久没用的块从缓存里移除,腾出空间给新块。但从操作系统视角看,这个“移除”只是把内存块的引用计数减掉,内存是否真正归还给系统,取决于同一块内存有没有继续被其他东西复用。如果Block Cache的容量设成4G,而4G一直有热数据在反复读取,那么这4G就会一直占用,RSS自然掉不下来。
我见过不少人把block_cache_size调得非常大,比如默认256M觉得不够,直接改成8G、16G。结果就是RSS直线上升。如果你的服务内存本身就紧张,Block Cache容量反而应该精打细算。比如你的热数据总量只有2G,那就没必要给Block Cache分配8G,给4G都算浪费。
Block Cache的另一个关键点是:它分入了strict_capacity_limit这个参数。默认情况下,RocksDB的Block Cache容量是“软限制”,也就是说即使超过设定值,也可以继续占用内存,不会硬性拒绝。这在内存配额比较严格的环境里会带来隐患——你把某个实例的内存上限设为4G,但Block Cache容量设为8G,RocksDB可能实际占用超过配额。写代码时最好显式开启
strict_capacity_limit = true让Block Cache在容量用满后直接拒绝新的插入,而不是无限膨胀。
3.2 第二块内存:MemTable,写路径的临时堆叠
MemTable是RocksDB写入路径的“内存缓冲层”,所有写操作先写入MemTable,再在内存里完成排序,最后刷盘成SST。默认每个列族(Column Family)会维护至少一个active MemTable,还可能因为写入过快出现多个immutable MemTable。
MemTable本身使用Arena分配器管理,而Arena的特点就是“一次性分配大块内存,内部自己管理小块内存的分配和释放”。这种分配策略效率极高,但带来的副作用就是:即使某一批数据已经刷到SST并从MemTable逻辑上删掉了,Arena的底层内存块也未必会立刻还给操作系统。Arena会把这些内存块缓存在自己的空闲链表里,供下一个MemTable复用。这又是一个典型的“内存被持有”的场景。
所以在内存统计里,rocksdb.cur-size-all-mem-tables代表的只是当前所有MemTable里的数据大小(逻辑占用),RSS实际增幅往往比这个值高不少,因为Arena还有碎片和缓存块。另外,如果write_buffer_size设置的是64MB,但同时对4个列族写入,那内存峰值瞬时占用可能远超你预期。很多典型故障都是从“写入突增导致内存暴涨”开始的。
3.3 第三块内存:索引与过滤器,隐形的内存大户
Table Cache里面存的是SST文件的元数据,包括Index和Bloom Filter。在prefix_extractor开启、filter_policy是Bloom Filter时,每个SST文件的过滤器都可能被加载进内存。这块内存不在Block Cache范围内,而是单独通过table_cache和max_open_files来管理。
如果你的数据文件比较碎(比如一个列族下面几千个SST),加载进来的索引和过滤器占用可能比Block Cache还大。我们项目里就遇到过,设置max_open_files = -1(表示把所有SST的索引尽量都打开)后,内存直接涨了1G多。后来把这个参数调小,让RocksDB只维护最常用SST的索引,内存立刻降下来了。
这个现象背后是“内存换性能”的经典权衡:索引常驻,随机读会更快;索引释放掉,每次读取需要重新从磁盘读索引块,随机读性能会下降。所以不要盲目把所有优化参数都拉满,要根据磁盘IOPS、内存配额和业务读模型的综合情况来做取舍。
4. 真凶落点:为什么内存就是不归还操作系统
4.1 分配器行为:jemalloc/mimalloc的“缓存”机制
RocksDB默认可以用jemalloc做底层内存分配器,而jemalloc本身为了性能,会在释放内存时保留一部分空闲页,不立刻归还给操作系统。它这样做是因为频繁的madvise/munmap会导致性能下降。jemalloc的dirty pages机制非常典型:内存释放后先进入dirty page队列,系统可以通过
malloc_stats_print或者je_malloc_stats_print查看。如果你发现RocksDB实例释放了很多内存,但RSS依然很高,可以先用jemalloc的统计接口确认有多少是dirty page,再估算实际还能回收的空间。
这可以解释一个现象:你调用RocksDB的某个接口删了一大堆数据,或者把某个列族drop了,理论上内存应该大幅下降,但RSS纹丝不动。其实不是泄漏,而是内存被jemalloc缓存着。想要验证也很简单,跟进MALLOC_CONF=dirty_decay_ms:1000,background_thread:true这种配置,观察RSS是否缓慢下降。jemalloc的回收是异步且有延时的,默认decay时间可能是10秒甚至更久,对于某些环境看似“永远不释放”。
4.2 文件系统缓存与SST文件释放的错位
前面提到,RocksDB写SST文件时,如果走的是Buffered IO,那Page Cache里也会堆积大量SST文件内容。当SST文件被Compaction删除或替换时,理论上对应文件的Page Cache可以被释放,但Linux并不会主动回收这部分Cache,除非系统内存压力比较大,触发了内存回收机制。
这就造成一个很割裂的现象:RocksDB内部的元数据统计显示内存使用在下降,但容器内RSS/系统可用内存却没好起来。因为你看到的“内存占用”很大一部分是文件Cache,而它归属操作系统内核管理,不属于RocksDB进程。这个问题在高版本内核里,如果用了cgroup v2的memory.high限制时,可能表现得更明显。
4.3 内存碎片化:MemTable大块分配带来的细碎残留
RocksDB在Compaction时会创建新的SST文件,同时把旧文件删掉。这个过程中会涉及大量的短生命周期内存分配,尤其是4KB、8KB这种小块。如果底层分配器在应对“大块分配/小块释放”的混合序列时不够高效,就很容易产生内存碎片。
碎片化带来的直接表现就是:你明明释放了一堆对象,但可用内存还是不够,甚至RSS在上涨。因为新的分配请求无法复用那些零散的空闲块,只能继续向操作系统申请新的内存。这个问题在长周期运行的高负载实例上尤其突出,最后往往只能靠定期重启进程来“物理回收”内存。
我当时排查某服务时,RSS从启动时的2G一路涨到12G,但通过jemalloc stats看到实际“已使用”的内存只有6G,剩下的全是碎片和缓存。这种时候光调RocksDB参数不一定管用,还需要针对分配器做优化,比如换用mimalloc、开启background_thread=true,或者调整narenas的数量。
5. 诊断与排查:如何从“怀疑泄漏”走到“定位根因”
5.1 用官方API量化各类型内存占用
第一步,在业务代码里直接取RocksDB的内存统计数据。可以像下面这样写一个小函数:
std::string GetRocksdbMemoryInfo(rocksdb::DB* db) { std::string ret; uint64_t mem_table_usage = 0; uint64_t block_cache_usage = 0; uint64_t table_readers_usage = 0; uint64_t all_mem_tables_usage = 0; uint64_t total_usage = 0; getAllocatedMemory(db, &mem_table_usage, &block_cache_usage, &table_readers_usage, &all_mem_tables_usage, &total_usage); ret += "mem_table_usage: " + std::to_string(mem_table_usage) + "\n"; ret += "block_cache_usage: " + std::to_string(block_cache_usage) + "\n"; ret += "table_readers_usage: " + std::to_string(table_readers_usage) + "\n"; ret += "all_mem_tables_usage: " + std::to_string(all_mem_tables_usage) + "\n"; ret += "total_usage: " + std::to_string(total_usage) + "\n"; return ret; }实际操作里,RocksDB官方提供GetProperty接口,其中与内存相关的关键属性有以下几个:
| 属性键 | 含义 |
|---|---|
rocksdb.cur-size-all-mem-tables | 当前所有MemTable的数据总大小,包含active和immutable |
rocksdb.size-all-mem-tables | MemTable全部大小(近似,同样是逻辑视图) |
rocksdb.block-cache-usage | Block Cache当前已用大小 |
rocksdb.estimate-table-readers-mem | 索引/过滤器等SST元数据的内存估算 |
rocksdb.estimate-live-data-size | 主存储数据的估算大小(用于容量规划) |
rocksdb.block-cache-pinned-usage | 被固定住的Block Cache内存,某种情况下很关键 |
把这些值打出来之后,再对照RSS,就很容易定位是哪一块在“托底”。我们当时就是靠这个把“内存泄漏”证伪了:总用量基本等于各分项之和,且总和稳定在6G左右,并没有无限上涨。
5.2 用jemalloc的统计定位分配器层面问题
如果RocksDB自己的统计没问题,可RSS还是异常,就把视角切到底层分配器。用jemalloc时,在启动参数里加上:
export MALLOC_CONF="stats_print:true,dirty_decay_ms:1000"运行一段时间后,通过信号SIGUSR2可以触发jemalloc把内存统计打印到stderr。里面有几个关键指标:
allocated:实际分配到对象上的内存总量active:jemalloc当前持有并使用的内存页总量mapped:向操作系统申请的总量dirty pages:空闲但尚未归还给操作系统
如果active远大于allocated,说明碎片和空闲页很多;如果mapped远大于active,说明jemalloc持有的大量内存在这个层面属于“缓存”。这种场景通常需要调整dirty_decay_ms和muzzy_decay_ms的时延来加速归还,或者采用background_thread=true让后台线程定期清理。
有时候RSS比你预想的高,是因为RocksDB不仅有默认的内存分配,还有压缩(Compression)和解压过程中的临时内存。比如Snappy压缩时可能一次性分配几MB的缓冲区,Zstd更高。多个线程同时做Compaction时,瞬态峰值容易被推高。
5.3 追踪mmap与文件映射
如果怀疑SST元数据常驻内存,还可以用
pmap -x <pid>看看是否存在大量fd映射。不过pmap在容器里可能权限受限,也可以用/proc/<pid>/smaps去统计各映射区域的大小。如果发现很多大小等于SST文件大小的映射段,基本可以确认是Table Cache或文件的mmap残留。
在Linux上,RocksDB可以使用mmap的读取方式,即
options.use_mmap_reads = true;这种情况会把SST文件内容直接映射进进程地址空间,RSS会根据访问热度逐步上涨,而且这些页面很难被主动释放。如果对随机读性能要求不是极高,建议关闭mmap_reads,改用普通的预读模式,对内存控制会好很多。
5.4 复现实验:数据删了,内存却不降
一个常用的实验手段是:往RocksDB大量写入测试数据,等RSS稳定后,再按Key范围批量Delete,然后继续观察RSS变化。如果数据删除后RSS没有任何变化,有两种可能:
- 一是你删的数据还留在SST里,未经历Compaction和实际空间回收;
- 二是分配器缓存住了这些内存页。
前者需要进一步确认Compaction是否被触发。在没有稳定触发Compaction时,数据删除只是逻辑删除,底层空间不会立刻缩回来,这也会被误读为“内存泄漏”。实际排查里,我遇到过有业务方持续写入、删除,但Compaction配置不合理,导致旧SST一直占着内存和磁盘空间。这种情况下首先要调的是level0_file_num_compaction_trigger和max_bytes_for_level_base,而不是纠结内存分配器。
6. 调优实践:在可控范围内让内存“释放”得更快
6.1 降低Block Cache容量,设置合理的硬上限
如果业务场景中读多写少,但内存又不是无限大,我会把Block Cache调到1G~2G,并开启strict_capacity_limit。这样做可能会牺牲一部分读命中率,但对内存水位控制有明显的改善。你还可以通过
options.block_cache = rocksdb::NewLRUCache(1024 * 1024 * 1024ULL, /*num_shard_bits=*/ -1, /*strict_capacity_limit=*/ true);来显式限定容量。当容量接近上限时,RocksDB会开始更激进地淘汰冷块。
6.2 控制MemTable数量与刷盘节奏
MemTable内存可以通过write_buffer_size控制单表大小,通过max_write_buffer_number控制允许保留的MemTable数量。如果业务写入有周期性峰值,我会把write_buffer_size设在64MB~128MB,把max_write_buffer_number设为4以下。这样能避免峰值写入时瞬时创建大量MemTable。
同时,min_write_buffer_number_to_merge的设置也会影响刷盘策略。合并多个MemTable再刷盘可以减少SST数量,但会增加内存保留时间。如果对内存水位极度敏感,可以把它调低,让MemTable更快刷出。
还有一个容易被忽视的参数是arena_block_size,它控制MemTable内部Arena分配块的大小。默认一般是4KB,但在高吞吐写入场景下,较小的块会增加分配次数和碎片。可以适当提高到64KB或128KB,减少分配器层面的碎片。
6.3 清理Table Cache,限制打开的SST数量
如果索引和过滤器常驻内存太多,可以考虑:
- 把
max_open_files设定为一个合理的数值,比如1000或2000,不要用-1; - 开启
skip_stats_update_on_db_open; - 在不需要范围查询的前提下,关闭
prefix_extractor,减少前缀匹配对Bloom Filter的依赖。
同时可以调用
db->ResetStats();这个接口会清除部分内部缓存统计信息。另外,某些版本提供了CancelAllBackgroundWork的能力,在执行大范围数据迁移或备份时可以先暂停Compaction,避免临时内存峰值。
6.4 用madvise和周期性任务主动回收
Linux支持madvise(MADV_DONTNEED),但RocksDB内部不一定积极调用。jemalloc也提供了mallctl控制节点,比如arena.<i>.purge可以手动触发清理。在热点版本中,我们可以通过定时任务调用:
// 以jemalloc为例 void PurgeArena() { const char* cmd = "arena.65536.purge"; // 或 "background_thread:true" }不过这种方法不保证立刻让RSS下降,因为操作系统对内存的回收有自己的调度逻辑。真正的“释放给系统”,还需要内存页进入free列表后由回收机制触发。所以我的建议是:不要追求“RSS立刻下降”,而是要把内存水位控制在一个可控的、有规律波动范围内。
6.5 常用参数组合的配置示例
下面是一个经过生产环境验证的相对保守的配置,适合8G内存配额、RocksDB数据量在500G左右的随机读写场景:
rocksdb::Options options; options.write_buffer_size = 128 * 1024 * 1024; // 128MB options.max_write_buffer_number = 4; options.min_write_buffer_number_to_merge = 2; options.max_open_files = 2000; options.block_cache = rocksdb::NewLRUCache(2 * 1024 * 1024 * 1024ULL, -1, true); options.table_cache_numshardbits = 4; options.arena_block_size = 64 * 1024; // 64KB options.compaction_style = rocksdb::kCompactionStyleLevel; options.level0_file_num_compaction_trigger = 8; options.level0_slowdown_writes_trigger = 12; options.level0_stop_writes_trigger = 20; options.max_background_compactions = 4; options.max_background_flushes = 2; options.bytes_per_sync = 1048576; // 1MB options.writable_file_max_buffer_size = 1024 * 1024; options.use_mmap_reads = false; options.use_mmap_writes = false; options.avoid_unnecessary_blocking_io = true;注意这里max_background_compactions和max_background_flushes都不能太小,否则写入阻塞会导致MemTable堆积,进而推高内存。同时,太激进地限制Compaction线程数,也会导致L0层SST数量膨胀,到时候索引/过滤器的内存占用反而上升。
7. 常见误区和排查速查表
7.1 误区一:RSS高就等于内存泄漏
RocksDB的设计目标之一就是充分利用可用内存提升缓存命中率。给它一个明确的内存上限,并监控它是否突破该上限,比单纯看RSS曲线更有实际意义。如果RSS稳定在容量上限附近,且没有无限增长,一般就不是泄漏。
7.2 误区二:把LRU淘汰当成“释放内存”
Block Cache的LRU淘汰只是把某个块引用移除。如果该块还处于“被固定”(pinned)状态,内存根本不会释放。典型情况是迭代器还没关闭或者索引块被引用着。排查时可以通过rocksdb.block-cache-pinned-usage字段确认固定内存的大小。
7.3 误区三:直接重启进程“物理回收”
重启确实能让RSS归零,但代价是缓存冷启动,读写性能直接大幅回退。而且如果根源是参数配置不合理,重启后过几天内存又会涨上来,属于治标不治本。
7.4 误区四:调低write_buffer_size就能降内存
有时候恰恰相反。write_buffer_size调太低会导致SST文件数量暴增,L0层频繁触发Compaction,反而增加临时内存和元数据开销。要根据写入吞吐、Compaction频率和P99延迟一起评估。
我整理了一个简单的排查速查表,方便你按图索骥:
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| RSS稳定高位,不随业务波动 | Block Cache容量设置过大、热数据全部命中 | 查看block-cache-usage与设定容量对比 |
| 数据删除后RSS不降 | Compaction未触发、分配器缓存、页缓存残留 | 查看estimate-live-data-size和cur-size-all-mem-tables |
| 内存缓慢持续上涨 | 碎片化膨胀、MemTable堆积、SST索引泄漏 | 用jemalloc stats看active/allocated是否持续增大 |
| 写峰值内存暴涨 | MemTable瞬时堆积、写入缓冲过多 | 看size-all-mem-tables是否接近max_write_buffer_number * write_buffer_size |
| 随机读命中率低但内存高 | 索引/过滤器常驻,数据块缓存命中有限 | 看estimate-table-readers-mem占比 |
| Block Cache超出设定容量 | strict_capacity_limit未开启 | 确认配置并开启 |
| RSS下降滞后于空闲时间 | 分配器dirty page回收慢 | 调dirty_decay_ms或启用background_thread |
按这张表排查,大多数“内存不释放”的问题都能找到一个相对明确的解释,而不是一头扎进“内存泄漏”的猜测里。
8. 最后分享一个真实案例
我们线上有一个偏重读的RocksDB实例,8G配额,但是部署后发现RSS一度逼近7.6G,触发过好几次内存告警。我刚开始也怀疑是哪里有了泄漏,毕竟写入量并不算大。后来把所有分项内存打印出来,发现Block Cache占了4G,而且max_open_files设置成了-1,索引和过滤器吃了将近2G,剩下的是memtable和其他开销。
我用jemalloc stats看到active大概6.8G,allocated只有5.1G,这中间差了1.7G,也就是大量缓存页和碎片。因为业务对随机读的延迟要求比较严格,我没有走“激进裁剪Block Cache”的路线,而是做了两个调整:
- 把
max_open_files改为2000,限制索引和过滤器常驻条数; - 开启jemalloc的
background_thread=true,同时把dirty_decay_ms设为2000,让空闲内存更快归还。
两周观察下来,RSS稳定在6.2G左右,读延迟没有明显劣化,告警也消失了。这件事给我的经验就是:内存“不释放”往往不是单一因素,而是一个组合现象——Block Cache容量、Table Cache常驻条数、分配器回收策略、Compaction节奏,全部叠加在一起。排查时要一项项拆开看,别指望有一个万能开关能一键搞定。
如果你也遇到类似问题,建议先把自己的内存统计脚本写起来,把block-cache-usage、estimate-table-readers-mem、cur-size-all-mem-tables这三个核心指标接进监控。看到异常的时候先别慌,逐项确认,再决定是调Block Cache、调MemTable策略,还是直接找分配器做优化。这套流程走通之后,你会发现所谓“内存不释放”,多半只是一个需要耐心解释的“内存管理方式”而已。