Redis 實戰 —— 12. 降低記憶體佔用

滿賦諸機發表於2021-02-01

簡介

降低 Redis 的記憶體佔用有助於減少建立快照和載入快照所需的時間、提升載入 AOF 檔案和重寫 AOF 檔案時的效率、縮短從伺服器進行同步所需的時間(快照、 AOF 檔案重寫在 持久化選項 中進行了介紹,從伺服器同步在 複製、處理故障、事務及效能優化 中進行了介紹),並且能讓 Redis 儲存更多的資料而無需新增額外的硬體。 P208

短結構 (short structure) P208

Redis 為列表、集合、雜湊和有序集合提供了一組配置選項,這些選項可以讓 Redis 以更節約空間的方式儲存長度較短的結構(後面簡稱“短結構”)。 P208

在列表、雜湊和有序集合的長度較短或者體積較小的時候, Redis 可以選擇使用一種名為壓縮列表 (ziplist) 的緊湊儲存方式來儲存這些機構。壓縮列表是列表、雜湊和有序集合這 3 種不同型別的物件的一種非結構化 (unstructured) 表示:與 Redis 在通常情況下使用雙向連結串列表示列表、使用雜湊表表示雜湊、使用雜湊表加上跳錶 (skiplist) 表示有序集合的做法不同,壓縮列表會以序列化的方式儲存資料,這些序列化資料每次被讀取的時候都要進行解碼,每次被寫入的時候也要進行區域性的重新編碼,並且可能需要對記憶體裡面的資料進行移動。 P209

壓縮列表表示 P209

本節以最簡單的列表進行觀察對比。

雙向連結串列 P209

列表不進行壓縮時使用雙向連結串列 (doubly linked list) 進行儲存,連結串列的每個結點都有三個指標: P209

  • 指向前一個結點的指標
  • 指向後一個結點的指標
  • 指向結點包含的字串值的指標

其中字串值又分為三個部分: P209

  • 字串的長度
  • 字串剩餘可用的位元組數
  • 以空字元結尾的字串本身

可以發現未壓縮前,每儲存一個字串,最少需要 21 位元組的額外開銷 (overhead) 。(三個指標每個佔 4 個位元組,兩個整數每個佔 4 個位元組,字串結尾的空字元佔 1 個位元組) P209

壓縮列表 P209

壓縮列表是由結點(非真實結點)組成的序列 (sequence) ,每個結點都由兩個長度值和一個字串組成。 P209

  • 第一個長度值:前一個結點的長度,用於從後向前的遍歷(一般以一個位元組儲存)
  • 第二個長度值:當前結點的長度(一般以一個位元組儲存)
  • 字串:長度等於位元組數,沒有空字元

可以發現壓縮後,每儲存一個字串,最少需要 2 位元組的額外開銷。 P210

使用壓縮列表編碼 P210

不同結構關於使用壓縮列表的配置選項 P210

# 列表使用壓縮列表表示的限制條件
list-max-ziplist-entries 512
list-max-ziplist-value 64

# 雜湊使用壓縮列表表示的限制條件
hash-max-ziplist-entries 512
hash-max-ziplist-value 64

# 有序集合使用壓縮列表表示的限制條件
zset-max-ziplist-entries 512
zset-max-ziplist-value 64

其中, ...-entries 選項說明列表、雜湊和有序集合在被編碼為壓縮列表的情況下,允許包含的最大元素數量; ...-value 選項說明了壓縮列表每個結點的最大體積是多少位元組。當這些選項設定的限制條件中的任意一個被突破時, Redis 就會將對應的列表、雜湊和有序集合從壓縮列表編碼轉換為其他結構,而記憶體佔用也會因此增加,並且即使其將來重新滿足限制條件,也不會再轉換回壓縮列表。 P210

除錯 P210

OBJECT 命令允許從內部檢視給定 key 的 Redis 物件, 它通常用在除錯(debugging) 或者瞭解為了節省空間而對 key 使用特殊編碼的情況。當將Redis 用於進行快取時,也可以通過 OBJECT 命令中的資訊,決定 key 的驅逐策略 (eviction policies) 。

  • OBJECT REFCOUNT <key>: 返回給定 key 引用所儲存的值的次數。主要用於除錯
  • OBJECT ENCODING <key>: 返回給定 key 所儲存的值所使用的內部表示(representation)
  • OBJECT IDLETIME <key>: 返回給定 key 自儲存以來的空閒時間(idle, 沒有被讀取也沒有被寫入),以秒為單位
集合的整數集合編碼 P211

如果集合的所有成員都可以被解釋為十進位制整數(在平臺的有符號整數範圍內),並且集合成員的數量足夠少,那麼 Redis 就會以有序整數陣列的方式儲存集合,這種儲存方式又被稱為整數集合 (intset) 。整數集合不僅可以降低記憶體消耗,還可以提升所有標準集合操作的執行速度。 P211

整數集合的配置選項 P211

# 集合使用整數集合表示的限制條件
set-max-intset-entries 512

當整數集合包含當元素數量超過配置選項設定的限制時,整數集合將被轉換為雜湊表表示。 P212

長壓縮列表和大整數集合帶來的效能問題 P212
壓縮列表結點數 效能
< 1000 差別不大
5000 ~ 10000 開始下降
50000 下降明顯
> 100000 低到無法使用

推薦將壓縮列表的長度限制在 1024 個元素內,並且每個元素的體積不能超過 64 位元組,對於大多數雜湊應用來說,這種配置可以同時兼顧低記憶體佔用和高效能這兩方面優點。 P214

Redis 在 3.2 版本後的列表底層預設使用 quicklist ,這種資料結構兼顧了雙向連結串列和壓縮列表的優點,因此列表目前來說已使用最優配置。

我們在設計 Redis 時同時也要保持鍵名簡短(包括資料鍵、雜湊的域、集合和有序集合的成員以及所有列表的結點),當儲存結點的資料量達到上百萬個或者數十億個時,將能節省 MB 升至 GB 級的空間。 P214

分片結構 P214

分片 (sharding) 本質上就是基於某些簡單的規則將資料劃分為更小的部分,然後根據資料所屬的部分來決定將資料傳送到哪個位置上面。這種技術可以擴充套件儲存空間並提高所能處理的負載量。 P214

接下來將把分片的概念應用到雜湊、集合和有序集合上,並在講解實現這些資料結構的其中一部分標準功能的方法。這種情況下,程式不再是將值 X 儲存到鍵 Y 裡面,而是將值 X 儲存到鍵 Y:<shardid> 裡面。 P214

對列表進行分片 P214

在不使用 Lua 指令碼的情況下對列表進行分配非常困難,因此將在後面介紹使用 Lua 指令碼構建一個分片式的列表,並支援以阻塞和非阻塞兩種方式從列表的兩端進行推入和彈出操作。

對有序集合進行分片 P215

因為 ZRANGE, ZRANGEBYSCORE, ZRANK, ZCOUNT, ZREMRANGE, ZREMRANGEBYSCORE 這類命令的分片版本需要對有序集合的所有分片進行操作才能計算出命令的最終結果,所以這些操作無法執行得像普通的有序集合操作那麼快,因此對有序集合進行分片的作用不大。

如果需要將完整的資訊儲存到一個體積較大的有序集合中,但只會對分值排名前 n 位和後 n 位對元素進行操作,那麼可以使用下面介紹對雜湊分片對方法對有序集合進行分片,並維護額外對最高分值對有序集合和最低分值對有序集合,然後通過 ZADD 命令為這兩個有序集合新增新元素,並通過 ZREMRANGEBYRANK 命令確保元素對數量不會超過限制。 P215

分片式雜湊 P215

對雜湊的鍵進行劃分時,可以把雜湊儲存的鍵作為一個資訊源,並使用雜湊函式為鍵計算出一個數字雜湊值,然後根據需要儲存的鍵的總數量以及每個分片需要儲存的鍵數量,計算出所需的分片數,最後使用分片數和雜湊只決定應把鍵儲存到哪個分片裡面。 P215

所思

其實我們平時在考慮分片這種形式的時候是不太會考慮到鍵的總數量的這種條件,基本上是根據現有的資料進行分析後設定一個分片數量 shard_num ,這樣當有一個鍵 key 需要計算對應的分片時,只需要 cal_hash(key) % shard_num 即可得到對應的 shard_id 。但類似 CRC32MD5 這種方式進行雜湊值時有一個問題,就是書中提到的當分片數量改變時,會有大量鍵的新舊雜湊值不同,就需要將資料遷移至新雜湊值對應的 shard_id 。為了避免這樣的情況,就需要一致性雜湊演算法,使得分片數量改變時需要遷移的資料儘量小一點,並保證遷移後的資料仍能夠較為均勻的在每個分片上。

將字串儲存到雜湊裡面 P217

如果發現將很多相關聯的短字串或者數字儲存到了字串鍵裡面,並且持續地將這些鍵命名為 namespace:id 這樣的形式,那麼可以考慮將這些值儲存到分片雜湊裡面,在某些情況下,這種做法可以明顯減少記憶體佔用。 P217

分片集合 P218

集合一樣可以通過類似雜湊的方式處理鍵獲得分片 id ,進而改造相應的命令支援分片式操作。

如果鍵是整數且最大值相對較小,那麼除了直接使用鍵獲取分片 id ,還可以使用點陣圖 (bitmap) 記錄每個鍵是否在“集合”中。 P221

如果鍵是整數,數量非常多,無法全部存下,但又能容忍一定的誤差,那麼可以使用布隆過濾器記錄每個鍵是否在“集合”中(判斷為不存在時,則必定不存在;判斷為存在時,有極低概率不存在)。

打包儲存二進位制位和位元組 P221

前面提到當使用類似 namespace:id 這樣當字串鍵去儲存短字串或者計數器時,使用分片雜湊可以有效降低儲存這些資料所需當記憶體。但是,如果被儲存的是一些簡短並且長度固定當連續 id ,那麼我們還有使用比分片雜湊更為節約記憶體當資料儲存方法可用。 P221

Redis 資料結構常用命令簡介 中介紹過可用於高效打包和更新 Redis 字串的四個命令: P221

  • GETRANGE: 用於讀取被儲存字串的其中一部分內容
  • SETRANGE: 用於對儲存在字串裡面的其中一部分內容進行設定
  • GETBIT: 用於獲取字串裡面某個二進位制位的值
  • SETBIT: 用於對字串裡面某個二進位制位進行設定

通過這四個命令,我們可以在不對資料進行壓縮的情況下,使用 Redis 字串以儘可能緊湊的格式去儲存計數器、定長字串、布林值等資料。 P221

決定被儲存位置資訊的格式 P221

我們以儲存的資訊是使用者的位置資訊為例,不同記憶體的使用量決定了不同的位置精度: P221

  • 1 位元組:精確到國家
  • 2 位元組:精確到國家及所在州/省
  • 3 位元組:精確到郵政編碼
  • 4 位元組:精確到經緯度(2 米)

這裡我們用 2 位元組儲存位置資訊,首先我們可以使用一個陣列儲存所有國家(或地區)的 ISO3 國家(或地區)編碼,然後用第一個位元組儲存所在國家(或地區)在陣列中的下標。然後我們可以使用一個 map ,同樣使用陣列儲存每個國家(或地區)的州/省資訊,用第二個位元組儲存所在州/省在對應陣列中的下標。 P222

儲存打包後的資料 P223

獲取到位置資訊對應到兩個位元組到資料後,就可以使用 SETRANGE 命令將其儲存到字串鍵裡面去了。但是還需要考慮使用者的總量,假如使用者數量達到 7.5 億,那麼需要 1.5 GB 記憶體儲存所有使用者的資料,但 Redis 的字串鍵最大隻能儲存 512 MB 資料,並且 Redis 在對現有的字串進行設定的時候,如果被設定的部分超過了現有字串的末尾,那麼 Redis 可能需要分配更多的記憶體以儲存新資料,因此對一個長字串的末尾進行設定,耗時要比執行一個簡單的 SETBIT 呼叫多得多。為了解決上述問題,我們需要將資料分片到多個字串鍵裡面。 P223

我們可以在每個字串裡面儲存 2^20 個使用者的位置資訊,這相當於在字串裡面構建 100 多萬個節點,而這樣的字串需要佔 2 MB 的記憶體。 P223

對分片字串進行聚合計算 P224

對所有使用者的位置資訊進行聚合計算 P224

找到提前儲存的最大的使用者 id ,然後計算最大分片 id ,遍歷每個字串分片中的每個使用者的資料(使用 GETRANGE 分塊獲取資料),根據兩個位元組對應的下標找到對應的國家(或地區)及州/省資訊,然後統計即可。

對指定使用者的位置資訊進行聚合計算 P226

遍歷每個指定的使用者 id ,計算其對應的分片 id 和分片中的偏移量,使用 GETRANGE 獲取對應的兩個位元組,根據兩個位元組對應的下標找到對應的國家(或地區)及州/省資訊,然後統計即可。

本文首發於公眾號:滿賦諸機(點選檢視原文) 開源在 GitHub :reading-notes/redis-in-action

相關文章