RocksDB内存不释放?从Block Cache到jemalloc的真相与调优

发布时间:2026/10/14 23:28:45
RocksDB内存不释放?从Block Cache到jemalloc的真相与调优
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.5GMemTable占了几百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容量设为8GRocksDB可能实际占用超过配额。写代码时最好显式开启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_CONFdirty_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_threadtrue或者调整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和immutablerocksdb.size-all-mem-tablesMemTable全部大小近似同样是逻辑视图rocksdb.block-cache-usageBlock 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_CONFstats_print:true,dirty_decay_ms:1000运行一段时间后通过信号SIGUSR2可以触发jemalloc把内存统计打印到stderr。里面有几个关键指标allocated实际分配到对象上的内存总量activejemalloc当前持有并使用的内存页总量mapped向操作系统申请的总量dirty pages空闲但尚未归还给操作系统如果active远大于allocated说明碎片和空闲页很多如果mapped远大于active说明jemalloc持有的大量内存在这个层面属于“缓存”。这种场景通常需要调整dirty_decay_ms和muzzy_decay_ms的时延来加速归还或者采用background_threadtrue让后台线程定期清理。有时候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.8Gallocated只有5.1G这中间差了1.7G也就是大量缓存页和碎片。因为业务对随机读的延迟要求比较严格我没有走“激进裁剪Block Cache”的路线而是做了两个调整把max_open_files改为2000限制索引和过滤器常驻条数开启jemalloc的background_threadtrue同时把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策略还是直接找分配器做优化。这套流程走通之后你会发现所谓“内存不释放”多半只是一个需要耐心解释的“内存管理方式”而已。