Redis的String型別,原來這麼佔記憶體

楊同學technotes發表於2023-01-10

Redis的String型別,原來這麼佔記憶體

存一個 Long 型別這麼佔記憶體,Redis 的記憶體開銷都花在哪兒了?

1、場景介紹

假設現在我們要開發一個圖片儲存系統,要求這個系統能夠根據圖片 ID 快速查詢到圖片儲存物件 ID。圖片 ID 和圖片儲存物件 ID 的樣例資料如下:

photo_id: 1101000060
photo_obj_id: 3302000080

在這種場景下,圖片 ID 和圖片儲存物件 ID 剛好是一對一的關係,是典型的“鍵 - 單值”模式,Redis 的 String 型別提供了“一個鍵對應一個值的資料”的儲存形式,在這種場景下剛好適用。

確定使用 String 型別後,接下來我們透過實戰,來看看它的記憶體使用情況。首先透過下面命令連線上 Redis。

本文我使用的 Redis Server 及下文原始碼都是 6.2.4 版本。
redis-cli -h 127.0.0.1 -p 6379

然後執行下面的命令檢視 Redis 的初始記憶體使用情況。

127.0.0.1:6379> info memory
# Memory
used_memory:871840

接著插入 10 條資料:

10.118.32.170:0> set 1101000060 3302000080
10.118.32.170:0> set 1101000061 3302000081
10.118.32.170:0> set 1101000062 3302000082
10.118.32.170:0> set 1101000063 3302000083
10.118.32.170:0> set 1101000064 3302000084
10.118.32.170:0> set 1101000065 3302000085
10.118.32.170:0> set 1101000066 3302000086
10.118.32.170:0> set 1101000067 3302000087
10.118.32.170:0> set 1101000068 3302000088
10.118.32.170:0> set 1101000069 3302000089

再次檢視記憶體:

127.0.0.1:6379> info memory
# Memory
used_memory:872528

可以看到,儲存 10 個圖片,記憶體使用了 688 個位元組。一個圖片 ID 和圖片儲存物件 ID 的記錄平均用了 68 位元組。

但問題是,一組圖片 ID 及其儲存物件 ID 的記錄,實際只需要 16 位元組就可以了。圖片 ID 和圖片儲存物件 ID 都是 10 位數,而 8 位元組的 Long 型別最大可以表示 2 的 64 次方的數值,肯定可以表示 10 位數。這樣算下來只需 16 位元組就可以了,為什麼 String 型別卻用了 68 位元組呢?

為了一探究竟,我們不得不從 String 型別的底層實現扒起。

2、String 型別的底層實現

當你儲存的資料中包含字元時,String 型別就會用簡單動態字串(Simple Dynamic String,SDS)結構體來儲存。

2.1 SDS

SDS 的結構定義在sds.h檔案中,在 Redis 3.2 版本之後,SDS 由一種資料結構變成了 5 種資料結構。

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) hisdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

這 5 種資料結構依次儲存不同長度的內容,Redis 會根據 SDS 儲存的內容長度來選擇不同的結構。

  • sdshdr5:儲存大小為 32 位元組(2 的 5 次方),只被應用在了 Redis 中的 key 中。
  • sdshdr8:儲存大小為 256 位元組(2 的 8 次方)。
  • sdshdr16:儲存大小為 64KB(2 的 16 次方)。
  • sdshdr32:儲存大小為 4GB(2 的 32 次方)。
  • sdshdr64:儲存大小為 2 的 64 次方位元組。

以 sdshdr8 為例。

  • buf:位元組陣列,儲存實際資料。為了表示位元組陣列的結束,Redis 會自動在陣列最後加一個'\0',這就會額外佔用 1 個位元組的開銷。
  • len:佔 4 個位元組,表示 buf 的已用長度,不包括'\0'
  • alloc:也佔 4 個位元組,表示 buf 的實際分配長度,不包括'\0'
  • flags:佔 1 個位元組,標記當前位元組陣列的屬性,是sdshdr8還是sdshdr16等。(flags 值的定義可以看下面程式碼)

在原始碼sds.h中,flags 值定義如下:

#define HI_SDS_TYPE_5  0 
#define HI_SDS_TYPE_8  1
#define HI_SDS_TYPE_16 2
#define HI_SDS_TYPE_32 3
#define HI_SDS_TYPE_64 4

2.2 RedisObject

因為 Redis 的資料型別有很多,而且,不同資料型別都有些相同的後設資料要記錄,所以,值物件並不是直接儲存,而是被包裝成redisObject物件,它的定義如下。

typedef struct redisObject {
    unsigned type:4;//物件型別(4位=0.5位元組)
    unsigned encoding:4;//編碼(4位=0.5位元組)
    unsigned lru:LRU_BITS;//記錄物件最後一次被應用程式訪問的時間(24位=3位元組)
    int refcount;//引用計數。等於0時表示可以被垃圾回收(32位=4位元組)
    void *ptr;//指向底層實際的資料儲存結構,如:sds等(8位元組)
} robj;

下面可以幫助我們理解:

為了節省記憶體空間,Redis 還做了一些最佳化。

當儲存的是 Long 型別整數時,RedisObject 中的指標就直接賦值為整數資料了,這樣就不用額外的指標再指向整數了。這種儲存方式通常也叫作 int 編碼方式。

當儲存的是字串資料,並且字串小於等於 44 位元組時,RedisObject 中的後設資料、指標和 SDS 是一塊連續的記憶體區域,這樣就可以避免記憶體碎片。這種佈局方式也被稱為 embstr 編碼方式。

當字串大於 44 位元組時,SDS 的資料量就開始變多了,Redis 就不再把 SDS 和 RedisObject 佈局在一起了,而是會給 SDS 分配獨立的空間,並用指標指向 SDS 結構。這種佈局方式被稱為 raw 編碼模式。

使用 OBJECT ENCODING 命令可以檢視一個資料庫鍵的值物件的編碼:

127.0.0.1:6379> SET msg "hello world"
OK
127.0.0.1:6379> OBJECT ENCODING msg
"embstr"
127.0.0.1:6379> SET story "long long long ago..."
OK
127.0.0.1:6379> OBJECT ENCODING story
"raw"
127.0.0.1:6379> SADD numbers 1 3 5
(integer) 3
127.0.0.1:6379> OBJECT ENCODING numbers
"intset"
127.0.0.1:6379> SADD numbers "seven"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING numbers
"hashtable"
注意這個命令SET story "long long long ago...",省略號指的是省略了很多字元。

知道了 SDS 和 RedisObject 額外後設資料開銷,現在,我們就可以計算 String 型別的記憶體使用量了。

圖片儲存物件 ID 是 Long 型別整數,所以可以直接用 int 編碼的 RedisObject 儲存。每個 int 編碼的 RedisObject 後設資料部分佔 8 位元組,指標部分被直接賦值為 8 位元組的整數了。圖片 ID 使用 sdshdr5 資料結構來儲存,會為 10 位的圖片 ID 分配 16 個位元組,結束符 '\0' 佔 1 個位元組。

共佔用 34 個位元組。與上文所說的一個圖片 ID 和圖片儲存物件 ID 的記錄平均用了 68 位元組相差有點大啊,另外的開銷去哪兒了?

2.3 全域性雜湊表

為了實現從鍵到值的快速訪問,Redis 使用了一個雜湊表來儲存所有鍵值對。因為這個雜湊表儲存了所有的鍵值對,所以,也稱為全域性雜湊表。雜湊表的每一項是一個 dictEntry 的結構體,用來指向一個鍵值對。dictEntry 結構中有三個 8 位元組的指標,分別指向 key、value 以及下一個 dictEntry,三個指標共 24 位元組,如下圖所示:

jemalloc 在分配記憶體時,會分配一個最接近 2 的 N 次方的數值。舉個例子。如果你申請 6 位元組空間,jemalloc 實際會分配 2 的 4 次方即 8 位元組空間;如果你申請 24 位元組空間,jemalloc 則會分配 32 位元組。

最終我們分析出來的記憶體開銷,為 66 位元組,比較接近上文場景中的平均值 68 了。

最後

既然 String 型別這麼佔記憶體,那麼你有好的方案來節省記憶體嗎?

這篇文章內容我準備了一週,如果對你有幫助,可以點個「在看」嗎?你的點贊會讓作者興奮得一晚上睡不著覺。

對後面的內容感興趣,也可以關注公眾號「楊同學technotes」,感謝支援!

參考資料

相關文章