# 11 萬金油的String,為什麼不好用了? 先跟你分享一個我曾經遇到的需求。 當時,我們要開發一個圖片存儲系統,要求這個系統能快速地記錄圖片 ID 和圖片在存儲系統中保存時的 ID(可以直接叫作圖片存儲對象 ID)。同時,還要能夠根據圖片 ID 快速查找到圖片存儲對象 ID。 photo_id: 1101000051 photo_obj_id: 3301000051 127.0.0.1:6379> set 1101000051 3301000051 我們保存了 1 億張圖片,大約用了 6.4GB 的內存。但是,隨著圖片數據量的不斷增加,我們的 Redis 內存使用量也在增加,結果就遇到了大內存 Redis 實例因為生成 RDB 而響應變慢的問題 ## 為什麼 String 類型內存開銷大? 我們保存了 1 億張圖片的信息,用了約 6.4GB 的內存,一個圖片 ID 和圖片存儲對象 ID 的記錄平均用了 64 字節。但問題是,一組圖片 ID 及其存儲對象 ID 的記錄,實際只需要 16 字節就可以了。 其實,除了記錄實際數據,String 類型還需要額外的內存空間記錄數據長度、空間使用等信息,這些信息也叫作元數據。當實際保存的數據較小時,元數據的空間開銷就顯得比較大了 ## String 類型具體是怎麼保存數據的呢? ### Simple Dynamic String,SDS ```c struct sdshdr { int len; // 當前字串長度(不包含結尾 \0) int alloc; // 已配置的空間(不含 header 和 \0) char buf[]; // 真正儲存字串的資料區 } ``` | 類型 | 對應欄位大小 | 最大支援長度 | 用途說明 | | ---------- | ------------------------------------- | ------------- | ---------------------- | | `sdshdr5` | 無 `len`/`alloc`(長度存在 flags 裡) | 31 Bytes 以下 | 超小字串(節省記憶體) | | `sdshdr8` | `len`: 1 byte <br>`alloc`: 1 byte | 255 | 常見短字串 | | `sdshdr16` | `len`: 2 bytes <br>`alloc`: 2 bytes | 65,535 | 中等字串 | | `sdshdr32` | `len`: 4 bytes <br>`alloc`: 4 bytes | 約 4GB | 長字串 | | `sdshdr64` | `len`: 8 bytes <br>`alloc`: 8 bytes | 幾乎無限制 | 超長字串 | Redis 會根據字串的實際長度自動選擇最小可用的 SDS header,不需要你手動設定。 ### Redis Object ```c typedef struct redisObject { unsigned type : 4; // 資料類型:string, list, hash, set, zset unsigned encoding : 4; // 底層資料的編碼方式 int refcount; // 引用計數(記憶體管理用) void *ptr; // 指向實際資料的指標(如 SDS、dict 等) } robj; ``` ``` ┌────────────────────┐ │ redisObject │ │--------------------│ │ type (4 bits) │ → string / list / ... │ encoding (4 bits) │ → raw / int / listpack ... │ refcount (int) │ → 記憶體計數用 │ ptr (void*) │ → 指向具體資料結構 └────────────────────┘ ``` | `type` 類型 | 可能的 `encoding` | 說明 | | ----------- | ----------------- | ------------------------------------------ | | `string` | `raw` | 一般 SDS 儲存字串(> 44 bytes) | | | `embstr` | 儲存短字串(<= 44 bytes),效能高 | | | `int` | 儲存整數字串(如 `"100"`),直接用整數表示 |  ### 計算 String 類型需要的大小  因為使用 int 編碼,所以 key 跟 value 各為 16 Bytes dictEntry 結構中有三個 8 字節的指針,分別指向 key、value 以及下一個 dictEntry,三個指針共 24 字節 (Bytes),但是 jemalloc 在分配內存時,會根據我們申請的字節數 N,找一個比 N 大,但是最接近 N 的 2 的冪次數作為分配的空間,這樣可以減少頻繁分配的次數。如果你申請 24 字節空間,jemalloc 則會分配 32 字節 32 Bytes (dictEntry) + 16 Bytes (key) + 16 Bytes (value) = 64 Bytes 有效信息只有 16 字節,使用 String 類型保存時,卻需要 64 字節的內存空間,有 48 字節都沒有用於保存實際的數據 ## 用什麼數據結構可以節省內存? Redis 有一種底層數據結構,叫壓縮列表(ziplist),這是一種非常節省內存的結構。 那我們要怎麼讓資料存在 ziplist 這個資料結構裡面? | `type` 類型 | 可能的 `encoding` | 說明 | | ----------- | ------------------- | ----------------------------- | | `hash` | `ziplist`(已淘汰) | Redis 6 以前的緊湊型 hash | | | `listpack`(新) | Redis 7 之後的 ziplist 替代品 | | | `hashtable` | 元素多或太大時使用 | 在保存單值的鍵值對時,可以採用基於 Hash 類型的二級編碼方法。這裡說的二級編碼,就是把一個單值的數據拆分成兩部分,前一部分作為 Hash 集合的 key,後一部分作為 Hash 集合的 value,這樣一來,我們就可以把單值數據保存到 Hash 集合中了。 以圖片 ID 1101000060 和圖片存儲對象 ID 3302000080 為例,我們可以把圖片 ID 的前 7 位(1101000)作為 Hash 類型的鍵,把圖片 ID 的最後 3 位(060)和圖片存儲對象 ID 分別作為 Hash 類型值中的 key 和 value。 按照這種設計方法,我在 Redis 中插入了一組圖片 ID 及其存儲對象 ID 的記錄,並且用 info 命令查看了內存開銷,我發現,增加一條記錄後,內存佔用只增加了 16 字節,如下所示: ``` 127.0.0.1:6379> info memory # Memory used_memory:1039120 127.0.0.1:6379> hset 1101000 060 3302000080 (integer) 1 127.0.0.1:6379> info memory # Memory used_memory:1039136 ``` 在使用 String 類型時,每個記錄需要消耗 64 字節,這種方式卻只用了 16 字節,所使用的內存空間是原來的 1/4,滿足了我們節省內存空間的需求。 ### 怎麼決定要用 ziplist 還是 hashtable 1. hash-max-ziplist-entries:表示用壓縮列表保存時哈希集合中的最大元素個數。 2. hash-max-ziplist-value:表示用壓縮列表保存時哈希集合中單個元素的最大長度。 如果 hash 裡面的元素個數超過 hash-max-ziplist-entries 或是單個元素最大長度大於 hash-max-ziplist-value 就會改用 hashtable 編碼。在節省內存空間方面,hashtable 就沒有 ziplist 那麼高效了。 **為了能充分使用壓縮列表的精簡內存佈局,我們一般要控制保存在 Hash 集合中的元素個數**。所以,在剛才的二級編碼中,我們只用圖片 ID 最後 3 位作為 Hash 集合的 key,也就保證了 Hash 集合的元素個數不超過 1000,同時,我們把 hash-max-ziplist-entries 設置為 1000,這樣一來,Hash 集合就可以一直使用壓縮列表來節省內存空間了。 ## Note 1. 從 Redis 7 開始,ziplist 已經被淘汰,取而代之的是新的格式叫做 listpack 2. 我自己實驗的結果跟書上講的不一樣 (Redis 7.2.7) ## 實驗 不管是用 `set` 還是 `hset` 都是使用 96 Bytes ``` $ redis-cli 127.0.0.1:6379> CONFIG GET hash-* 1) "hash-max-listpack-entries" 2) "512" 3) "hash-max-ziplist-entries" 4) "512" 5) "hash-max-listpack-value" 6) "64" 7) "hash-max-ziplist-value" 8) "64" ``` ``` $ redis-cli INFO memory | grep used_memory: => used_memory:2394464 127.0.0.1:6379> set 1101000051 3301000051 OK 127.0.0.1:6379> OBJECT ENCODING 1101000051 "int" $ redis-cli INFO memory | grep used_memory: => used_memory:2394560 $ ruby -e "puts 2394560 - 2394464" => 96 ``` ``` used_memory:2394560 127.0.0.1:6379> hset 1101000 060 3302000080 (integer) 1 127.0.0.1:6379> OBJECT ENCODING 1101000 "listpack" used_memory:2394656 $ ruby -e "puts 2394656 - 2394560" => 96 ```
×
Sign in
Email
Password
Forgot password
or
By clicking below, you agree to our
terms of service
.
Sign in via Facebook
Sign in via Twitter
Sign in via GitHub
Sign in via Dropbox
Sign in with Wallet
Wallet (
)
Connect another wallet
New to HackMD?
Sign up