背景
最近碰到一個 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
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_alloc
或update_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
主要由兩部分組成:
- 資料本身:對應 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
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_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)) {
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 不會為其分配新的複製緩衝區塊,而是增加現有replBufBlock
的refcount
。
相應地,在 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 ,就會觸發資料的驅逐。但實際上不是。
資料被驅逐需滿足以下條件:
- maxmemory 必須大於 0。
- maxmemory-policy 不能是 noeviction。
- 記憶體使用需滿足一定的條件。不是 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_normal
或mem_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 從大到小的順序輸出。