Redis 記憶體突增時,如何定量分析其記憶體使用情況

iVictor發表於2024-09-23

背景

最近碰到一個 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 持久化等操作引起的。接下來,我們看看如何系統地分析這類問題。

本文主要包括以下幾部分:

  1. INFO 中的used_memory是怎麼來的?
  2. 什麼是used_memory
  3. used_memory記憶體通常會被用於哪些場景?
  4. Redis 7 在記憶體統計方面的變化。
  5. 資料驅逐的觸發條件——當used_memory 超過maxmemory後,是否一定會觸發驅逐?
  6. 最後,分享一個指令碼,幫助實時分析used_memory增長時,具體是哪一部分的記憶體消耗導致的。

INFO 中的 used_memory 是怎麼來的?

當我們執行INFO命令時,Redis 會呼叫genRedisInfoString函式來生成其輸出。

// server.c
sds genRedisInfoString(const char *section) {
...
/* Memory */
if (allsections || defsections || !strcasecmp(section,"memory")) {
...
size_t zmalloc_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);
}
...
return info;
}

可以看到,used_memory 的值來自 zmalloc_used,而 zmalloc_used 又是透過zmalloc_used_memory()函式獲取的。

// zmalloc.c
size_t zmalloc_used_memory(void) {
size_t um;
atomicGet(used_memory,um);
return um;
}

zmalloc_used_memory() 的實現很簡單,就是以原子方式讀取 used_memory 的值。

什麼是 used_memory

used_memory是一個靜態變數,其型別為redisAtomic size_t,其中redisAtomic_Atomic型別的別名。_Atomic是 C11 標準引入的關鍵字,用於宣告原子型別,保證在多執行緒環境中對該型別的操作是原子的,避免資料競爭。

#define redisAtomic _Atomic
static redisAtomic size_t used_memory = 0;

used_memory 的更新主要透過兩個宏定義實現:

#define update_zmalloc_stat_alloc(__n) atomicIncr(used_memory,(__n))
#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_allocupdate_zmalloc_stat_free來更新 used_memory 的值。

在 Redis 中,記憶體管理主要透過以下兩個函式來實現:

// zmalloc.c
void *ztrymalloc_usable(size_t size, size_t *usable) {
ASSERT_NO_SIZE_OVERFLOW(size);
void *ptr = malloc(MALLOC_MIN_SIZE(size)+PREFIX_SIZE);

if (!ptr) return NULL;
#ifdef HAVE_MALLOC_SIZE
size = zmalloc_size(ptr);
update_zmalloc_stat_alloc(size);
if (usable) *usable = size;
return ptr;
#else
...
#endif
}

void zfree(void *ptr) {
...
if (ptr == NULL) return;
#ifdef HAVE_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主要由兩部分組成:

  1. 資料本身:對應 INFO 中的used_memory_dataset
  2. 內部管理和維護資料結構的開銷:對應 INFO 中的used_memory_overhead

需要注意的是,used_memory_dataset 並不是根據 Key 的數量及 Key 使用的記憶體計算出來的,而是透過 used_memory 減去 used_memory_overhead 得到的。

接下來,我們重點分析下used_memory_overhead 的來源。實際上,Redis 提供了一個單獨的函式-getMemoryOverheadData,專門用於計算這一部分的記憶體開銷。

// object.c
struct redisMemOverhead *getMemoryOverheadData(void) {
int j;
// mem_total 用於累積總的記憶體開銷,最後會賦值給 used_memory_overhead。
size_t mem_total = 0;
// mem 用於計算每一部分的記憶體使用量。
size_t mem = 0;
// 呼叫 zmalloc_used_memory() 獲取 used_memory。
size_t zmalloc_used = zmalloc_used_memory();
// 使用 zcalloc 分配一個 redisMemOverhead 結構體的記憶體。
struct redisMemOverhead *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;

/* Computing the memory used by the clients would be O(N) if done
* here online. We use our values computed incrementally by
* 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 緩衝區和 AOF Rewrite Buffer 的記憶體開銷
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;
long long keyscount = 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;

/* Metrics computed after subtracting the startup memory from
* the total memory. */
size_t net_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;

return mh;
}

基於上面程式碼的分析,可以知道 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_slavesmem_clients_normalmem_aof_buffer

  • mem_aof_buffer:重點關注 AOF 重寫期間緩衝區的大小。
  • mem_clients_slaves 和 mem_clients_normal:都是客戶端,記憶體分配方式相同。客戶端的記憶體開銷主要包括以下三部分:
    1. 輸入緩衝區:用於暫存客戶端命令,大小由 client-query-buffer-limit 限制。
    2. 輸出緩衝區:用於快取傳送給客戶端的資料,大小受 client-output-buffer-limit 控制。如果資料超過軟硬限制並持續一段時間,客戶端會被關閉。
    3. 客戶端物件本身佔用的記憶體。

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)) {
listIter li;
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結構體用於儲存全域性複製緩衝區的一個塊。

typedef struct replBufBlock {
int refcount; /* Number of replicas or repl backlog using. */
long long id; /* The unique incremental number. */
long long repl_offset; /* Start replication offset of the block. */
size_t size, used;
char buf[];
} replBufBlock;

每個replBufBlock包含一個refcount欄位,用於記錄該塊被多少個複製例項(包括主節點的複製積壓緩衝區和從節點)所引用。

當新的從節點新增時,Redis 不會為其分配新的複製緩衝區塊,而是增加現有replBufBlockrefcount

相應地,在 Redis 7 中,mh->repl_backlog 和 mh->clients_slaves 的記憶體計算方式也發生了變化。

if (listLength(server.slaves) &&
(long long)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) {
/* The approximate memory of rax tree for indexed blocks. */
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 ,就會觸發資料的驅逐。但實際上不是。

資料被驅逐需滿足以下條件:

  1. maxmemory 必須大於 0。
  2. maxmemory-policy 不能是 noeviction。
  3. 記憶體使用需滿足一定的條件。不是 used_memory 大於 maxmemory,而是 used_memory 減去 mem_not_counted_for_evict 後的值大於 maxmemory。

其中,mem_not_counted_for_evict的值可以透過 INFO 命令獲取,它的大小是在freeMemoryGetNotCountedMemory函式中計算的。

size_t freeMemoryGetNotCountedMemory(void) {
size_t overhead = 0;
int slaves = listLength(server.slaves);

if (slaves) {
listIter li;
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();
}
return overhead;
}

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

# python3 redis_mem_usage_analyzer.py -host 10.0.1.182 -p 6379
Metric(2024-09-12 04:52:42) Old Value New Value(+3s) Change per second
==========================================================================================
Summary
---------------------------------------------
used_memory 16.43G 16.44G 1.1M
used_memory_dataset 11.93G 11.93G 22.66K
used_memory_overhead 4.51G 4.51G 1.08M

Overhead(Total) 4.51G 4.51G 1.08M
---------------------------------------------
mem_clients_normal 440.57K 440.52K -18.67B
mem_clients_slaves 458.41M 461.63M 1.08M
mem_replication_backlog 160M 160M 0B
mem_aof_buffer 0B 0B 0B
used_memory_startup 793.17K 793.17K 0B
used_memory_scripts 0B 0B 0B
mem_hashtable 3.9G 3.9G 0B

Evict & Fragmentation
---------------------------------------------
maxmemory 20G 20G 0B
mem_not_counted_for_evict 458.45M 461.73M 1.1M
mem_counted_for_evict 15.99G 15.99G 2.62K
maxmemory_policy volatile-lru volatile-lru
used_memory_peak 16.43G 16.44G 1.1M
used_memory_rss 16.77G 16.77G 1.32M
mem_fragmentation_bytes 345.07M 345.75M 232.88K

Others
---------------------------------------------
keys 77860000 77860000 0.0
instantaneous_ops_per_sec 8339 8435
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_normalmem_clients_slaves比較大,可指定 --client 檢視客戶端的記憶體使用情況。

# python3 redis_mem_usage_analyzer.py -host 10.0.1.182 -p 6379 --client
ID Address Name Age Command User Qbuf Omem Total Memory
----------------------------------------------------------------------------------------------------
216 10.0.1.75:37811 721 psync default 0B 232.83M 232.85M
217 10.0.1.22:35057 715 psync default 0B 232.11M 232.13M
453 10.0.0.198:51172 0 client default 26B 0B 60.03K
...

其中,

  • Qbuf:輸入緩衝區的大小。
  • Omem:輸出緩衝區的大小。
  • Total Memory:該連線佔用的總記憶體。

結果按 Total Memory 從大到小的順序輸出。

相關文章