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

簡介

降低 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