11 String
為什麼String型別記憶體開銷大
RedisObject 結構體 + SDS:
- 當儲存64位有符號整數時,String會存為一個8位元組的Long型別整數,稱為int編碼方式
當資料包含字串時,String型別會用簡單動態字串(SDS)結構體來儲存
- buf:位元組陣列,儲存實際資料。為了表示位元組陣列的結束,Redis 會自動在陣列最後加一個“\0”,這就會額外佔用 1 個位元組的開銷。
- len:佔 4 個位元組,表示 buf 的已用長度。
- alloc:也佔個 4 位元組,表示 buf 的實際分配長度,一般大於 len。
RedisObject
Redis 的資料型別有很多,而且,不同資料型別都有些相同的後設資料要記錄(比如最後一次訪問的時間、被引用的次數等),所以,Redis 會用一個 RedisObject 結構體來統一記錄這些後設資料,同時指向實際資料。
一個 RedisObject 包含了 8 位元組的後設資料和一個 8 位元組指標,這個指標再進一步指向具體資料型別的實際資料所在。
編碼方式
- 儲存 Long 型別整數時,RedisObject 中的指標就直接賦值為整數資料了,只佔用8位元組。
- 儲存小於等於 44 位元組的字串時,RedisObject 中的後設資料、指標和 SDS 是一塊連續的記憶體區域,可以避免記憶體碎片。這種佈局方式也被稱為 embstr 編碼方式。
- 儲存大於44位元組的字串時,Redis會給SDS獨立的空間,用指標指向SDS結構,稱為raw編碼方式。
記憶體分配庫jemalloc
Redis 會使用一個全域性雜湊表儲存所有鍵值對,雜湊表的每一項是一個 dictEntry 的結構體,用來指向一個鍵值對。dictEntry 結構中有三個 8 位元組的指標,分別指向 key、value 以及下一個 dictEntry,三個指標共 24 位元組。
jemalloc 在分配記憶體時,會根據我們申請的位元組數 N,找一個比 N 大,但是最接近 N 的 2 的冪次數作為分配的空間,這樣可以減少頻繁分配的次數。
申請 24 位元組空間,jemalloc 則會分配 32 位元組。所以,在我們剛剛說的場景裡,dictEntry 結構就佔用了 32 位元組。
示例:10位數圖片ID和物件ID存為String型別時,記憶體結構:
RedisObject:8(後設資料)+ 8(INT)= 16位元組
雜湊表:8(key) + 8(value) + 8(next) = 24位元組 =>jemalloc 分配32位元組
總計: 16 * 2(鍵值對) + 32 = 64位元組
壓縮列表ziplist
壓縮列表之所以能節省記憶體,就在於它是用一系列連續的 entry 儲存資料。
- 表頭:zlbytes(列表長度)、zltail(列表尾偏移量)、zllen(列表entry個數)
- 後設資料:連續的entry
- 表尾:zlend(列表結束)
每個entry構成:
- prev_len:前一個entry的長度,小於254位元組時佔1位元組,否則佔5位元組。
- len:表示自身長度,4 位元組;
- encoding:記錄該節點content屬性儲存資料的型別及長度。小於等於63位元組佔1位元組,小於等於16383位元組佔2位元組,否則佔5位元組。
- content:實際資料
Redis 基於壓縮列表實現了 List、Hash 和 Sorted Set 這樣的集合型別,這樣做的最大好處就是節省了 dictEntry 的開銷。
- 當你用 String 型別時,一個鍵值對就有一個 dictEntry,要用 32 位元組空間。
- 採用集合型別時,一個 key 就對應一個集合的資料,能儲存的資料多了很多,但也只用了一個 dictEntry,這樣就節省了記憶體。
集合型別如何儲存單值的鍵值對
可以採用基於 Hash 型別的二級編碼方法:
把一個單值的資料拆分成兩部分,前一部分作為 Hash 集合的 key,後一部分作為 Hash 集合的 value,這樣一來,我們就可以把單值資料儲存到 Hash 集合中了。
每個 entry 儲存一個圖片儲存物件 ID(8 位元組),此時,每個 entry 的 prev_len 只需要 1 個位元組就行,因為每個 entry 的前一個 entry 長度都只有 8 位元組,小於 254 位元組。這樣一來,一個圖片的儲存物件 ID 所佔用的記憶體大小是 14 位元組(1+ 4 + 1 + 8),實際分配 16 位元組。新增一個圖片:一個entry(16位元組)
Hash型別的底層實現
Redis Hash 型別的兩種底層實現結構,壓縮列表和雜湊表,都在什麼時候使用呢?
Hash 型別設定了用壓縮列表儲存資料時的兩個閾值,一旦超過了閾值,Hash 型別就會用雜湊表來儲存資料了。
- hash-max-ziplist-entries:表示用壓縮列表儲存時雜湊集合中的最大元素個數。
- hash-max-ziplist-value:表示用壓縮列表儲存時雜湊集合中單個元素的最大長度。
一旦從壓縮列表轉為了雜湊表,Hash 型別就會一直用雜湊表進行儲存,而不會再轉回壓縮列表了。
以圖片 ID 1101000060 和圖片儲存物件 ID 3302000080 為例,我們可以把圖片 ID 的前 7 位(1101000)作為 Hash 型別的鍵,把圖片 ID 的最後 3 位(060)和圖片儲存物件 ID 分別作為 Hash 型別值中的 key 和 value。
為了能充分使用壓縮列表的精簡記憶體佈局,我們一般要控制儲存在 Hash 集合中的元素個數。所以,在剛才的二級編碼中,我們只用圖片 ID 最後 3 位作為 Hash 集合的 key,也就保證了 Hash 集合的元素個數不超過 1000,同時,我們把 hash-max-ziplist-entries 設定為 1000,這樣一來,Hash 集合就可以一直使用壓縮列表來節省記憶體空間了。
小結
在儲存的鍵值對本身佔用的記憶體空間不大時,String 型別的後設資料開銷就佔據主導了,這裡麵包括了 RedisObject 結構、SDS 結構、dictEntry 結構的記憶體開銷。
針對這種情況,我們可以使用壓縮列表儲存資料。
使用 Hash 這種集合型別儲存單值鍵值對的資料時,我們需要將單值資料拆分成兩部分,分別作為 Hash 集合的鍵和值。
Redis容量預估網址:http://www.redis.cn/redis_mem...
12 集合統計模式
集合型別常見的四種統計模式,包括聚合統計、排序統計、二值狀態統計和基數統計。
聚合統計(Set集合)
聚合統計,就是指統計多個集合元素的聚合結果,包括:
- 統計多個集合的共有元素(交集統計);
- 把兩個集合相比,統計其中一個集合獨有的元素(差集統計);
- 統計多個集合的所有元素(並集統計)。
示例:記錄每天登入使用者ID,統計累計使用者,新增使用者,留存使用者。
key 是 user:id 以及當天日期,例如 user20200803;
value 是 Set 集合,記錄當天登入的使用者 ID。
累計使用者:統計user:id和user20200803的並集,儲存到user:id中
SUNIONSTORE user:id user:id user:id:20200803
新增使用者:統計user20200804和user:id的差集,儲存到user:new中
SDIFFSTORE user:new user:id:20200804 user:id
留存使用者:統計0803和0804的交集,儲存到userrem
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804
Set集合聚合統計風險: 計算複雜度高,資料量大時直接執行計算會導致Redis例項阻塞。
解決方案:
- 選一個從庫負責聚合運算
- 把資料讀取到客戶端,在客戶端完成聚合統計
排序統計
集合中的元素可以按序排列,這種對元素保序的集合型別叫作有序集合。
Redis集合型別:List,Hash,Set,Sorted Set
有序集合型別:
- List:按照元素進入 List 的順序進行排序
- Sorted Set:根據元素權重排序
List問題:List是透過元素位置來排序的,新元素插入後元素位置會改變,分頁操作時,Lrange可能讀取到舊資料。
所以,在面對需要展示最新列表、排行榜等場景時,如果資料更新頻繁或者需要分頁顯示,建議你優先考慮使用 Sorted Set。
二值狀態統計
在簽到統計時,每個使用者一天的簽到用 1 個 bit 位就能表示,一個月(假設是 31 天)的簽到情況用 31 個 bit 位就可以,而一年的簽到也只需要用 365 個 bit 位,根本不用太複雜的集合型別。這個時候,我們就可以選擇 Bitmap。
Bitmap 本身是用 String 型別作為底層資料結構實現的一種統計二值狀態的資料型別。
Bitmap可以看做一個bit陣列,提供GETBIT/SETBIT操作,使用偏移值offset對bitmap陣列進行讀寫,offset最小值為0。
- BITSET後該bit位設定為1;
- BITCOUNT統計所有為1的個數
- BITOP按位與
示例:統計8月3日簽到數(3日:0-1-2)
SETBIT uid:sign:3000:202008 2 1
GETBIT uid:sign:3000:202008 2
BITCOUNT uid:sign:3000:202008
如果只需要統計資料的二值狀態,例如商品有沒有、使用者在不在等,就可以使用 Bitmap,因為它只用一個 bit 位就能表示 0 或 1。在記錄海量資料時,Bitmap 能夠有效地節省記憶體空間。
基數統計
基數統計就是指統計一個集合中不重複的元素個數。
在 Redis 的集合型別中,Set 型別預設支援去重。
HyperLogLog 是一種用於統計基數的資料集合型別,它的最大優勢就在於,當集合元素數量非常多時,它計算基數所需的空間總是固定的,而且還很小。
示例:統計訪問頁面UV
PFADD page1:uv user1 user2 user3 user4 user5
PFCOUNT page1:uv
HyperLogLog 的統計規則是基於機率完成的,所以它給出的統計結果是有一定誤差的,標準誤算率是 0.81%。
小結
集合型別優缺點:
其他統計場景:
使用Sorted Set統計線上使用者數:
- 使用者上線時使用zadd online_users $timestamp $user_id把使用者新增到Sorted Set中
- 使用zcount online_users $starttime $endtime就可以得出指定時間段內的線上使用者數
- 使用Set記錄使用者喜歡水果,如
sadd user1 apple banana
,再使用zunionstore fruits_union 2 user1 user2
把結果儲存到fruits_union這個key中,zrange fruits_union 0 -1 withscores
可以得出每種水果被喜歡的次數。
13 GEO
位置資訊服務(Location-Based Service,LBS)應用訪問的資料是和人或物關聯的一組經緯度資訊,而且要能查詢相鄰的經緯度範圍,GEO 就非常適合應用在 LBS 服務的場景中。
對於一個 LBS 應用來說,除了記錄經緯度資訊,還需要根據使用者的經緯度資訊在車輛的 Hash 集合中進行範圍查詢。
實際上,GEO 型別的底層資料結構就是用 Sorted Set 來實現的,元素是車輛 ID,元素的權重分數是GeoHash編碼過的經緯度資訊。
GeoHash編碼
二分割槽間,區間編碼。
即先對經度和緯度分別編碼,然後再把經緯度各自的編碼組合成一個最終編碼。
GeoHash 編碼會把值編碼成一個 N 位的二進位制值,經度範圍[-180,180],緯度範圍[-90,90]做 N 次的二分割槽操作,其中 N 可以自定義。編碼值落在左分割槽,我們就用 0 表示;如果落在右分割槽,就用 1 表示。每做完一次二分割槽,我們就可以得到 1 位編碼值。
示例:經度值116.37,緯度值39.86的編碼過程
當一組經緯度值都編完碼後,我們再把它們的各自編碼值組合在一起,組合的規則是:最終編碼值的偶數位上依次是經度的編碼值,奇數位上依次是緯度的編碼值,其中,偶數位從 0 開始,奇數位從 1 開始。
使用 GeoHash 編碼後,我們相當於把整個地理空間劃分成了一個個方格,每個方格對應了 GeoHash 中的一個分割槽。所以使用 Sorted Set 範圍查詢得到的相近編碼值,在實際的地理空間上,也是相鄰的方格,這就可以實現 LBS 應用“搜尋附近的人或物”的功能了。
GEO其實是把經緯度編碼合併作為sorted set的key,但有時編碼相近並不一定是相鄰方格,一般的做法是同時查詢周圍的4或8個方格
操作方法
- GEOADD 命令:用於把一組經緯度資訊和相對應的一個 ID 記錄到 GEO 型別集合中
- GEORADIUS 命令:會根據輸入的經緯度位置,查詢以這個經緯度為中心的一定範圍內的其他元素。當然,我們可以自己定義這個範圍。
GEOADD cars:locations 116.034579 39.030452 33
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
自定義資料型別
Redis 鍵值對中的每一個值都是用 RedisObject 儲存的。
基本結構
RedisObject 的內部組成包括了 type、encoding、lru 和 refcount 4 個後設資料,以及 1 個*ptr
指標。
- type:表示值的型別,涵蓋了我們前面學習的五大基本型別;
- encoding:是值的編碼方式,用來表示 Redis 中實現各個基本型別的底層資料結構,例如 SDS、壓縮列表、雜湊表、跳錶等;
- lru:記錄了這個物件最後一次被訪問的時間,用於淘汰過期的鍵值對;
- refcount:記錄了物件的引用計數;
*ptr
:是指向資料的指標。
我們在定義了新的資料型別後,也只要在 RedisObject 中設定好新型別的 type 和 encoding,再用*ptr
指向新型別的實現,就行了。
Redis其他資料型別和應用
List佇列:
rpush入棧 + lpop出棧。
缺點:不支援ack,不支援多消費者。
PubSub:
支援多消費者。
缺點:PubSub只能發給線上消費者,消費者下線會丟失資料。
Stream資料結構:
可以持久化、支援ack機制、支援多個消費者、支援回溯消費。
布隆過濾器:
解決業務層記憶體穿透。
14 時間序列資料
與發生時間相關的一組資料,就是時間序列資料。
這些資料的特點是沒有嚴格的關係模型,記錄的資訊可以表示成鍵和值的關係。
讀寫特點
寫入特點:寫入要快,資料型別在進行資料插入時,複雜度要低,儘量不要阻塞。
- 時間序列資料通常是持續高併發寫入的
- 一個時間序列資料被記錄後通常就不會變了
讀取特點:查詢模式多,單條記錄查詢,範圍查詢,聚合計算等。
解決方案:基於 Hash 和 Sorted Set 實現,以及基於 RedisTimeSeries 模組實現。
Hash 和 Sorted Set組合
儲存時間序列資料,同時儲存Hash 和 Sorted Set兩種型別。
Hash 型別
優點:可以實現對單鍵的快速查詢,滿足了時間序列資料的單鍵查詢需求。
缺點:無法範圍查詢。
HGET device:temperature 202008030905
"25.1"
HMGET device:temperature 202008030905 202008030907 202008030908
1) "25.1"
2) "25.9"
3) "24.9"
Sorted Set
把時間戳作為 Sorted Set 集合的元素分數,把時間點上記錄的資料作為元素本身。
ZRANGEBYSCORE device:temperature 202008030907 202008030910
1) "25.9"
2) "24.9"
3) "25.3"
4) "25.2"
保證原子性
當多個命令及其引數本身無誤時,MULTI 和 EXEC 命令可以保證執行這些命令時的原子性。
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> HSET device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379> ZADD device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1
聚合計算
RedisTimeSeries 支援直接在 Redis 例項上進行聚合計算。
RedisTimeSeries 是 Redis 的一個擴充套件模組。它專門面向時間序列資料提供了資料型別和訪問介面,並且支援在 Redis 例項上直接對資料進行按時間範圍的聚合計算。
問題:如果你是Redis的開發維護者,你會把聚合計算也設計為Sorted Set的內在功能嗎?
解答:
不會。因為聚合計算是CPU密集型任務,Redis在處理請求時是單執行緒的,也就是它在做聚合計算時無法利用到多核CPU來提升計算速度,如果計算量太大,這也會導致Redis的響應延遲變長,影響Redis的效能。
Redis的定位就是高效能的記憶體資料庫,要求訪問速度極快。所以對於時序資料的儲存和聚合計算,我覺得更好的方式是交給時序資料庫去做,時序資料庫會針對這些儲存和計算的場景做針對性最佳化。
15 訊息佇列
訊息佇列在存取訊息時,必須要滿足三個需求,分別是訊息保序、處理重複的訊息和保證訊息可靠性。
Redis 的 List 和 Streams 兩種資料型別,就可以滿足訊息佇列的這三個需求。
List解決方案
訊息保序
生產者可以使用 LPUSH 命令把要傳送的訊息依次寫入 List,而消費者則可以使用 RPOP 命令,從 List 的另一端按照訊息的寫入順序,依次讀取訊息並進行處理。
效能風險:消費者需要不停執行RPOP,造成效能損失。
解決方案:阻塞式讀取 BRPOP,客戶端在沒有讀到佇列資料時,自動阻塞,直到有新的資料寫入佇列,再開始讀取新資料。
重複消費
要求:消費者程式本身能對重複訊息進行判斷。
方案:訊息佇列給每一個訊息提供全域性唯一的 ID 號;消費者程式要把已經處理過的訊息的 ID 號記錄下來。
冪等性:對於同一條訊息,消費者收到一次的處理結果和收到多次的處理結果是一致的。
可靠性
List 型別提供了 BRPOPLPUSH 命令,這個命令的作用是讓消費者程式從一個 List 中讀取訊息,同時,Redis 會把這個訊息再插入到另一個 List(可以叫作備份 List)留存。
這樣一來,如果消費者程式讀了訊息但沒能正常處理,等它重啟後,就可以從備份 List 中重新讀取訊息並進行處理了。
處理過程:生產者先用 LPUSH 把訊息插入到訊息佇列 mq 中。消費者程式使用 BRPOPLPUSH 命令讀取訊息,同時訊息還會被 Redis 插入到 mqback 佇列中。如果消費者程式處理訊息時當機了,等它重啟後,可以從 mqback 中再次讀取訊息,繼續處理。
問題:生產者訊息傳送很快,而消費者處理訊息的速度比較慢,這就導致 List 中的訊息越積越多,給 Redis 的記憶體帶來很大壓力。
解決:使用streams方案,啟動多個消費者程式組成一個消費組,一起分擔處理 List 中的訊息。
Streams解決方案
Streams 是 Redis 專門為訊息佇列設計的資料型別,它提供了豐富的訊息佇列操作命令。
- XADD:插入訊息,保證有序,可以自動生成全域性唯一 ID;
- XREAD:用於讀取訊息,可以按 ID 讀取資料;
- XREADGROUP:按消費組形式讀取訊息;
- XPENDING:用來查詢每個消費組內所有消費者已讀取但尚未確認的訊息;
- XACK:用於向訊息佇列確認訊息處理已完成。
XADD
XADD 命令可以往訊息佇列中插入新訊息,訊息的格式是鍵 - 值對形式。對於插入的每一條訊息,Streams 可以自動為其生成一個全域性唯一的 ID。
XADD mqstream * repo 5
"1599203861727-0"
*
表示插入資料自動生成全域性唯一ID,也可以自行設定。
XREAD
XREAD 在讀取訊息時,可以指定一個訊息 ID,並從這個訊息 ID 的下一條訊息開始進行讀取。
XREAD BLOCK 100 STREAMS mqstream 1599203861727-0
呼叫 XRAED 時設定 block 配置項,實現類似於 BRPOP 的阻塞讀取操作。
XREAD block 10000 streams mqstream $
(nil)
(10.00s)
“$”符號表示讀取最新的訊息,XREAD沒有新訊息時阻塞 10000 毫秒(即 10 秒),然後返回nil。
XGROUP建立消費組
Streams 本身可以使用 XGROUP 建立消費組,建立消費組之後,Streams 可以使用 XREADGROUP 命令讓消費組內的消費者讀取訊息。
XGROUP create mqstream group1 0
XREADGROUP group group1 consumer1 streams mqstream >
讓 group1 消費組裡的消費者 consumer1 從 mqstream 中讀取所有訊息,其中,命令最後的引數“>”,表示從第一條尚未被消費的訊息開始讀取。
注意:訊息佇列中的訊息一旦被消費組裡的一個消費者讀取了,就不能再被該消費組內的其他消費者讀取了。
XPENDING
為了保證消費者在發生故障或當機再次重啟後,仍然可以讀取未處理完的訊息,Streams 會自動使用內部佇列(也稱為 PENDING List)留存消費組裡每個消費者讀取的訊息,直到消費者使用 XACK 命令通知 Streams“訊息已經處理完成”。
如果消費者沒有成功處理訊息,它就不會給 Streams 傳送 XACK 命令,訊息仍然會留存。此時,消費者可以在重啟後,用 XPENDING 命令檢視已讀取、但尚未確認處理完成的訊息。
檢視group2中消費者組已讀取、但未確認的訊息
XPENDING mqstream group2
查詢指定消費者
XPENDING mqstream group2 - + 10 consumer2
XACK
處理訊息後,消費者可以使用 XACK 命令通知 Streams,然後這條訊息就會被刪除。