不同版本的 Redis 的鍵值對內存占用情況示例
文章目錄
- 不同版本的 Redis 的鍵值對內存占用情況示例
- Redis 6.0
- redisObject
- dictEntry
- sds
- 🍀 數據結構
- 🍀 sdslen() 函數
- 🍀 sdsReqType() 函數
- 🍀 sdsHdrSize() 函數
- 內存分配 - malloc() 函數
- 🍀 大小類別的計算
- 🍀 選擇合適的 bin
- 🍀 實際內存塊分配
- set [key] [value]
- 🍀 sdsdup() 函數
- 🍀 dictAddRaw() 函數
- 🍀 dictSetVal() 函數
- memory usage [key]
- 🍀 計算 value 的字節數
- 🍀 計算 key 的字節數
- 🍀 計算鍵值對結構體 dictEntry 的字節數
- 🍀 小結
- Redis 7.0
- redisObject
- dictEntry
- sds
- 🍀 數據結構
- 🍀 sdslen() 函數
- 🍀 sdsReqType() 函數
- 🍀 sdsHdrSize() 函數
- 內存分配 - malloc() 函數
- 🍀 大小類別的計算
- 🍀 選擇合適的 bin
- 🍀 實際內存塊分配
- set [key] [value]
- 🍀 sdsdup() 函數
- 🍀 dictAddRaw() 函數
- 🍀 dictSetVal() 函數
- memory usage [key]
- 🍀 計算 value 的字節數
- 🍀 計算 key 的字節數
- 🍀 計算鍵值對結構體 dictEntry 的字節數
- 🍀 計算所在 db 庫的字典元數據的字節數
- 🍀 小結
- 總結
- 造成差異的原因
- memory usage [key] 計算內存使用小結
- 感悟
本文主要討論在 Redis 6.0 與 Redis 7.0 中,以下代碼設置的鍵值對的內存使用字節差異:
# 1(6 個 a)
set aaaaaa 12345678# 2
memory usage aaaaaa# 3(7 個 a)
set aaaaaaa 12345678# 4
memory usage aaaaaaa
「1」與「3」兩條命令分別設置了鍵值對,雖然 key 只相差 1 個字符,但在 Redis 6.0 與 Redis 7.0 中使用 memory usage [key]
命令計算出的內存使用字節數有明顯差異。
-
Redis 6.0
127.0.0.1:6379> set aaaaaa 12345678 OK 127.0.0.1:6379> memory usage aaaaaa (integer) 48 127.0.0.1:6379> set aaaaaaa 12345678 OK 127.0.0.1:6379> memory usage aaaaaaa (integer) 49
-
Redis 7.0
127.0.0.1:6379> set aaaaaa 12345678 OK 127.0.0.1:6379> memory usage aaaaaa (integer) 48 127.0.0.1:6379> set aaaaaaa 12345678 OK 127.0.0.1:6379> memory usage aaaaaaa (integer) 56
Redis 6.0
環境:
- Redis 6.0.8 源碼,單機模式環境
- Ubuntu 24.04.1 LTS,x86_64 架構(64 位操作系統)
redisObject
Redis 中的 value 對象由 redisObject 結構表示。
// 4 + 4 + 24 + 32 + 64 = 128 bits = 16 bytes
typedef struct redisObject {// 4 bitunsigned type:4;// 4 bitunsigned encoding:4;// #define LRU_BITS 24 即 24 bitunsigned lru:LRU_BITS;// 32 bit int refcount;// 64 bit(在 64 位操作系統中占 64 bit,在 32 位操作系統中占 32 bit)void *ptr;
} robj;
對象結構里包含的成員變量:
- type:標識該對象的數據類型,數據類型是指
String
、List
、Hash
、Set
、ZSet
等等。 - encoding:標識該對象使用的底層數據結構,底層數據結構是指
SDS
、ZipList
、SkipList
等等。 - lru:用于內存淘汰策略的最近最少使用或最少頻率使用的鍵值對狀態信息。
- refcount:引用計數。
- ptr:指向底層數據結構的指針。
struct redisObject
占用字節數為 16,可使用 sizeof(robj)
計算。
dictEntry
Redis 中的鍵值對由 dictEntry 結構表示。
// 8 + 8 + 8 = 24 bytes
typedef struct dictEntry {// 8 bytesvoid *key;// 8 bytesunion {void *val;uint64_t u64;int64_t s64;double d;} v;// 8 bytesstruct dictEntry *next;
} dictEntry;
對象結構里包含的成員變量:
- key:存儲 key 地址的指針。
- v:聯合體,存儲 value 地址或 value 本身的值。
- next:指向鏈表中的下一個元素。
struct dictEntry
占用字節數為 24,可根據 sizeof(robj)
計算。
sds
🍀 數據結構
// sds 實際是字符指針的別名,指向的是 sdshdr5、sdshdr8、sdshdr16 等結構體的 buf 字符數組
typedef char *sds;
/* 注意:sdshdr5 不會作為 value 的數據結構 */
struct __attribute__ ((__packed__)) sdshdr5 {unsigned char flags; /* 低 3 bit 表示 sds 類型,高 5 bit 表示字符串有效長度(不包含結束字符 '\0') */char buf[]; /* 實際存儲字符 */
};
struct __attribute__ ((__packed__)) sdshdr8 {uint8_t len; /* 字符串有效長度,不包含結束字符 '\0' */uint8_t alloc; /* 為 buf 字符數組分配了的字節大小,不包含結束字符 '\0' */unsigned char flags; /* 低 3 bit 表示 sds 類型,高 5 bit 未使用 */char buf[]; /* 實際存儲字符 */
};struct __attribute__ ((__packed__)) sdshdr16 {uint16_t len;uint16_t alloc;unsigned char flags;char buf[];
};struct __attribute__ ((__packed__)) sdshdr32 {uint32_t len;uint32_t alloc;unsigned char flags;char buf[];
};struct __attribute__ ((__packed__)) sdshdr64 {uint64_t len;uint64_t alloc;unsigned char flags;char buf[];
};
根據內存分配原理,如果我們已知 buf 字符數組的起始地址,那么在此地址的基礎上,將地址減去 sizeof(char)
,得到的地址所存儲的內容就是字符變量 flags
的內容。據此,我們就可以得到對應的 sds 類型。 這一點在后面的源碼分析中會有體現。
📍
__attribute__ ((__packed__))
用于告訴編譯器進行緊湊字節填充,即忽略默認的對齊規則,不進行任何字節填充。
🍀 sdslen() 函數
作用:返回字符串的有效長度,有效長度并不包含結束字符 '\0'
。
/* 返回字符串的有效長度 */
static inline size_t sdslen(const sds s) {// sds 實際是 char * 別名,因此 s[-1] 實際上將字符指針存儲的地址減去 sizeof(char) 并解引用,得到字符變量 flags 存儲的內容 unsigned char flags = s[-1];// 根據 flags 中存儲的 sds 類型標識來判斷 sds 類型,以正確得到 len 屬性值,即字符串有效長度switch(flags&SDS_TYPE_MASK) {case SDS_TYPE_5:return SDS_TYPE_5_LEN(flags);case SDS_TYPE_8:return SDS_HDR(8,s)->len;case SDS_TYPE_16:return SDS_HDR(16,s)->len;case SDS_TYPE_32:return SDS_HDR(32,s)->len;case SDS_TYPE_64:return SDS_HDR(64,s)->len;}return 0;
}
🍀 sdsReqType() 函數
作用:根據字符串長度,獲取至少應該使用的 sds 數據結構類型的標識。
/* 根據字符串長度 string_size,獲取至少應該使用的 sds 數據結構類型的標識。 */
static inline char sdsReqType(size_t string_size) {// 如果字符串長度小于 2^5,則應當使用類型為 sdshr5 的結構體作為 sds 數據結構if (string_size < 1<<5)return SDS_TYPE_5;// 如果字符串長度小于 2^8,則應當使用類型為 sdshr8 的結構體作為 sds 數據結構if (string_size < 1<<8)return SDS_TYPE_8;// 如果字符串長度小于 2^8,則應當使用類型為 sdshr16 的結構體作為 sds 數據結構if (string_size < 1<<16)return SDS_TYPE_16;// 條件編譯,會根據操作系統架構進行動態調整代碼
#if (LONG_MAX == LLONG_MAX)if (string_size < 1ll<<32)return SDS_TYPE_32;return SDS_TYPE_64;
#elsereturn SDS_TYPE_32;
#endif
}
🍀 sdsHdrSize() 函數
作用:根據類型標識,獲取對應類型的結構體(sdshdr5、sdshdr8、sdshdr16…)的占用字節大小。
/* 根據類型標識 type,獲取對應類型的結構體(sdshdr5、sdshdr8、sdshdr16...)的占用字節大小。 */
static inline int sdsHdrSize(char type) {switch(type&SDS_TYPE_MASK) {case SDS_TYPE_5:return sizeof(struct sdshdr5);case SDS_TYPE_8:return sizeof(struct sdshdr8);case SDS_TYPE_16:return sizeof(struct sdshdr16);case SDS_TYPE_32:return sizeof(struct sdshdr32);case SDS_TYPE_64:return sizeof(struct sdshdr64);}return 0;
}
內存分配 - malloc() 函數
Redis 選擇了使用 jemalloc 作為其默認的內存分配器,因此我們這里關注 jemalloc 對 malloc()
函數的實現。
/* 內存分配,size 是請求分配的內存大小,但實際分配的連續內存塊大小 >= size */
void *je_malloc(size_t size) {void *ret;static_opts_t sopts;dynamic_opts_t dopts;LOG("core.malloc.entry", "size: %zu", size);static_opts_init(&sopts);dynamic_opts_init(&dopts);sopts.bump_empty_alloc = true;sopts.null_out_result_on_error = true;sopts.set_errno_on_error = true;sopts.oom_string = "<jemalloc>: Error in malloc(): out of memory\n";dopts.result = &ret;dopts.num_items = 1;/* 將 item_size 設置為請求分配的內存大小 size */dopts.item_size = size;/* imalloc() 函數完成內存分配,并將分配的連續內存塊的起始地址存放在 dopts.result 指針指向的地址中,即 ret 中 */imalloc(&sopts, &dopts);LOG("core.malloc.exit", "result: %p", ret);return ret;
}
/* 返回內存分配情況的錯誤狀態碼 */
int imalloc(static_opts_t *sopts, dynamic_opts_t *dopts) {// .../* We always need the tsd. Let's grab it right away. */tsd_t *tsd = tsd_fetch();assert(tsd);if (likely(tsd_fast(tsd))) {/* Fast and common path. */tsd_assert_fast(tsd);sopts->slow = false;/* imalloc_body() 函數完成內存分配,并將分配的連續內存塊的起始地址存放在 dopts->result 指針指向的地址中 */ return imalloc_body(sopts, dopts, tsd);} else {sopts->slow = true;return imalloc_body(sopts, dopts, tsd);}
}
int imalloc_body(static_opts_t *sopts, dynamic_opts_t *dopts, tsd_t *tsd) {/* 指向實際分配的內存塊的起始地址 */void *allocation = NULL;/* 用于存儲請求的內存大小 */size_t size = 0;szind_t ind = 0;size_t usize = 0;/* Reentrancy is only checked on slow path. */int8_t reentrancy_level;/* 計算請求的內存大小,正常情況下,會將 *size = dopts->item_size,也就是將請求的內存大小賦值給 size 變量 */if (unlikely(compute_size_with_overflow(sopts->may_overflow, dopts,&size))) {goto label_oom; // 如果計算過程中發生溢出,則跳轉到錯誤處理標簽}// .../* 核心算法開始 */// 如果沒有特殊對齊要求,默認情況下 dopts->alignment 為 0if (dopts->alignment == 0) {/* 將請求的字節大小 size 轉為索引,該索引用于定位負責處理特定大小內存塊的 bin */ind = sz_size2index(size);// ...} else { // 如果有對齊要求// 根據對齊需求調整大小usize = sz_sa2u(size, dopts->alignment);// ...}// ...// imalloc_no_sample() 函數實際執行內存分配,并返回分配的連續內存塊的起始地址allocation = imalloc_no_sample(sopts, dopts, tsd, size, usize, ind);if (unlikely(allocation == NULL)) {goto label_oom;}/* Success! */// 將已分配的內存塊的起始地址賦給 *dopts->result*dopts->result = allocation;return 0;// ...
}
整個的內存分配,大致做了三件事情:
- 大小類別的計算
- 選擇合適的 bin
- 實際內存塊分配
🍀 大小類別的計算
將請求的字節大小 size 轉為索引,該索引用于定位負責處理特定大小內存塊的 bin。實際上,該索引不僅可以定位到 tcache_t
中對應的 cache_bin_t
實例,還可以得到請求字節大小對應的實際 jemalloc 應該分配的內存塊大小,這個實際分配內存塊大小等于 sz_index2size_tab[ind]
。
szind_t sz_size2index(size_t size) {assert(size > 0);if (likely(size <= LOOKUP_MAXCLASS)) {/* 將請求的字節大小 size 轉為索引,該索引用于定位負責處理特定大小內存塊的 bin */return sz_size2index_lookup(size);}return sz_size2index_compute(size);
}
#define LG_TINY_MIN 3szind_t sz_size2index_lookup(size_t size) {assert(size <= LOOKUP_MAXCLASS);{/* * 1.根據 size 計算 sz_size2index_tab 映射表索引:(size-1) >> LG_TINY_MIN* 2.從 sz_size2index_tab 映射表獲取定位 bin 的索引:sz_size2index_tab[(size-1) >> LG_TINY_MIN] */szind_t ret = (sz_size2index_tab[(size-1) >> LG_TINY_MIN]);assert(ret == sz_size2index_compute(size));/* 返回用于定位負責處理特定大小內存塊的 bin 的索引 */return ret;}
}
這里 jemalloc 實際維護了兩張映射表:
-
sz_size2index_tab
:維護了從「請求字節大小」到「索引」的映射。/** sz_size2index_tab is a compact lookup table that rounds request sizes up to* size classes. In order to reduce cache footprint, the table is compressed,* and all accesses are via sz_size2index().*/ extern uint8_t const sz_size2index_tab[];
數組索引(index) 定位 bin/存儲的 sz_index2size_tab 數組索引(value) 0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 8 10 9 11 9 12 10 … … -
sz_index2size_tab
:維護了從「索引」到「jemalloc 應該分配的內存塊大小」的映射。/** sz_index2size_tab encodes the same information as could be computed (at* unacceptable cost in some code paths) by sz_index2size_compute().*/ extern size_t const sz_index2size_tab[NSIZES];
數組索引(index) jemalloc 應該分配的內存塊大小(value) 0 8 1 16 2 24 3 32 4 40 5 48 6 56 7 64 8 80 9 96 10 112 11 128 12 160 13 192 14 224 … … 233 6917529027641081856 234 8070450532247928832
📑 例如:
- 如果請求字節大小 size = 8,那么通過
sz_size2index_lookup()
計算得到的存儲的sz_index2size_tab
數組索引為 0,可得sz_index2size_tab[0]
的值為 8。也就是說,如果請求字節大小為 8,那么 jemalloc 會為其分配 8 字節的連續內存塊。 - 如果請求字節大小 size = 9,那么通過
sz_size2index_lookup()
計算得到的存儲的sz_index2size_tab
數組索引為 1,可得sz_index2size_tab[1]
的值為 16。也就是說,如果請求字節大小為 9,那么 jemalloc 會為其分配 16 字節的連續內存塊。
🍀 選擇合適的 bin
當應用程序請求分配某個大小的對象時,jemalloc 會計算出最接近且不小于該大小的類別索引,然后使用這個索引來訪問 tcache_t
中對應的 cache_bin_t
進行分配。每個 cache_bin_t
實例專用于一個預定義的大小類別,從而實現了對多種不同大小內存塊的支持。
也就是在源碼中,有大概這樣的邏輯:
szind_t ind = sz_size2index(size); // 獲取大小類別的索引
cache_bin_t *bin = &tcache->bins_small[ind]; // 獲取對應的 bin,以 cache_bin_t bins_small[39] 數組為例
tcache 是什么呢?
#define NBINS 39
#define NSIZES 235struct tcache_s {// .../** 小對象的緩存 bin 數組* 每個索引 i 位置的 bin 每次分配的內存塊大小與 sz_index2size_tab 映射表中索引 i 位置存儲的 jemalloc 應該分配的內存塊大小相同*/cache_bin_t bins_small[NBINS];/** 大對象的緩存 bin 數組* 每個索引 i 位置的 bin 每次分配的內存塊大小與 sz_index2size_tab 映射表中索引 i+NBINS 位置存儲的 jemalloc 應該分配的內存塊大小相同*/cache_bin_t bins_large[NSIZES - NBINS];
};
🍀 實際內存塊分配
首先需要了解 cache_bin_t
結構體:
typedef struct cache_bin_s cache_bin_t;
typedef int32_t cache_bin_sz_t;struct cache_bin_s {cache_bin_sz_t low_water;cache_bin_sz_t ncached;cache_bin_stats_t tstats;void **avail;
};
avail
:這是一個二級指針,存儲了一個指針數組的末端邊界地址。指針數組是用于存儲一組指向可用內存塊的指針。指針數組可看做是一個棧結構,從棧頂 -> 棧底,對應指針數組的首地址 -> 末端邊界地址(低地址 -> 高地址),avail 二級指針指向的地址即棧底。ncached
:記錄當前 bin 中有多少個可用的內存塊,每次成功分配時減一,回收時加一。它也是指針數組的元素個數,即可用內存塊數量。
avail[-ncached, ..., -1]
是可用內存塊的指針,其中最低地址的對象將最先被分配出去。也就是說,當進行內存分配時,將棧頂元素 *(avail - ncached)
彈出,并 ncached--
。源碼如下:
/* 使用 bin 實例分配對應大小的內存塊,返回分配的內存塊的首地址 */
void *cache_bin_alloc_easy(cache_bin_t *bin, bool *success) {void *ret;/* 檢查 bin 中是否有可用的緩存塊 */if (unlikely(bin->ncached == 0)) { // 如果沒有可用塊bin->low_water = -1; // 設置低水位標記為無效值*success = false; // 分配失敗return NULL; // 返回空指針表示分配失敗}/* 分配成功 */*success = true;/* * 從 bin 的 avail 棧頂彈出一個內存塊地址。* 注意這里的減法操作是因為 avail 指向的是棧底,而 ncached 表示棧中的元素數量。* 這樣可以確保我們總是從棧頂獲取最新的可用塊。*/ret = *(bin->avail - bin->ncached);/* 更新緩存計數器,因為我們剛剛分配了一個塊 */bin->ncached--;if (unlikely(bin->ncached < bin->low_water)) {bin->low_water = bin->ncached;}/* 返回分配的內存塊地址 */return ret;
}
set [key] [value]
set [key] [value]
命令對應的處理函數為 setCommand
。
以在命令執行前,db 中不存在該 key 為例,setCommand()
函數會調用到核心處理函數 dbAdd()
。
/* 將 key-value 添加到 db 中 */
void dbAdd(redisDb *db, robj *key, robj *val) {// sds 是 char* 的別名,通過 sdsdup() 函數得到的實際是表示 key 的 sds 結構體 buf 字符數組首元素地址sds copy = sdsdup(key->ptr);int retval = dictAdd(db->dict, copy, val);serverAssertWithInfo(NULL,key,retval == DICT_OK);if (val->type == OBJ_LIST ||val->type == OBJ_ZSET ||val->type == OBJ_STREAM)signalKeyAsReady(db, key);if (server.cluster_enabled) slotToKeyAdd(key->ptr);
}int dictAdd(dict *d, void *key, void *val)
{// 1.在堆中開辟 dictEntry 結構體對象空間// 2.將 dictEntry 存儲在 db 字典中// 3.將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中// 4.返回 dictEntry 結構體指針dictEntry *entry = dictAddRaw(d,key,NULL);if (!entry) return DICT_ERR;// 將 value 設置到 dictEntry 結構體對象 dictSetVal(d, entry, val);return DICT_OK;
}
我們重點關注源碼中以下三個函數的作用:
sdsdup(key->ptr)
:拷貝 sds,并返回字符數組的首元素地址。dictAddRaw(d, key, NULL)
:- 在堆中開辟 dictEntry 結構體對象空間;
- 將 dictEntry 存儲在 db 字典中;
- 將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中;
- 返回 dictEntry 結構體指針。
dictSetVal(d, entry, val)
:將 value 設置到 dictEntry 結構體對象
🍀 sdsdup() 函數
作用:拷貝 sds,并返回字符數組的首元素地址。
sds sdsdup(const sds s) {/* sdslen(s) 返回字符串的有效長度 */return sdsnewlen(s, sdslen(s));
}
sds sdsnewlen(const void *init, size_t initlen) {void *sh;sds s;/* 根據初始化長度獲取至少應該使用的 sds 數據結構類型的標識 */char type = sdsReqType(initlen);/* 空字符串通常為拼接而創建的,因此使用 sdshdr8 作為 sds 數據結構比 sdshdr5 更加合適 */if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;/* 根據類型標識,獲取對應類型的結構體(sdshdr5、sdshdr8、sdshdr16...)的占用字節大小 *//* 由于實際存儲字符串的 char buf[] 是結構體最后一個成員,因此這是一個柔性數組,占用字節不會計算在使用 sizeof() 得到的結構體占用字節范圍內 */int hdrlen = sdsHdrSize(type);/* 指向 sds 類型 —— flags 變量的指針 */unsigned char *fp;/* 為 sds 數據結構分配堆內存 *//* 請求字節大小為 hdrlen+initlen+1 = sds 結構體大小 + 字符串有效長度 + 結束字符 '\0' 的 1 個字節 */sh = s_malloc(hdrlen+initlen+1);// .../* 將預期字符數組 buf 的起始地址存儲到 char* s 中 */s = (char*)sh+hdrlen;/* 將 flags 變量的地址存儲到 unsigned char *fp 中 */fp = ((unsigned char*)s)-1;/* 根據類型標識,對 sds 類型的實現數據類型結構體進行屬性配置 */switch(type) {case SDS_TYPE_5: {*fp = type | (initlen << SDS_TYPE_BITS); /* 低 3 bit 表示 sds 類型,高 5 bit 表示字符串有效長度 */break;}case SDS_TYPE_8: {SDS_HDR_VAR(8,s); /* SDS_HDR_VAR 是一個宏函數,作用是將 sh 指針指向 sds 結構體的起始地址,以操作結構體 */sh->len = initlen; /* 設置字符串有效長度 */sh->alloc = initlen; /* 設置為 buf 字符數組分配了的字節大小 */*fp = type; /* 設置 sds 類型 */break;}case SDS_TYPE_16: {SDS_HDR_VAR(16,s);sh->len = initlen;sh->alloc = initlen;*fp = type;break;}case SDS_TYPE_32: {SDS_HDR_VAR(32,s);sh->len = initlen;sh->alloc = initlen;*fp = type;break;}case SDS_TYPE_64: {SDS_HDR_VAR(64,s);sh->len = initlen;sh->alloc = initlen;*fp = type;break;}}/* 字符數組拷貝 */if (initlen && init)memcpy(s, init, initlen);/* 為了兼容 c 標準字符串函數,以 '\0' 作為字符數組結束標識 */s[initlen] = '\0';/* 返回字符數組 buf 的起始地址 */return s;
}
對應的:
-
key = “aaaaaa”,字節數
initlen
為 6,由于字節數 < 32,因此使用sdshdr5
作為 key 字符串的數據結構,則hdrlen = sizeof(struct sdshdr5) = 1
,因此調用s_malloc()
函數時請求字節大小為hdrlen+initlen+1 = 1+6+1 = 8
,則sz_size2index_lookup()
函數計算得到的存儲的sz_index2size_tab
數組索引為 0,而sz_index2size_tab[0]
的值為 8,即實際分配內存塊大小為 8。在配置sdshdr5
實例屬性時,設置alloc = initlen = 6
。 -
key = “aaaaaaa”,字節數
initlen
為 7,由于字節數 < 32,因此使用sdshdr5
作為 key 字符串的數據結構,則hdrlen = sizeof(struct sdshdr5) = 1
,因此調用s_malloc_usable()
函數時請求字節大小為hdrlen+initlen+1 = 1+7+1 = 9
,則sz_size2index_lookup()
函數計算得到的存儲的sz_index2size_tab
數組索引為 1,而sz_index2size_tab[0]
的值為 16,即實際分配內存塊大小為 16。在配置sdshdr5
實例屬性時,設置alloc = initlen = 7
。也就是說,Redis 不會將多分配的 7 字節作為字符數組 buf 的空間使用。
🍀 dictAddRaw() 函數
作用:
- 在堆中開辟 dictEntry 結構體對象空間;
- 將 dictEntry 存儲在 db 字典中;
- 將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中;
- 返回 dictEntry 結構體指針。
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{long index;dictEntry *entry;dictht *ht;if (dictIsRehashing(d)) _dictRehashStep(d);/* 計算 key 在 dict 哈希字典中的索引 */if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)return NULL;/* 由于可能的擴容,因此存在兩個 dict,需要判斷使用使用哪一個 */ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];/* 在堆中開辟 dictEntry 結構體對象空間 */entry = zmalloc(sizeof(*entry));/* 采用頭插法,并以鏈表形式,將 dictEntry 存儲到字典索引位置 */entry->next = ht->table[index];ht->table[index] = entry;/* key 計數 +1 */ht->used++;/* dictSetKey 是一個宏,會替換為 entry->key = key 即將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中 */dictSetKey(d, entry, key);/* 返回 dictEntry 結構體指針 */return entry;
}
對應的:
-
key = “aaaaaa”,由于
sizeof(struct dictEntry)
為 24,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 2,而sz_index2size_tab[2]
的值為 24,即實際分配內存塊大小為 24。 -
key = “aaaaaaa”,由于
sizeof(struct dictEntry)
為 24,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 2,而sz_index2size_tab[2]
的值為 24,即實際分配內存塊大小為 24。
🍀 dictSetVal() 函數
作用:將 value 設置到 dictEntry 結構體對象。這實際是一個宏函數,在預編譯時期完成替換。
#define dictSetVal(d, entry, _val_) do { \if ((d)->type->valDup) \(entry)->v.val = (d)->type->valDup((d)->privdata, _val_); \else \(entry)->v.val = (_val_); \
} while(0)
這一步很簡單,就是設置 dictEntry -> v.val
指針指向。但我們要重點關注的是 dictEntry -> v.val
指針或者說 robj *val
指針指向的結構體信息,因為這個結構體是 value 的實際內存存儲與占用內容。
這里只看 value = “12345678” 的源碼部分。由于 “12345678” 可用整型表示,為了節約內存,Redis 會使用 OBJ_ENCODING_INT
編碼來進行優化。
// 返回 value 對應的 redisObject 結構體的指針
robj *createStringObjectFromLongLongWithOptions(long long value, int valueobj) {robj *o;// ...if (value >= LONG_MIN && value <= LONG_MAX) {// 創建一個 type = OBJ_STRING 的 redisObject 結構體,sizeof(struct redisObject) 為 16 字節o = createObject(OBJ_STRING, NULL);// 設置編碼為 OBJ_ENCODING_INTo->encoding = OBJ_ENCODING_INT;// 復用指針變量,節省內存,把 12345678 當做地址存儲。在 get 時,會根據 encoding 再從 ptr 取出值o->ptr = (void*)((long)value);}// ...return o;
}
對應的:
-
key = “aaaaaa”,value = “12345678”,由于
sizeof(struct redisObject)
為 16,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 1,而sz_index2size_tab[1]
的值為 16,即實際分配內存塊大小為 16。 -
key = “aaaaaaa”,value = “12345678”,由于
sizeof(struct redisObject)
為 16,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 1,而sz_index2size_tab[1]
的值為 16,即實際分配內存塊大小為 16。
memory usage [key]
memory usage [key]
命令對應的處理函數為 memoryCommand
。
void memoryCommand(client *c) {// ...// 1.計算 value 的字節數size_t usage = objectComputeSize(dictGetVal(de),samples);// 2.計算 key 的字節數usage += sdsAllocSize(dictGetKey(de));// 3.計算鍵值對結構體 dictEntry 的字節數usage += sizeof(dictEntry);// ...
}
usage
變量即是用于存儲鍵值對的內存使用字節數。可以看到,共有三個部分組成:
objectComputeSize(dictGetVal(de),samples)
:計算 value 的字節數。sdsAllocSize(dictGetKey(de))
:計算 key 的字節數。sizeof(dictEntry)
:計算鍵值對結構體 dictEntry 的字節數。
🍀 計算 value 的字節數
size_t objectComputeSize(robj *o, size_t sample_size) {sds ele, ele2;dict *d;dictIterator *di;struct dictEntry *de;size_t asize = 0, elesize = 0, samples = 0;if (o->type == OBJ_STRING) {if(o->encoding == OBJ_ENCODING_INT) { // 執行第 1 個 if 中的語句asize = sizeof(*o); // sizeof(struct redisObject) = 16 bytes} else if(o->encoding == OBJ_ENCODING_RAW) {asize = sdsAllocSize(o->ptr)+sizeof(*o);} else if(o->encoding == OBJ_ENCODING_EMBSTR) {asize = sdslen(o->ptr)+2+sizeof(*o);} else {serverPanic("Unknown string encoding");}} else if (o->type == OBJ_LIST) {// ...} else if (o->type == OBJ_SET) {// ...} else if (o->type == OBJ_ZSET) {// ...} else if (o->type == OBJ_HASH) {// ...} else if (o->type == OBJ_STREAM) {// ...} else if (o->type == OBJ_MODULE) {// ...} else {serverPanic("Unknown object type");}return asize;
}
對應的:
- key = “aaaaaa”,value = “12345678”,存儲時
redisObject.type = OBJ_STRING
且redisObject.encoding = OBJ_ENCODING_INT
,則objectComputeSize()
函數返回結果為sizeof(struct redisObject)
即 16。 - key = “aaaaaaa”,value = “12345678”,存儲時
redisObject.type = OBJ_STRING
且redisObject.encoding = OBJ_ENCODING_INT
,則objectComputeSize()
函數返回結果為sizeof(struct redisObject)
即 16。
🍀 計算 key 的字節數
size_t sdsAllocSize(sds s) {// 獲取 sds 結構體的 alloc 屬性值,這實際是為字符數組 buf 開辟了的內存大小(不包含結束字符 '\0')size_t alloc = sdsalloc(s);// sds 結構體占用字節 + 為字符數組 buf 開辟了的內存大小 + 結束字符 '\0' 1 個字節// 這實際是之前 set 時對 key 進行內存分配計算出的請求內存大小,而非實際分配內存大小,redis 6.0 沒有使用這多分配的空間return sdsHdrSize(s[-1])+alloc+1;
}/* sdsalloc() = sdsavail() + sdslen() */
static inline size_t sdsalloc(const sds s) {unsigned char flags = s[-1];switch(flags&SDS_TYPE_MASK) {case SDS_TYPE_5:return SDS_TYPE_5_LEN(flags);case SDS_TYPE_8:return SDS_HDR(8,s)->alloc;case SDS_TYPE_16:return SDS_HDR(16,s)->alloc;case SDS_TYPE_32:return SDS_HDR(32,s)->alloc;case SDS_TYPE_64:return SDS_HDR(64,s)->alloc;}return 0;
}
對應的:
- key = “aaaaaa”,通過之前對
sdsdup()
函數的分析,請求內存大小為sizeof(struct sdshdr5)+alloc+1=1+6+1=8
。 - key = “aaaaaaa”,通過之前對
sdsdup()
函數的分析,請求內存大小為sizeof(struct sdshdr5)+alloc+1=1+7+1=9
。
🍀 計算鍵值對結構體 dictEntry 的字節數
typedef struct dictEntry {void *key;union {void *val;uint64_t u64;int64_t s64;double d;} v;struct dictEntry *next;
} dictEntry;
占用字節分析:
- *void key:8 字節。
- union v:聯合體,8 字節。
- *struct dictEntry next:8 字節。
綜上,sizeof(struct dictEntry)
的結果為 24 字節。
🍀 小結
綜上對每個函數的分析,以及 set 時的具體實現,我們得出:
類型 | set aaaaaa 12345678 | set aaaaaaa 12345678 |
---|---|---|
計算 value 的字節數 | 16 | 16 |
計算 key 的字節數 | 8 | 9 |
計算鍵值對結構體 dictEntry 的字節數 | 24 | 24 |
字節總和 | 48 | 49 |
Redis 7.0
- Redis 7.0.14 源碼,單機模式環境
- Ubuntu 24.04.1 LTS,x86_64 架構(64 位操作系統)
redisObject
Redis 中的 value 對象由 redisObject 結構表示。
// 4 + 4 + 24 + 32 + 64 = 128 bits = 16 bytes
typedef struct redisObject {// 4 bitunsigned type:4;// 4 bitunsigned encoding:4;// #define LRU_BITS 24 即 24 bitunsigned lru:LRU_BITS;// 32 bit int refcount;// 64 bit(在 64 位操作系統中占 64 bit,在 32 位操作系統中占 32 bit)void *ptr;
} robj;
對象結構里包含的成員變量:
- type:標識該對象的數據類型,數據類型是指
String
、List
、Hash
、Set
、ZSet
等等。 - encoding:標識該對象使用的底層數據結構,底層數據結構是指
SDS
、ZipList
、SkipList
等等。 - lru:用于內存淘汰策略的最近最少使用或最少頻率使用的鍵值對狀態信息。
- refcount:引用計數。
- ptr:指向底層數據結構的指針。
struct redisObject
占用字節數為 16,可使用 sizeof(robj)
計算。
dictEntry
Redis 中的鍵值對由 dictEntry 結構表示。
// 8 + 8 + 8 = 24 bytes
typedef struct dictEntry {// 8 bytesvoid *key;// 8 bytesunion {void *val;uint64_t u64;int64_t s64;double d;} v;// 8 bytesstruct dictEntry *next;// 空指針數組,由于是結構體最后一個成員,因此是柔性數組,不參與結構體占用字節大小計算void *metadata[];
} dictEntry;
對象結構里包含的成員變量:
- key:存儲 key 地址的指針。
- v:聯合體,存儲 value 地址或 value 本身的值。
- next:指向鏈表中的下一個元素。
- metadata:存儲與鍵值對相關的額外信息。
struct dictEntry
占用字節數為 24,可根據 sizeof(robj)
計算。
sds
🍀 數據結構
// sds 實際是字符指針的別名,指向的是 sdshdr5、sdshdr8、sdshdr16 等結構體的 buf 字符數組
typedef char *sds;
/* 注意:sdshdr5 不會作為 value 的數據結構 */
struct __attribute__ ((__packed__)) sdshdr5 {unsigned char flags; /* 低 3 bit 表示 sds 類型,高 5 bit 表示字符串有效長度(不包含結束字符 '\0') */char buf[]; /* 實際存儲字符 */
};
struct __attribute__ ((__packed__)) sdshdr8 {uint8_t len; /* 字符串有效長度,不包含結束字符 '\0' */uint8_t alloc; /* 為 buf 字符數組分配了的字節大小,不包含結束字符 '\0' */unsigned char flags; /* 低 3 bit 表示 sds 類型,高 5 bit 未使用 */char buf[]; /* 實際存儲字符 */
};struct __attribute__ ((__packed__)) sdshdr16 {uint16_t len;uint16_t alloc;unsigned char flags;char buf[];
};struct __attribute__ ((__packed__)) sdshdr32 {uint32_t len;uint32_t alloc;unsigned char flags;char buf[];
};struct __attribute__ ((__packed__)) sdshdr64 {uint64_t len;uint64_t alloc;unsigned char flags;char buf[];
};
根據內存分配原理,如果我們已知 buf 字符數組的起始地址,那么在此地址的基礎上,將地址減去 sizeof(char)
,得到的地址所存儲的內容就是字符變量 flags
的內容。據此,我們就可以得到對應的 sds 類型。 這一點在后面的源碼分析中會有體現。
📍
__attribute__ ((__packed__))
用于告訴編譯器進行緊湊字節填充,即忽略默認的對齊規則,不進行任何字節填充。
🍀 sdslen() 函數
作用:返回字符串的有效長度,有效長度并不包含結束字符 '\0'
。
/* 返回字符串的有效長度 */
static inline size_t sdslen(const sds s) {// sds 實際是 char * 別名,因此 s[-1] 實際上將字符指針存儲的地址減去 sizeof(char) 并解引用,得到字符變量 flags 存儲的內容 unsigned char flags = s[-1];// 根據 flags 中存儲的 sds 類型標識來判斷 sds 類型,以正確得到 len 屬性值,即字符串有效長度switch(flags&SDS_TYPE_MASK) {case SDS_TYPE_5:return SDS_TYPE_5_LEN(flags);case SDS_TYPE_8:return SDS_HDR(8,s)->len;case SDS_TYPE_16:return SDS_HDR(16,s)->len;case SDS_TYPE_32:return SDS_HDR(32,s)->len;case SDS_TYPE_64:return SDS_HDR(64,s)->len;}return 0;
}
🍀 sdsReqType() 函數
作用:根據字符串長度,獲取至少應該使用的 sds 數據結構類型的標識。
/* 根據字符串長度 string_size,獲取至少應該使用的 sds 數據結構類型的標識。 */
static inline char sdsReqType(size_t string_size) {// 如果字符串長度小于 2^5,則應當使用類型為 sdshr5 的結構體作為 sds 數據結構if (string_size < 1<<5)return SDS_TYPE_5;// 如果字符串長度小于 2^8,則應當使用類型為 sdshr8 的結構體作為 sds 數據結構if (string_size < 1<<8)return SDS_TYPE_8;// 如果字符串長度小于 2^8,則應當使用類型為 sdshr16 的結構體作為 sds 數據結構if (string_size < 1<<16)return SDS_TYPE_16;// 條件編譯,會根據操作系統架構進行動態調整代碼
#if (LONG_MAX == LLONG_MAX)if (string_size < 1ll<<32)return SDS_TYPE_32;return SDS_TYPE_64;
#elsereturn SDS_TYPE_32;
#endif
}
🍀 sdsHdrSize() 函數
作用:根據類型標識,獲取對應類型的結構體(sdshdr5、sdshdr8、sdshdr16…)的占用字節大小。
/* 根據類型標識 type,獲取對應類型的結構體(sdshdr5、sdshdr8、sdshdr16...)的占用字節大小。 */
static inline int sdsHdrSize(char type) {switch(type&SDS_TYPE_MASK) {case SDS_TYPE_5:return sizeof(struct sdshdr5);case SDS_TYPE_8:return sizeof(struct sdshdr8);case SDS_TYPE_16:return sizeof(struct sdshdr16);case SDS_TYPE_32:return sizeof(struct sdshdr32);case SDS_TYPE_64:return sizeof(struct sdshdr64);}return 0;
}
內存分配 - malloc() 函數
Redis 選擇了使用 jemalloc 作為其默認的內存分配器,因此我們這里關注 jemalloc 對 malloc()
函數的實現。
整個的內存分配,大致做了三件事情:
- 大小類別的計算
- 選擇合適的 bin
- 實際內存塊分配
void *je_malloc(size_t size) {// .../* * 1.大小類別的計算* 將請求的字節大小 size 轉為索引,該索引用于定位負責處理特定大小內存塊的 bin */szind_t ind = sz_size2index_lookup(size);// .../* * 2.選擇合適的 bin* 從 tcache 中獲取對應大小類別的緩存 bin */cache_bin_t *bin = tcache_small_bin_get(tcache, ind);bool tcache_success;/* * 3.實際內存塊分配* 嘗試從 bin 中分配內存,如果成功則設置 tcache_success 為 true,并返回分配的連續內存的起始地址 */void* ret = cache_bin_alloc_easy(bin, &tcache_success);/* 如果分配成功 */if (tcache_success) {// .../* 返回分配的連續內存的起始地址 */return ret;}/* 如果上述過程未能成功分配內存,則使用默認的內存分配方法 */return malloc_default(size);
}
🍀 大小類別的計算
將請求的字節大小 size 轉為索引,該索引用于定位負責處理特定大小內存塊的 bin。實際上,該索引不僅可以定位到 tcache_t
中對應的 cache_bin_t
實例,還可以得到請求字節大小對應的實際 jemalloc 應該分配的內存塊大小,這個實際分配內存塊大小等于 sz_index2size_tab[ind]
。
#define SC_LG_TINY_MIN 3szind_t sz_size2index_lookup(size_t size) {assert(size <= SC_LOOKUP_MAXCLASS);/* * 1.根據 size 計算 sz_size2index_tab 映射表索引:(size + (ZU(1) << SC_LG_TINY_MIN) - 1) >> SC_LG_TINY_MIN* 2.從 sz_size2index_tab 映射表獲取定位 bin 的索引:sz_size2index_tab[(size + (ZU(1) << SC_LG_TINY_MIN) - 1) >> SC_LG_TINY_MIN] */szind_t ret = (sz_size2index_tab[(size + (ZU(1) << SC_LG_TINY_MIN) - 1)>> SC_LG_TINY_MIN]);assert(ret == sz_size2index_compute(size));/* 返回存儲的 sz_index2size_tab 數組索引 */return ret;
}
這里 jemalloc 實際維護了兩張映射表:
-
sz_size2index_tab
:維護了從「請求字節大小」到「索引」的映射。uint8_t sz_size2index_tab[(SC_LOOKUP_MAXCLASS >> SC_LG_TINY_MIN) + 1];/* 以下初始化映射表代碼不必做了解 */ static void sz_boot_size2index_tab(const sc_data_t *sc_data) {size_t dst_max = (SC_LOOKUP_MAXCLASS >> SC_LG_TINY_MIN) + 1;size_t dst_ind = 0;for (unsigned sc_ind = 0; sc_ind < SC_NSIZES && dst_ind < dst_max;sc_ind++) {const sc_t *sc = &sc_data->sc[sc_ind];size_t sz = (ZU(1) << sc->lg_base)+ (ZU(sc->ndelta) << sc->lg_delta);size_t max_ind = ((sz + (ZU(1) << SC_LG_TINY_MIN) - 1)>> SC_LG_TINY_MIN);for (; dst_ind <= max_ind && dst_ind < dst_max; dst_ind++) {sz_size2index_tab[dst_ind] = sc_ind;}} }
數組索引(index) 存儲的 sz_index2size_tab 數組索引(value) 0 0 1 0 2 1 3 2 4 3 5 4 6 5 7 6 8 7 9 8 … … -
sz_index2size_tab
:維護了從「索引」到「jemalloc 應該分配的內存塊大小」的映射。size_t sz_index2size_tab[SC_NSIZES];/* 以下初始化映射表代碼不必做了解 */ static void sz_boot_index2size_tab(const sc_data_t *sc_data) {for (unsigned i = 0; i < SC_NSIZES; i++) {const sc_t *sc = &sc_data->sc[i];sz_index2size_tab[i] = (ZU(1) << sc->lg_base)+ (ZU(sc->ndelta) << (sc->lg_delta));} }
數組索引(index) 存儲的內存塊大小(value) 0 8 1 16 2 24 3 32 4 40 5 48 6 56 7 64 8 80 9 96 10 112 11 128 12 160 13 192 14 224 … … 233 6917529027641081856 234 8070450532247928832
📑 例如:
- 如果請求字節大小 size = 8,那么通過
sz_size2index_lookup()
計算得到的存儲的sz_index2size_tab
數組索引為 0,可得sz_index2size_tab[0]
的值為 8。也就是說,如果請求字節大小為 8,那么 jemalloc 會為其分配 8 字節的連續內存塊。 - 如果請求字節大小 size = 9,那么通過
sz_size2index_lookup()
計算得到的存儲的sz_index2size_tab
數組索引為 1,可得sz_index2size_tab[1]
的值為 16。也就是說,如果請求字節大小為 9,那么 jemalloc 會為其分配 16 字節的連續內存塊。
🍀 選擇合適的 bin
當應用程序請求分配某個大小的對象時,jemalloc 會計算出最接近且不小于該大小的類別索引,然后使用這個索引來訪問 tcache_t
中對應的 cache_bin_t
進行分配。每個 cache_bin_t
實例專用于一個預定義的大小類別,從而實現了對多種不同大小內存塊的支持。
也就是在源碼中,有大概這樣的邏輯:
szind_t ind = sz_size2index(size); // 獲取大小類別的索引
cache_bin_t *bin = &tcache->bins_small[ind]; // 獲取對應的 bin,以 cache_bin_t bins_small[39] 數組為例
tcache 是什么呢?
typedef struct tcache_s tcache_t;struct tcache_s {// .../** 小對象的緩存 bin 數組* 每個索引 i 位置的 bin 每次分配的內存塊大小與 sz_index2size_tab 映射表中索引 i 位置存儲的 jemalloc 應該分配的內存塊大小相同*/cache_bin_t bins_small[SC_NBINS];/** 大對象的緩存 bin 數組* 每個索引 i 位置的 bin 每次分配的內存塊大小與 sz_index2size_tab 映射表中索引 i+SC_NBINS 位置存儲的 jemalloc 應該分配的內存塊大小相同*/cache_bin_t bins_large[SC_NSIZES-SC_NBINS];
};
🍀 實際內存塊分配
首先需要了解 cache_bin_t
結構體:
typedef struct cache_bin_s cache_bin_t;
typedef int32_t cache_bin_sz_t;struct cache_bin_s {cache_bin_sz_t low_water;cache_bin_sz_t ncached;cache_bin_stats_t tstats;void **avail;
};
avail
:這是一個二級指針,存儲了一個指針數組的末端邊界地址。指針數組是用于存儲一組指向可用內存塊的指針。指針數組可看做是一個棧結構,從棧頂 -> 棧底,對應指針數組的首地址 -> 末端邊界地址(低地址 -> 高地址),avail 二級指針指向的地址即棧底。ncached
:記錄當前 bin 中有多少個可用的內存塊,每次成功分配時減一,回收時加一。它也是指針數組的元素個數,即可用內存塊數量。
avail[-ncached, ..., -1]
是可用內存塊的指針,其中最低地址的對象將最先被分配出去。也就是說,當進行內存分配時,ncached--
,并將棧頂元素 *(avail - (ncached + 1))
彈出。源碼如下:
/* 使用 bin 實例分配對應大小的內存塊,返回分配的內存塊的首地址 */
void *cache_bin_alloc_easy(cache_bin_t *bin, bool *success) {void *ret;/* 更新緩存計數器,因為我們準備分配一個塊 */bin->ncached--;/* 檢查 bin 中是否有可用的緩存塊 */if (unlikely(bin->ncached <= bin->low_water)) {bin->low_water = bin->ncached;if (bin->ncached == -1) {bin->ncached = 0;*success = false;return NULL;}}/* 分配成功 */*success = true;/* * 從 bin 的 avail 棧頂彈出一個內存塊地址。* 注意這里的減法操作是因為 avail 指向的是棧底,而 ncached 表示棧中的元素數量。* 這樣可以確保我們總是從棧頂獲取最新的可用塊。*/ret = *(bin->avail - (bin->ncached + 1));/* 返回分配的內存塊地址 */return ret;
}
set [key] [value]
set [key] [value]
命令對應的處理函數為 setCommand
。
以在命令執行前,db 中不存在該 key 為例,setCommand()
函數會調用到核心處理函數 dbAdd()
。
/* 將 key-value 添加到 db 中 */
void dbAdd(redisDb *db, robj *key, robj *val) {// sds 是 char* 的別名,通過 sdsdup() 函數得到的實際是表示 key 的 sds 結構體 buf 字符數組首元素地址sds copy = sdsdup(key->ptr);// 1.在堆中開辟 dictEntry 結構體對象空間// 2.將 dictEntry 存儲在 db 字典中// 3.將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中// 4.返回 dictEntry 結構體指針dictEntry *de = dictAddRaw(db->dict, copy, NULL);serverAssertWithInfo(NULL, key, de != NULL);// 將 value 設置到 dictEntry 結構體對象 dictSetVal(db->dict, de, val);signalKeyAsReady(db, key, val->type);if (server.cluster_enabled) slotToKeyAddEntry(de, db);notifyKeyspaceEvent(NOTIFY_NEW,"new",key,db->id);
}
我們重點關注源碼中以下三個函數的作用:
sdsdup(key->ptr)
:拷貝 sds,并返回字符數組的首元素地址。dictAddRaw(db->dict, copy, NULL)
:- 在堆中開辟 dictEntry 結構體對象空間;
- 將 dictEntry 存儲在 db 字典中;
- 將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中;
- 返回 dictEntry 結構體指針。
dictSetVal(db->dict, de, val)
:將 value 設置到 dictEntry 結構體對象
🍀 sdsdup() 函數
作用:拷貝 sds,并返回字符數組的首元素地址。
sds sdsdup(const sds s) {/* sdslen(s) 返回字符串的有效長度 */return sdsnewlen(s, sdslen(s));
}
sds sdsnewlen(const void *init, size_t initlen) {return _sdsnewlen(init, initlen, 0);
}
sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {void *sh;sds s;/* 根據初始化長度獲取至少應該使用的 sds 數據結構類型的標識 */char type = sdsReqType(initlen);/* 空字符串通常為拼接而創建的,因此使用 sdshdr8 作為 sds 數據結構比 sdshdr5 更加合適 */if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;/* 根據類型標識,獲取對應類型的結構體(sdshdr5、sdshdr8、sdshdr16...)的占用字節大小 *//* 由于實際存儲字符串的 char buf[] 是結構體最后一個成員,因此這是一個柔性數組,占用字節不會計算在使用 sizeof() 得到的結構體占用字節范圍內 */int hdrlen = sdsHdrSize(type);/* 指向 sds 類型 —— flags 變量的指針 */unsigned char *fp;size_t usable;/* 檢查 size_t 溢出 */assert(initlen + hdrlen + 1 > initlen);/* 為 sds 數據結構分配堆內存,并將 jemalloc 實際分配的字節大小記錄在 usable 中 *//* 請求字節大小為 hdrlen+initlen+1 = sds 結構體大小 + 字符串有效長度 + 結束字符 '\0' 的 1 個字節 */sh = trymalloc?s_trymalloc_usable(hdrlen+initlen+1, &usable) :s_malloc_usable(hdrlen+initlen+1, &usable);// .../* 將預期字符數組的起始地址存儲到 char* s 中 */s = (char*)sh+hdrlen;/* 將 flags 變量的地址存儲到 unsigned char *fp 中 */fp = ((unsigned char*)s)-1;/* 獲取柔性數組 char buf[] 可用字節大小 *//* usable = 總共分配的堆內存字節大小 - sizeof(sds 結構體) - 結束標識 '\0' 占 1 個字節 */ usable = usable-hdrlen-1;if (usable > sdsTypeMaxSize(type))usable = sdsTypeMaxSize(type);/* 根據類型標識,對 sds 類型的實現數據類型結構體進行屬性配置 */switch(type) {case SDS_TYPE_5: {*fp = type | (initlen << SDS_TYPE_BITS); /* 低 3 bit 表示 sds 類型,高 5 bit 表示字符串有效長度 */break;}case SDS_TYPE_8: {SDS_HDR_VAR(8,s); /* SDS_HDR_VAR 是一個宏函數,作用是將 sh 指針指向 sds 結構體的起始地址,以操作結構體 */sh->len = initlen; /* 設置字符串有效長度 */sh->alloc = usable; /* 設置為 buf 字符數組分配了的字節大小 */*fp = type; /* 設置 sds 類型 */break;}case SDS_TYPE_16: {SDS_HDR_VAR(16,s);sh->len = initlen;sh->alloc = usable;*fp = type;break;}case SDS_TYPE_32: {SDS_HDR_VAR(32,s);sh->len = initlen;sh->alloc = usable;*fp = type;break;}case SDS_TYPE_64: {SDS_HDR_VAR(64,s);sh->len = initlen;sh->alloc = usable;*fp = type;break;}}/* 字符數組拷貝 */if (initlen && init)memcpy(s, init, initlen);/* 為了兼容 c 標準字符串函數,以 '\0' 作為字符數組結束標識 */s[initlen] = '\0';/* 返回字符數組 buf 的起始地址 */return s;
}
對應的:
-
key = “aaaaaa”,字節數
initlen
為 6,由于字節數 < 32,因此使用sdshdr5
作為 key 字符串的數據結構,則hdrlen = sizeof(struct sdshdr5) = 1
,因此調用s_malloc_usable()
函數時請求字節大小為hdrlen+initlen+1 = 1+6+1 = 8
,則sz_size2index_lookup()
函數計算得到的存儲的sz_index2size_tab
數組索引為 0,而sz_index2size_tab[0]
的值為 8,即實際分配內存塊大小*usable = 8
。在配置sdshdr5
實例屬性時,設置alloc = usable - hdrlen - 1 = 6
。 -
key = “aaaaaaa”,字節數
initlen
為 7,由于字節數 < 32,因此使用sdshdr5
作為 key 字符串的數據結構,則hdrlen = sizeof(struct sdshdr5) = 1
,因此調用s_malloc_usable()
函數時請求字節大小為hdrlen+initlen+1 = 1+7+1 = 9
,則sz_size2index_lookup()
函數計算得到的存儲的sz_index2size_tab
數組索引為 1,而sz_index2size_tab[0]
的值為 16,即實際分配內存塊大小*usable = 16
。在配置sdshdr5
實例屬性時,設置alloc = usable - hdrlen - 1 = 14
。也就是說,Redis 會將多分配的 7 字節作為字符數組 buf 的空間使用。
🍀 dictAddRaw() 函數
作用:
- 在堆中開辟 dictEntry 結構體對象空間;
- 將 dictEntry 存儲在 db 字典中;
- 將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中;
- 返回 dictEntry 結構體指針。
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{long index;dictEntry *entry;int htidx;if (dictIsRehashing(d)) _dictRehashStep(d);/* 計算 key 在 dict 哈希字典中的索引 */if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)return NULL;/* 由于可能的擴容,因此存在兩個 dict,需要判斷使用使用哪一個 */htidx = dictIsRehashing(d) ? 1 : 0;/* 字典元數據大小,在單機模式下,默認為 0 */size_t metasize = dictMetadataSize(d);/* 在堆中開辟 dictEntry 結構體對象空間 */entry = zmalloc(sizeof(*entry) + metasize);/* 如果有字典元數據,則將 (&entry)->metadata 的 metasize 個字節初始化為 0 */if (metasize > 0) {memset(dictMetadata(entry), 0, metasize);}/* 采用頭插法,并以鏈表形式,將 dictEntry 存儲到字典索引位置 */entry->next = d->ht_table[htidx][index];d->ht_table[htidx][index] = entry;/* key 計數 +1 */d->ht_used[htidx]++;/* dictSetKey 是一個宏,會替換為 entry->key = key 即將 key 字符數組的首元素地址存儲在 dictEntry 結構體的 void *key 指針中 */dictSetKey(d, entry, key);/* 返回 dictEntry 結構體指針 */return entry;
}
對應的:
-
key = “aaaaaa”,由于
sizeof(struct dictEntry)
為 24,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 2,而sz_index2size_tab[2]
的值為 24,即實際分配內存塊大小為 24。 -
key = “aaaaaaa”,由于
sizeof(struct dictEntry)
為 24,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 2,而sz_index2size_tab[2]
的值為 24,即實際分配內存塊大小為 24。
🍀 dictSetVal() 函數
作用:將 value 設置到 dictEntry 結構體對象。這實際是一個宏函數,在預編譯時期完成替換。
#define dictSetVal(d, entry, _val_) do { \if ((d)->type->valDup) \(entry)->v.val = (d)->type->valDup((d), _val_); \else \(entry)->v.val = (_val_); \
} while(0)
這一步很簡單,就是設置 dictEntry -> v.val
指針指向。但我們要重點關注的是 dictEntry -> v.val
指針或者說 robj *val
指針指向的結構體信息,因為這個結構體是 value 的實際內存存儲與占用內容。
這里只看 value = “12345678” 的源碼部分。由于 “12345678” 可用整型表示,為了節約內存,Redis 會使用 OBJ_ENCODING_INT
編碼來進行優化。
// 返回 value 對應的 redisObject 結構體的指針
robj *createStringObjectFromLongLongWithOptions(long long value, int valueobj) {robj *o;// ...if (value >= LONG_MIN && value <= LONG_MAX) {// 創建一個 type = OBJ_STRING 的 redisObject 結構體,sizeof(struct redisObject) 為 16 字節o = createObject(OBJ_STRING, NULL);// 設置編碼為 OBJ_ENCODING_INTo->encoding = OBJ_ENCODING_INT;// 復用指針變量,節省內存,把 12345678 當做地址存儲。在 get 時,會根據 encoding 再從 ptr 取出值o->ptr = (void*)((long)value);}// ...return o;
}
對應的:
-
key = “aaaaaa”,value = “12345678”,由于
sizeof(struct redisObject)
為 16,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 1,而sz_index2size_tab[1]
的值為 16,即實際分配內存塊大小為 16。 -
key = “aaaaaaa”,value = “12345678”,由于
sizeof(struct redisObject)
為 16,因此sz_size2index_lookup()
函數計算得到的存儲的 sz_index2size_tab 數組索引為 1,而sz_index2size_tab[1]
的值為 16,即實際分配內存塊大小為 16。
memory usage [key]
memory usage [key]
命令對應的處理函數為 memoryCommand
。
void memoryCommand(client *c) {// ...// 1.計算 value 的字節數size_t usage = objectComputeSize(c->argv[2],dictGetVal(de),samples,c->db->id);// 2.計算 key 的字節數usage += sdsZmallocSize(dictGetKey(de));// 3.計算鍵值對結構體 dictEntry 的字節數usage += sizeof(dictEntry);// 4.計算所在 db 庫的字典元數據的字節數usage += dictMetadataSize(c->db->dict);// ...
}
usage
變量即是用于存儲鍵值對的內存使用字節數。可以看到,共有四個部分組成:
objectComputeSize(c->argv[2],dictGetVal(de),samples,c->db->id)
:計算 value 的字節數。sdsZmallocSize(dictGetKey(de))
:計算 key 的字節數。sizeof(dictEntry)
:計算鍵值對結構體 dictEntry 的字節數。dictMetadataSize(c->db->dict)
:計算所在 db 庫的字典元數據的字節數
🍀 計算 value 的字節數
size_t objectComputeSize(robj *key, robj *o, size_t sample_size, int dbid) {sds ele, ele2;dict *d;dictIterator *di;struct dictEntry *de;size_t asize = 0, elesize = 0, samples = 0;if (o->type == OBJ_STRING) {if(o->encoding == OBJ_ENCODING_INT) { // 執行第 1 個 if 中的語句// sizeof(struct redisObject) = 16 bytesasize = sizeof(*o);} else if(o->encoding == OBJ_ENCODING_RAW) {asize = sdsZmallocSize(o->ptr)+sizeof(*o);} else if(o->encoding == OBJ_ENCODING_EMBSTR) {asize = zmalloc_size((void *)o);} else {serverPanic("Unknown string encoding");}} else if (o->type == OBJ_LIST) {// ...} else if (o->type == OBJ_SET) {// ...} else if (o->type == OBJ_ZSET) {// ...} else if (o->type == OBJ_HASH) {// ...} else if (o->type == OBJ_STREAM) {// ...} else if (o->type == OBJ_MODULE) {// ...} else {serverPanic("Unknown object type");}return asize;
}
對應的:
- key = “aaaaaa”,value = “12345678”,存儲時
redisObject.type = OBJ_STRING
且redisObject.encoding = OBJ_ENCODING_INT
,則objectComputeSize()
函數返回結果為sizeof(struct redisObject)
即 16。 - key = “aaaaaaa”,value = “12345678”,存儲時
redisObject.type = OBJ_STRING
且redisObject.encoding = OBJ_ENCODING_INT
,則objectComputeSize()
函數返回結果為sizeof(struct redisObject)
即 16。
🍀 計算 key 的字節數
size_t sdsZmallocSize(sds s) {// sds s 是 sds 結構體的 char buf[] 數組首元素地址,這里根據 s 獲取 sds 結構體首地址void *sh = sdsAllocPtr(s);// jemalloc 根據首地址獲取分配的連續內存塊字節大小return zmalloc_size(sh);
}void *sdsAllocPtr(sds s) {// s 為 char buf[] 首元素地址// s[-1] 獲取 type 成員地址,sdsHdrSize(s[-1]) 則是根據 type 獲取 sds 結構體占用字節// 兩者相減,就可以得到 sds 結構體首元素地址了return (void*) (s-sdsHdrSize(s[-1]));
}
對應的:
- key = “aaaaaa”,通過之前對
sdsdup()
函數的分析,可得 jemalloc 實際為 key 分配了 8 字節的連續內存。 - key = “aaaaaaa”,通過之前對
sdsdup()
函數的分析,可得 jemalloc 實際為 key 分配了 16 字節的連續內存。
🍀 計算鍵值對結構體 dictEntry 的字節數
typedef struct dictEntry {void *key;union {void *val;uint64_t u64;int64_t s64;double d;} v;struct dictEntry *next;void *metadata[];
} dictEntry;
占用字節分析:
- *void key:8 字節。
- union v:聯合體,8 字節。
- *struct dictEntry next:8 字節。
- void *metadata[]:柔性數組,不參與
sizeof(struct dictEntry)
計算。
綜上,sizeof(struct dictEntry)
的結果為 24 字節。
🍀 計算所在 db 庫的字典元數據的字節數
/** 返回 db 字典條目元數據的大小(以字節為單位)。* 在集群模式下,元數據用于構造屬于同一集群槽的 dict 條目的雙向鏈表。 */
size_t dictEntryMetadataSize(dict *d) {UNUSED(d);return server.cluster_enabled ? sizeof(clusterDictEntryMetadata) : 0;
}
在單機環境下,默認該函數的返回值為 0。
🍀 小結
綜上對每個函數的分析,以及 set 時的具體實現,我們得出:
類型 | set aaaaaa 12345678 | set aaaaaaa 12345678 |
---|---|---|
計算 value 的字節數 | 16 | 16 |
計算 key 的字節數 | 8 | 16 |
計算鍵值對結構體 dictEntry 的字節數 | 24 | 24 |
計算所在 db 庫的字典元數據的字節數 | 0 | 0 |
字節總和 | 48 | 56 |
總結
造成差異的原因
通過上面對源碼的分析,其實我們就可以知道 memory usage [key]
分析得到的內存使用情況為什么會有差異了。
首先需要說明的是,Redis 6.0 與 Redis 7.0 都為 key = "aaaaaaa"
都請求了 9 字節的內存字節大小,但 jemalloc 實際都分配了 16 字節的連續內存塊,但是對于多出來的 7 字節卻持有不同的態度。
- Redis 6.0 中,不會將多分配的 7 字節作為 sds 結構體中的字符數組 buf 的空間使用,即會設置成員
alloc = initlen = 7
。 - Redis 7.0 中,會將多分配的 7 字節作為 sds 結構體中的字符數組 buf 的空間使用,即會設置成員
alloc = usable - hdrlen - 1 = 14
。
對應的在使用 memory usage [key]
計算內存占用時:
- Redis 6.0 中,key 的字節數 =
sdsHdrSize(s[-1]) + alloc + 1
= sds 結構體占用字節 + 為字符數組 buf 開辟了的內存大小 + 結束字符 ‘\0’ 1 個字節,即 9 個字節。 - Redis 7.0 中,key 的字節數 = jemalloc 為 key 實際分配的連續內存塊大小,即 16 個字節。
從這里我們可以看出,Redis 7.0 相較于 Redis 6.0,對于 jemalloc 實際分配的額外內存空間,進行了優化利用。
memory usage [key] 計算內存使用小結
Redis 6.0:
類型 | set aaaaaa 12345678 | set aaaaaaa 12345678 |
---|---|---|
計算 value 的字節數 | 16 | 16 |
計算 key 的字節數 | 8 | 9 |
計算鍵值對結構體 dictEntry 的字節數 | 24 | 24 |
字節總和 | 48 | 49 |
Redis 7.0:
類型 | set aaaaaa 12345678 | set aaaaaaa 12345678 |
---|---|---|
計算 value 的字節數 | 16 | 16 |
計算 key 的字節數 | 8 | 16 |
計算鍵值對結構體 dictEntry 的字節數 | 24 | 24 |
計算所在 db 庫的字典元數據的字節數 | 0 | 0 |
字節總和 | 48 | 56 |
感悟
最后,通過本文對源碼的分析,我們可以認識到:
- Redis 使用 jemalloc 作為默認的內存分配器,這使得它能夠更有效地管理內存分配。jemalloc 會根據請求的大小選擇最合適的內存塊,從而減少內部碎片并提高分配效率。
- 對于簡單的數值型字符串,如果它們可以被表示為長整數(long),Redis 會選擇使用
OBJ_ENCODING_INT
編碼來節省空間。這種方式不僅減少了內存占用,而且加快了數據訪問速度。 - 在設計數據結構時,考慮到字節對齊規則,以確保最佳性能,在本文分析中,在計算字節時并沒有提到結構體字節對齊,這是因為 Redis 對數據結構的巧妙設計使得無需進行字節填充。此外,柔性數組用于 sds 結構體中,允許動態增長字符緩沖區而不增加額外的指針開銷。