Redis 内存突增时,如何定量分析其内存使用情况

[复制链接]
发表于 2024-9-23 12:18:26 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
背景

最近碰到一个 case,一个 Redis 实例的内存突增,used_memory最大时达到了 78.9G,而该实例的maxmemory设置却只有 16G,最终导致实例中的数据被大量驱逐。
以下是问题发生时INFO MEMORY的部门输出内容。
  1. # Memory
  2. used_memory:84716542624
  3. used_memory_human:78.90G
  4. used_memory_rss:104497676288
  5. used_memory_rss_human:97.32G
  6. used_memory_peak:84716542624
  7. used_memory_peak_human:78.90G
  8. used_memory_peak_perc:100.00%
  9. used_memory_overhead:75682545624
  10. used_memory_startup:906952
  11. used_memory_dataset:9033997000
  12. used_memory_dataset_perc:10.66%
  13. allocator_allocated:84715102264
  14. allocator_active:101370822656
  15. allocator_resident:102303637504
  16. total_system_memory:810745470976
  17. total_system_memory_human:755.07G
  18. used_memory_lua:142336
  19. used_memory_lua_human:139.00K
  20. used_memory_scripts:6576
  21. used_memory_scripts_human:6.42K
  22. number_of_cached_scripts:13
  23. maxmemory:17179869184
  24. maxmemory_human:16.00G
  25. maxmemory_policy:volatile-lru
  26. allocator_frag_ratio:1.20
  27. 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函数来生成其输出。
  1. // server.c
  2. sds genRedisInfoString(const char *section) {
  3.     ...
  4.     /* Memory */
  5.     if (allsections || defsections || !strcasecmp(section,"memory")) {
  6.         ...
  7.         size_t zmalloc_used = zmalloc_used_memory();
  8.         ...
  9.         if (sections++) info = sdscat(info,"\r\n");
  10.         info = sdscatprintf(info,
  11.             "# Memory\r\n"
  12.             "used_memory:%zu\r\n"
  13.             "used_memory_human:%s\r\n"
  14.             "used_memory_rss:%zu\r\n"
  15.             ...
  16.             "lazyfreed_objects:%zu\r\n",
  17.             zmalloc_used,
  18.             hmem,
  19.             server.cron_malloc_stats.process_rss,
  20.             ...
  21.             lazyfreeGetFreedObjectsCount()
  22.         );
  23.         freeMemoryOverheadData(mh);
  24.     }
  25.     ...
  26.     return info;
  27. }
复制代码
可以看到,used_memory 的值来自 zmalloc_used,而 zmalloc_used 又是通过zmalloc_used_memory()函数获取的。
  1. // zmalloc.c
  2. size_t zmalloc_used_memory(void) {
  3.     size_t um;
  4.     atomicGet(used_memory,um);
  5.     return um;
  6. }
复制代码
zmalloc_used_memory() 的实现很简单,就是以原子方式读取 used_memory 的值。
什么是 used_memory

used_memory是一个静态变量,其类型为redisAtomic size_t,其中redisAtomic是_Atomic类型的别名。_Atomic是 C11 标准引入的关键字,用于声明原子类型,包管在多线程环境中对该类型的操作是原子的,制止数据竞争。
  1. #define redisAtomic _Atomic
  2. static redisAtomic size_t used_memory = 0;
复制代码
used_memory 的更新主要通过两个宏定义实现:
  1. #define update_zmalloc_stat_alloc(__n) atomicIncr(used_memory,(__n))
  2. #define update_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 中,内存管理主要通过以下两个函数来实现:
  1. // zmalloc.c
  2. void *ztrymalloc_usable(size_t size, size_t *usable) {
  3.     ASSERT_NO_SIZE_OVERFLOW(size);
  4.     void *ptr = malloc(MALLOC_MIN_SIZE(size)+PREFIX_SIZE);

  5.     if (!ptr) return NULL;
  6. #ifdef HAVE_MALLOC_SIZE
  7.     size = zmalloc_size(ptr);
  8.     update_zmalloc_stat_alloc(size);
  9.     if (usable) *usable = size;
  10.     return ptr;
  11. #else
  12.     ...
  13. #endif
  14. }

  15. void zfree(void *ptr) {
  16.     ...
  17.     if (ptr == NULL) return;
  18. #ifdef HAVE_MALLOC_SIZE
  19.     update_zmalloc_stat_free(zmalloc_size(ptr));
  20.     free(ptr);
  21. #else
  22.    ...
  23. #endif
  24. }
复制代码
其中,

  • 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,专门用于盘算这一部门的内存开销。
  1. // object.c
  2. struct redisMemOverhead *getMemoryOverheadData(void) {
  3.     int j;
  4.     // mem_total 用于累积总的内存开销,最后会赋值给 used_memory_overhead。
  5.     size_t mem_total = 0;
  6.     // mem 用于计算每一部分的内存使用量。
  7.     size_t mem = 0;
  8.     // 调用 zmalloc_used_memory() 获取 used_memory。
  9.     size_t zmalloc_used = zmalloc_used_memory();
  10.     // 使用 zcalloc 分配一个 redisMemOverhead 结构体的内存。
  11.     struct redisMemOverhead *mh = zcalloc(sizeof(*mh));
  12.     ...
  13.     // 将 Redis 启动时的内存使用量 server.initial_memory_usage 加入到总内存开销中。
  14.     mem_total += server.initial_memory_usage;

  15.     mem = 0;
  16.     // 将复制积压缓冲区的内存开销加入到总内存开销中。
  17.     if (server.repl_backlog)
  18.         mem += zmalloc_size(server.repl_backlog);
  19.     mh->repl_backlog = mem;
  20.     mem_total += mem;

  21.     /* Computing the memory used by the clients would be O(N) if done
  22.      * here online. We use our values computed incrementally by
  23.      * clientsCronTrackClientsMemUsage(). */
  24.     // 计算客户端内存开销
  25.     mh->clients_slaves = server.stat_clients_type_memory[CLIENT_TYPE_SLAVE];
  26.     mh->clients_normal = server.stat_clients_type_memory[CLIENT_TYPE_MASTER]+
  27.                          server.stat_clients_type_memory[CLIENT_TYPE_PUBSUB]+
  28.                          server.stat_clients_type_memory[CLIENT_TYPE_NORMAL];
  29.     mem_total += mh->clients_slaves;
  30.     mem_total += mh->clients_normal;
  31.     // 计算 AOF 缓冲区和 AOF Rewrite Buffer 的内存开销
  32.     mem = 0;
  33.     if (server.aof_state != AOF_OFF) {
  34.         mem += sdsZmallocSize(server.aof_buf);
  35.         mem += aofRewriteBufferSize();
  36.     }
  37.     mh->aof_buffer = mem;
  38.     mem_total+=mem;
  39.     // 计算 Lua 脚本缓存的内存开销
  40.     mem = server.lua_scripts_mem;
  41.     mem += dictSize(server.lua_scripts) * sizeof(dictEntry) +
  42.         dictSlots(server.lua_scripts) * sizeof(dictEntry*);
  43.     mem += dictSize(server.repl_scriptcache_dict) * sizeof(dictEntry) +
  44.         dictSlots(server.repl_scriptcache_dict) * sizeof(dictEntry*);
  45.     if (listLength(server.repl_scriptcache_fifo) > 0) {
  46.         mem += listLength(server.repl_scriptcache_fifo) * (sizeof(listNode) +
  47.             sdsZmallocSize(listNodeValue(listFirst(server.repl_scriptcache_fifo))));
  48.     }
  49.     mh->lua_caches = mem;
  50.     mem_total+=mem;
  51.     // 计算数据库的内存开销:遍历所有数据库 (server.dbnum)。对于每个数据库,计算主字典 (db->dict) 和过期字典 (db->expires) 的内存开销。
  52.     for (j = 0; j < server.dbnum; j++) {
  53.         redisDb *db = server.db+j;
  54.         long long keyscount = dictSize(db->dict);
  55.         if (keyscount==0) continue;

  56.         mh->total_keys += keyscount;
  57.         mh->db = zrealloc(mh->db,sizeof(mh->db[0])*(mh->num_dbs+1));
  58.         mh->db[mh->num_dbs].dbid = j;

  59.         mem = dictSize(db->dict) * sizeof(dictEntry) +
  60.               dictSlots(db->dict) * sizeof(dictEntry*) +
  61.               dictSize(db->dict) * sizeof(robj);
  62.         mh->db[mh->num_dbs].overhead_ht_main = mem;
  63.         mem_total+=mem;

  64.         mem = dictSize(db->expires) * sizeof(dictEntry) +
  65.               dictSlots(db->expires) * sizeof(dictEntry*);
  66.         mh->db[mh->num_dbs].overhead_ht_expires = mem;
  67.         mem_total+=mem;

  68.         mh->num_dbs++;
  69.     }
  70.     // 将计算的 mem_total 赋值给 mh->overhead_total。
  71.     mh->overhead_total = mem_total;
  72.     // 计算数据的内存开销 (zmalloc_used - mem_total) 并存储在 mh->dataset。
  73.     mh->dataset = zmalloc_used - mem_total;
  74.     mh->peak_perc = (float)zmalloc_used*100/mh->peak_allocated;

  75.     /* Metrics computed after subtracting the startup memory from
  76.      * the total memory. */
  77.     size_t net_usage = 1;
  78.     if (zmalloc_used > mh->startup_allocated)
  79.         net_usage = zmalloc_used - mh->startup_allocated;
  80.     mh->dataset_perc = (float)mh->dataset*100/net_usage;
  81.     mh->bytes_per_key = mh->total_keys ? (net_usage / mh->total_keys) : 0;

  82.     return mh;
  83. }
复制代码
基于上面代码的分析,可以知道 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检察。
    1. 17) "db.0"
    2. 18) 1) "overhead.hashtable.main"
    3.     2) (integer) 2536870912
    4.     3) "overhead.hashtable.expires"
    5.     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 统计的是所有从节点客户端的内存使用量。
  1. if (server.repl_backlog)
  2.     mem += zmalloc_size(server.repl_backlog);
  3. mh->repl_backlog = mem;
  4. mem_total += mem;

  5. mem = 0;
  6. // 遍历所有从节点客户端,累加它们的输出缓冲区、输入缓冲区的内存使用量以及客户端对象本身的内存占用。
  7. if (listLength(server.slaves)) {
  8.     listIter li;
  9.     listNode *ln;

  10.     listRewind(server.slaves,&li);
  11.     while((ln = listNext(&li))) {
  12.         client *c = listNodeValue(ln);
  13.         mem += getClientOutputBufferMemoryUsage(c);
  14.         mem += sdsAllocSize(c->querybuf);
  15.         mem += sizeof(client);
  16.     }
  17. }
  18. mh->clients_slaves = mem;
复制代码
由于每个从节点都会分配一个独立的复制缓冲区(即从节点对应客户端的输出缓冲区),所以当从节点的数目增长时,这种实现方式会造成内存的浪费。不但如此,当client-output-buffer-limit设置过大且从节点数目过多时,还容易导致主节点 OOM。
针对这个问题,Redis 7 引入了一个全局复制缓冲区。无论是复制积压缓冲区(repl-backlog),还是从节点的复制缓冲区都是共享这个缓冲区。
replBufBlock结构体用于存储全局复制缓冲区的一个块。
  1. typedef struct replBufBlock {
  2.     int refcount;           /* Number of replicas or repl backlog using. */
  3.     long long id;           /* The unique incremental number. */
  4.     long long repl_offset;  /* Start replication offset of the block. */
  5.     size_t size, used;
  6.     char buf[];
  7. } replBufBlock;
复制代码
每个replBufBlock包罗一个refcount字段,用于记载该块被多少个复制实例(包罗主节点的复制积压缓冲区和从节点)所引用。
当新的从节点添加时,Redis 不会为其分配新的复制缓冲区块,而是增长现有replBufBlock的refcount。
相应地,在 Redis 7 中,mh->repl_backlog 和 mh->clients_slaves 的内存盘算方式也发生了变革。
  1. if (listLength(server.slaves) &&
  2.     (long long)server.repl_buffer_mem > server.repl_backlog_size)
  3. {
  4.     mh->clients_slaves = server.repl_buffer_mem - server.repl_backlog_size;
  5.     mh->repl_backlog = server.repl_backlog_size;
  6. } else {
  7.     mh->clients_slaves = 0;
  8.     mh->repl_backlog = server.repl_buffer_mem;
  9. }
  10. if (server.repl_backlog) {
  11.     /* The approximate memory of rax tree for indexed blocks. */
  12.     mh->repl_backlog +=
  13.         server.repl_backlog->blocks_index->numnodes * sizeof(raxNode) +
  14.         raxSize(server.repl_backlog->blocks_index) * sizeof(void*);
  15. }
  16. mem_total += mh->repl_backlog;
  17. 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函数中盘算的。
  1. size_t freeMemoryGetNotCountedMemory(void) {
  2.     size_t overhead = 0;
  3.     int slaves = listLength(server.slaves);

  4.     if (slaves) {
  5.         listIter li;
  6.         listNode *ln;

  7.         listRewind(server.slaves,&li);
  8.         while((ln = listNext(&li))) {
  9.             client *slave = listNodeValue(ln);
  10.             overhead += getClientOutputBufferMemoryUsage(slave);
  11.         }
  12.     }
  13.     if (server.aof_state != AOF_OFF) {
  14.         overhead += sdsalloc(server.aof_buf)+aofRewriteBufferSize();
  15.     }
  16.     return overhead;
  17. }
复制代码
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
  1. # python3 redis_mem_usage_analyzer.py -host 10.0.1.182 -p 6379
  2. Metric(2024-09-12 04:52:42)    Old Value            New Value(+3s)       Change per second   
  3. ==========================================================================================
  4. Summary
  5. ---------------------------------------------
  6. used_memory                    16.43G               16.44G               1.1M                
  7. used_memory_dataset            11.93G               11.93G               22.66K              
  8. used_memory_overhead           4.51G                4.51G                1.08M               

  9. Overhead(Total)                4.51G                4.51G                1.08M               
  10. ---------------------------------------------
  11. mem_clients_normal             440.57K              440.52K              -18.67B             
  12. mem_clients_slaves             458.41M              461.63M              1.08M               
  13. mem_replication_backlog        160M                 160M                 0B                  
  14. mem_aof_buffer                 0B                   0B                   0B                  
  15. used_memory_startup            793.17K              793.17K              0B                  
  16. used_memory_scripts            0B                   0B                   0B                  
  17. mem_hashtable                  3.9G                 3.9G                 0B                  

  18. Evict & Fragmentation
  19. ---------------------------------------------
  20. maxmemory                      20G                  20G                  0B                  
  21. mem_not_counted_for_evict      458.45M              461.73M              1.1M                
  22. mem_counted_for_evict          15.99G               15.99G               2.62K               
  23. maxmemory_policy               volatile-lru         volatile-lru                             
  24. used_memory_peak               16.43G               16.44G               1.1M                
  25. used_memory_rss                16.77G               16.77G               1.32M               
  26. mem_fragmentation_bytes        345.07M              345.75M              232.88K             

  27. Others
  28. ---------------------------------------------
  29. keys                           77860000             77860000             0.0                 
  30. instantaneous_ops_per_sec      8339                 8435                                     
  31. lazyfree_pending_objects       0                    0                    0.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 检察客户端的内存使用情况。
  1. # python3 redis_mem_usage_analyzer.py -host 10.0.1.182 -p 6379 --client
  2. ID    Address            Name  Age    Command         User     Qbuf       Omem       Total Memory   
  3. ----------------------------------------------------------------------------------------------------
  4. 216   10.0.1.75:37811          721    psync           default  0B         232.83M    232.85M        
  5. 217   10.0.1.22:35057          715    psync           default  0B         232.11M    232.13M        
  6. 453   10.0.0.198:51172         0      client          default  26B        0B         60.03K         
  7. ...  
复制代码
其中,

  • Qbuf:输入缓冲区的大小。
  • Omem:输出缓冲区的大小。
  • Total Memory:该毗连占用的总内存。
结果按 Total Memory 从大到小的顺序输出。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表