Redis内存突增,如何精准量化内存使用状况?

摘要:背景 最近碰到一个 case,一个 Redis 实例的内存突增,used_memory最大时达到了 78.9G,而该实例的maxmemory配置却只有 16G,最终导致实例中的数据被大量驱逐。 以下是问题发生时INFO MEMORY的部分输
背景 最近碰到一个 case,一个 Redis 实例的内存突增,used_memory最大时达到了 78.9G,而该实例的maxmemory配置却只有 16G,最终导致实例中的数据被大量驱逐。 以下是问题发生时INFO MEMORY的部分输出内容。 #Memory used_memory:84716542624 used_memory_human:78.90G used_memory_rss:104497676288 used_memory_rss_human:97.32G used_memory_peak:84716542624 used_memory_peak_human:78.90G used_memory_peak_perc:100.00% used_memory_overhead:75682545624 used_memory_startup:906952 used_memory_dataset:9033997000 used_memory_dataset_perc:10.66% allocator_allocated:84715102264 allocator_active:101370822656 allocator_resident:102303637504 total_system_memory:810745470976 total_system_memory_human:755.07G used_memory_lua:142336 used_memory_lua_human:139.00K used_memory_scripts:6576 used_memory_scripts_human:6.42K number_of_cached_scripts:13 maxmemory:17179869184 maxmemory_human:16.00G maxmemory_policy:volatile-lru allocator_frag_ratio:1.20 allocator_frag_bytes:16655720392 内存突增导致数据被驱逐,是 Redis 中一个较为常见的问题。很多童鞋在面对这类问题时往往缺乏清晰的分析思路,常常误以为是复制、RDB 持久化等操作引起的。接下来,我们看看如何系统地分析这类问题。 本文主要包括以下几部分: INFO 中的used_memory是怎么来的? 什么是used_memory? used_memory内存通常会被用于哪些场景? Redis 7 在内存统计方面的变化。 数据驱逐的触发条件——当used_memory超过maxmemory后,是否一定会触发驱逐? 最后,分享一个脚本,帮助实时分析used_memory增长时,具体是哪一部分的内存消耗导致的。 INFO 中的 used_memory 是怎么来的? 当我们执行INFO命令时,Redis 会调用genRedisInfoString函数来生成其输出。 //server.c sdsgenRedisInfoString(constchar*section){ ... /*Memory*/ if(allsections||defsections||!strcasecmp(section,"memory")){ ... size_tzmalloc_used=zmalloc_used_memory(); ... if(sections++)info=sdscat(info,"\r\n"); info=sdscatprintf(info, "#Memory\r\n" "used_memory:%zu\r\n" "used_memory_human:%s\r\n" "used_memory_rss:%zu\r\n" ... "lazyfreed_objects:%zu\r\n", zmalloc_used, hmem, server.cron_malloc_stats.process_rss, ... lazyfreeGetFreedObjectsCount() ); freeMemoryOverheadData(mh); } ... returninfo; } 可以看到,used_memory 的值来自 zmalloc_used,而 zmalloc_used 又是通过zmalloc_used_memory()函数获取的。 //zmalloc.c size_tzmalloc_used_memory(void){ size_tum; atomicGet(used_memory,um); returnum; } zmalloc_used_memory() 的实现很简单,就是以原子方式读取 used_memory 的值。 什么是 used_memory used_memory是一个静态变量,其类型为redisAtomic size_t,其中redisAtomic是_Atomic类型的别名。_Atomic是 C11 标准引入的关键字,用于声明原子类型,保证在多线程环境中对该类型的操作是原子的,避免数据竞争。 #defineredisAtomic_Atomic staticredisAtomicsize_tused_memory=0; used_memory的更新主要通过两个宏定义实现: #defineupdate_zmalloc_stat_alloc(__n)atomicIncr(used_memory,(__n)) #defineupdate_zmalloc_stat_free(__n)atomicDecr(used_memory,(__n)) 其中,update_zmalloc_stat_alloc(__n)是在分配内存时调用,它通过原子操作让 used_memory 加__n。 而update_zmalloc_stat_free(__n)则是在释放内存时调用,它通过原子操作让 used_memory 减__n。 这两个宏确保了在内存分配和释放过程中used_memory的准确更新,并且避免了并发操作带来的数据竞争问题。 在通过内存分配器(常用的内存分配器有 glibc's malloc、jemalloc、tcmalloc,Redis 中一般使用 jemalloc)中的函数分配或释放内存时,会同步调用update_zmalloc_stat_alloc或update_zmalloc_stat_free来更新 used_memory 的值。 在 Redis 中,内存管理主要通过以下两个函数来实现: //zmalloc.c void*ztrymalloc_usable(size_tsize,size_t*usable){ ASSERT_NO_SIZE_OVERFLOW(size); void*ptr=malloc(MALLOC_MIN_SIZE(size)+PREFIX_SIZE); if(!ptr)returnNULL; #ifdefHAVE_MALLOC_SIZE size=zmalloc_size(ptr); update_zmalloc_stat_alloc(size); if(usable)*usable=size; returnptr; #else ... #endif } voidzfree(void*ptr){ ... if(ptr==NULL)return; #ifdefHAVE_MALLOC_SIZE update_zmalloc_stat_free(zmalloc_size(ptr)); free(ptr); #else ... #endif } 其中, ztrymalloc_usable函数用于分配内存。该函数首先会调用malloc分配内存。如果分配成功,则会通过update_zmalloc_stat_alloc更新 used_memory 的值。 zfree函数用于释放内存。在释放内存之前,先通过update_zmalloc_stat_free调整 used_memory 的值,然后再调用free释放内存。 这种机制保证了 Redis 能够准确跟踪内存的分配和释放情况,从而有效地管理内存使用。 used_memory 内存通常会被用于哪些场景? used_memory主要由两部分组成: 数据本身:对应 INFO 中的used_memory_dataset。 内部管理和维护数据结构的开销:对应 INFO 中的used_memory_overhead。 需要注意的是,used_memory_dataset 并不是根据 Key 的数量及 Key 使用的内存计算出来的,而是通过 used_memory 减去 used_memory_overhead 得到的。 接下来,我们重点分析下used_memory_overhead的来源。实际上,Redis 提供了一个单独的函数-getMemoryOverheadData,专门用于计算这一部分的内存开销。 //object.c structredisMemOverhead*getMemoryOverheadData(void){ intj; // mem_total 用于累积总的内存开销,最后会赋值给 used_memory_overhead。 size_tmem_total=0; // mem 用于计算每一部分的内存使用量。 size_tmem=0; //调用 zmalloc_used_memory()获取 used_memory。 size_tzmalloc_used=zmalloc_used_memory(); //使用 zcalloc 分配一个 redisMemOverhead 结构体的内存。 structredisMemOverhead*mh=zcalloc(sizeof(*mh)); ... //将 Redis 启动时的内存使用量 server.initial_memory_usage 加入到总内存开销中。 mem_total+=server.initial_memory_usage; mem=0; //将复制积压缓冲区的内存开销加入到总内存开销中。 if(server.repl_backlog) mem+=zmalloc_size(server.repl_backlog); mh->repl_backlog=mem; mem_total+=mem; /*ComputingthememoryusedbytheclientswouldbeO(N)ifdone *hereonline.Weuseourvaluescomputedincrementallyby *clientsCronTrackClientsMemUsage().*/ //计算客户端内存开销 mh->clients_slaves=server.stat_clients_type_memory[CLIENT_TYPE_SLAVE]; mh->clients_normal=server.stat_clients_type_memory[CLIENT_TYPE_MASTER]+ server.stat_clients_type_memory[CLIENT_TYPE_PUBSUB]+ server.stat_clients_type_memory[CLIENT_TYPE_NORMAL]; mem_total+=mh->clients_slaves; mem_total+=mh->clients_normal; //计算AOF缓冲区和AOFRewriteBuffer的内存开销 mem=0; if(server.aof_state!=AOF_OFF){ mem+=sdsZmallocSize(server.aof_buf); mem+=aofRewriteBufferSize(); } mh->aof_buffer=mem; mem_total+=mem; //计算Lua脚本缓存的内存开销 mem=server.lua_scripts_mem; mem+=dictSize(server.lua_scripts)*sizeof(dictEntry)+ dictSlots(server.lua_scripts)*sizeof(dictEntry*); mem+=dictSize(server.repl_scriptcache_dict)*sizeof(dictEntry)+ dictSlots(server.repl_scriptcache_dict)*sizeof(dictEntry*); if(listLength(server.repl_scriptcache_fifo)>0){ mem+=listLength(server.repl_scriptcache_fifo)*(sizeof(listNode)+ sdsZmallocSize(listNodeValue(listFirst(server.repl_scriptcache_fifo)))); } mh->lua_caches=mem; mem_total+=mem; //计算数据库的内存开销:遍历所有数据库(server.dbnum)。对于每个数据库,计算主字典(db->dict)和过期字典(db->expires)的内存开销。 for(j=0;j<server.dbnum;j++){ redisDb*db=server.db+j; longlongkeyscount=dictSize(db->dict); if(keyscount==0)continue; mh->total_keys+=keyscount; mh->db=zrealloc(mh->db,sizeof(mh->db[0])*(mh->num_dbs+1)); mh->db[mh->num_dbs].dbid=j; mem=dictSize(db->dict)*sizeof(dictEntry)+ dictSlots(db->dict)*sizeof(dictEntry*)+ dictSize(db->dict)*sizeof(robj); mh->db[mh->num_dbs].overhead_ht_main=mem; mem_total+=mem; mem=dictSize(db->expires)*sizeof(dictEntry)+ dictSlots(db->expires)*sizeof(dictEntry*); mh->db[mh->num_dbs].overhead_ht_expires=mem; mem_total+=mem; mh->num_dbs++; } //将计算的 mem_total 赋值给 mh->overhead_total。 mh->overhead_total=mem_total; //计算数据的内存开销(zmalloc_used - mem_total)并存储在 mh->dataset。 mh->dataset=zmalloc_used-mem_total; mh->peak_perc=(float)zmalloc_used*100/mh->peak_allocated; /*Metricscomputedaftersubtractingthestartupmemoryfrom *thetotalmemory.*/ size_tnet_usage=1; if(zmalloc_used>mh->startup_allocated) net_usage=zmalloc_used-mh->startup_allocated; mh->dataset_perc=(float)mh->dataset*100/net_usage; mh->bytes_per_key=mh->total_keys?(net_usage/mh->total_keys):0; returnmh; } 基于上面代码的分析,可以知道 used_memory_overhead 由以下几部分组成: server.initial_memory_usage:Redis 启动时的内存使用量,对应 INFO 中used_memory_startup。 mh->repl_backlog:复制积压缓冲区的内存开销,对应 INFO 中的mem_replication_backlog。 mh->clients_slaves:从库的内存开销。对应 INFO 中的mem_clients_slaves。 mh->clients_normal:其它客户端的内存开销,对应 INFO 中的mem_clients_normal。 mh->aof_buffer:AOF 缓冲区和 AOF 重写缓冲区的内存开销,对应 INFO 中的mem_aof_buffer。AOF 缓冲区是数据写入 AOF 之前使用的缓冲区。AOF 重写缓冲区是 AOF 重写期间,用于存放新增数据的缓冲区。 mh->lua_caches:Lua 脚本缓存的内存开销,对应 INFO 中的used_memory_scripts。Redis 5.0 新增的。 字典的内存开销,这部分内存在 INFO 中没有显示,需要通过MEMORY STATS查看。 17)"db.0" 18)1)"overhead.hashtable.main" 2)(integer)2536870912 3)"overhead.hashtable.expires" 4)(integer)0 在这些内存开销中,used_memory_startup 基本不变,mem_replication_backlog 受 repl-backlog-size 的限制,used_memory_scripts 开销一般不大,而字典的内存开销则与数据量的大小成正比。 所以,重点需要注意的主要有三项:mem_clients_slaves,mem_clients_normal和mem_aof_buffer。 mem_aof_buffer:重点关注 AOF 重写期间缓冲区的大小。 mem_clients_slaves 和 mem_clients_normal:都是客户端,内存分配方式相同。客户端的内存开销主要包括以下三部分: 输入缓冲区:用于暂存客户端命令,大小由client-query-buffer-limit限制。 输出缓冲区:用于缓存发送给客户端的数据,大小受client-output-buffer-limit控制。如果数据超过软硬限制并持续一段时间,客户端会被关闭。 客户端对象本身占用的内存。 Redis 7 在内存统计方面的变化 在 Redis 7 中,还会统计以下项的内存开销: mh->cluster_links:集群链接的内存开销,对应 INFO 中的mem_cluster_links。 mh->functions_caches:Function 缓存的内存开销,对应 INFO 中的used_memory_functions。 集群模式下键到槽映射的内存开销,对应 MEMORY STATS 中的overhead.hashtable.slot-to-keys。 此外,Redis 7 引入了 Multi-Part AOF,这个特性移除了 AOF 重写缓冲区。 需要注意的是,mh->repl_backlog 和 mh->clients_slaves 的内存计算方式也发生了变化。 在 Redis 7 之前,mh->repl_backlog 统计的是复制积压缓冲区的大小,mh->clients_slaves 统计的是所有从节点客户端的内存使用量。 if(server.repl_backlog) mem+=zmalloc_size(server.repl_backlog); mh->repl_backlog=mem; mem_total+=mem; mem=0; //遍历所有从节点客户端,累加它们的输出缓冲区、输入缓冲区的内存使用量以及客户端对象本身的内存占用。 if(listLength(server.slaves)){ listIterli; listNode*ln; listRewind(server.slaves,&li); while((ln=listNext(&li))){ client*c=listNodeValue(ln); mem+=getClientOutputBufferMemoryUsage(c); mem+=sdsAllocSize(c->querybuf); mem+=sizeof(client); } } mh->clients_slaves=mem; 因为每个从节点都会分配一个独立的复制缓冲区(即从节点对应客户端的输出缓冲区),所以当从节点的数量增加时,这种实现方式会造成内存的浪费。不仅如此,当client-output-buffer-limit设置过大且从节点数量过多时,还容易导致主节点 OOM。 针对这个问题,Redis 7 引入了一个全局复制缓冲区。无论是复制积压缓冲区(repl-backlog),还是从节点的复制缓冲区都是共享这个缓冲区。 replBufBlock结构体用于存储全局复制缓冲区的一个块。 typedefstructreplBufBlock{ intrefcount;/*Numberofreplicasorreplbacklogusing.*/ longlongid;/*Theuniqueincrementalnumber.*/ longlongrepl_offset;/*Startreplicationoffsetoftheblock.*/ size_tsize,used; charbuf[]; }replBufBlock; 每个replBufBlock包含一个refcount字段,用于记录该块被多少个复制实例(包括主节点的复制积压缓冲区和从节点)所引用。 当新的从节点添加时,Redis 不会为其分配新的复制缓冲区块,而是增加现有replBufBlock的refcount。 相应地,在 Redis 7 中,mh->repl_backlog 和 mh->clients_slaves 的内存计算方式也发生了变化。 if(listLength(server.slaves)&& (longlong)server.repl_buffer_mem>server.repl_backlog_size) { mh->clients_slaves=server.repl_buffer_mem-server.repl_backlog_size; mh->repl_backlog=server.repl_backlog_size; }else{ mh->clients_slaves=0; mh->repl_backlog=server.repl_buffer_mem; } if(server.repl_backlog){ /*Theapproximatememoryofraxtreeforindexedblocks.*/ mh->repl_backlog+= server.repl_backlog->blocks_index->numnodes*sizeof(raxNode)+ raxSize(server.repl_backlog->blocks_index)*sizeof(void*); } mem_total+=mh->repl_backlog; mem_total+=mh->clients_slaves; 具体而言,如果全局复制缓冲区的大小大于repl-backlog-size,则复制积压缓冲区(mh->repl_backlog)的大小取repl-backlog-size,剩余部分视为从库使用的内存(mh->clients_slaves)。如果全局复制缓冲区的大小小于等于repl-backlog-size,则直接取全局复制缓冲区的大小。 此外,由于引入了一个 Rax 树来索引全局复制缓冲区中的部分节点,复制积压缓冲区还需要计算 Rax 树的内存开销。 数据驱逐的触发条件 很多人有个误区,认为只要 used_memory 大于 maxmemory ,就会触发数据的驱逐。但实际上不是。 数据被驱逐需满足以下条件: maxmemory 必须大于 0。 maxmemory-policy 不能是 noeviction。 内存使用需满足一定的条件。不是 used_memory 大于 maxmemory,而是 used_memory 减去 mem_not_counted_for_evict 后的值大于 maxmemory。 其中,mem_not_counted_for_evict的值可以通过 INFO 命令获取,它的大小是在freeMemoryGetNotCountedMemory函数中计算的。 size_tfreeMemoryGetNotCountedMemory(void){ size_toverhead=0; intslaves=listLength(server.slaves); if(slaves){ listIterli; listNode*ln; listRewind(server.slaves,&li); while((ln=listNext(&li))){ client*slave=listNodeValue(ln); overhead+=getClientOutputBufferMemoryUsage(slave); } } if(server.aof_state!=AOF_OFF){ overhead+=sdsalloc(server.aof_buf)+aofRewriteBufferSize(); } returnoverhead; } freeMemoryGetNotCountedMemory函数统计了所有从节点的复制缓存区、AOF 缓存区和 AOF 重写缓冲区的总大小。 所以,在 Redis 判断是否需要驱逐数据时,会从used_memory中剔除从节点复制缓存区、AOF 缓存区以及 AOF 重写缓冲区的内存占用。 Redis 内存分析脚本 最后,分享一个脚本。 这个脚本能够帮助我们快速分析 Redis 的内存使用情况。通过输出结果,我们可以直观地查看 Redis 各个部分的内存消耗情况并识别当used_memory增加时,具体是哪一部分的内存消耗导致的。 脚本地址:https://github.com/slowtech/dba-toolkit/blob/master/redis/redis_mem_usage_analyzer.py #python3redis_mem_usage_analyzer.py-host10.0.1.182-p6379 Metric(2024-09-1204:52:42)OldValueNewValue(+3s)Changepersecond ========================================================================================== Summary --------------------------------------------- used_memory16.43G16.44G1.1M used_memory_dataset11.93G11.93G22.66K used_memory_overhead4.51G4.51G1.08M Overhead(Total)4.51G4.51G1.08M --------------------------------------------- mem_clients_normal440.57K440.52K-18.67B mem_clients_slaves458.41M461.63M1.08M mem_replication_backlog160M160M0B mem_aof_buffer0B0B0B used_memory_startup793.17K793.17K0B used_memory_scripts0B0B0B mem_hashtable3.9G3.9G0B Evict&Fragmentation --------------------------------------------- maxmemory20G20G0B mem_not_counted_for_evict458.45M461.73M1.1M mem_counted_for_evict15.99G15.99G2.62K maxmemory_policyvolatile-lruvolatile-lru used_memory_peak16.43G16.44G1.1M used_memory_rss16.77G16.77G1.32M mem_fragmentation_bytes345.07M345.75M232.88K Others --------------------------------------------- keys77860000778600000.0 instantaneous_ops_per_sec83398435 lazyfree_pending_objects000.0 该脚本每隔一段时间(由-i参数决定,默认是 3 秒)采集一次 Redis 的内存数据。然后,它会将当前采集到的数据(New Value)与上一次的数据(Old Value)进行对比,计算出每秒的增量(Change per second)。 输出主要分为四大部分: Summary:汇总部分,used_memory = used_memory_dataset + used_memory_overhead。 Overhead(Total):展示 used_memory_overhead 中各个细项的内存消耗情况。Overhead(Total) 等于所有细项之和,理论上应与 used_memory_overhead 相等。 Evict & Fragmentation:显示驱逐和内存碎片的一些关键指标。其中,mem_counted_for_evict = used_memory - mem_not_counted_for_evict,当mem_counted_for_evict超过maxmemory时,才会触发数据驱逐。 Others:其他一些重要指标,包括 keys(键的总数量)、instantaneous_ops_per_sec(每秒操作数)以及 lazyfree_pending_objects(通过异步删除等待释放的对象数)。 如果发现mem_clients_normal或mem_clients_slaves比较大,可指定 --client 查看客户端的内存使用情况。 #python3redis_mem_usage_analyzer.py-host10.0.1.182-p6379--client IDAddressNameAgeCommandUserQbufOmemTotalMemory ---------------------------------------------------------------------------------------------------- 21610.0.1.75:37811721psyncdefault0B232.83M232.85M 21710.0.1.22:35057715psyncdefault0B232.11M232.13M 45310.0.0.198:511720clientdefault26B0B60.03K ... 其中, Qbuf:输入缓冲区的大小。 Omem:输出缓冲区的大小。 Total Memory:该连接占用的总内存。 结果按 Total Memory 从大到小的顺序输出。