Redis 深度歷險: 核心原理和應用實踐

目錄

1.Redis 可以做什麼? 

2.基礎:萬丈高樓平地起 ——Redis 基礎數據結構 

string (字符串)  

list (列表) 

hash (字典) 

set (集合) 

zset (有序列表)  

容器型數據結構的通用規則 

過期時間 

應用 1:千帆競發 —— 分佈式鎖 

分佈式鎖 

應用 2:緩兵之計 —— 延時隊列

應用 3:節衣縮食 —— 位圖 

應用 4:四兩撥千斤 —— HyperLogLog 

應用 5:層巒疊嶂 —— 布隆過濾器 

應用 6:斷尾求生 —— 簡單限流 

應用 7:一毛不拔 —— 漏斗限流 

應用 8:近水樓臺 —— GeoHash


1.Redis 可以做什麼? 

Redis 的業務應用範圍非常廣泛,讓我們以掘金技術社區(juejin.im)的帖子模塊爲實
例,梳理一下,Redis 可以用在哪些地方? 
1、記錄帖子的點贊數、評論數和點擊數 (hash)。  
2、記錄用戶的帖子 ID 列表 (排序),便於快速顯示用戶的帖子列表 (zset)。  
3、記錄帖子的標題、摘要、作者和封面信息,用於列表頁展示 (hash)。  
4、記錄帖子的點贊用戶 ID 列表,評論 ID 列表,用於顯示和去重計數 (zset)。  
5、緩存近期熱帖內容 (帖子內容空間佔用比較大),減少數據庫壓力 (hash)。  
6、記錄帖子的相關文章 ID,根據內容推薦相關帖子 (list)。  
7、如果帖子 ID 是整數自增的,可以使用 Redis 來分配帖子 ID(計數器)。  
8、收藏集和帖子之間的關係 (zset)。  
9、記錄熱榜帖子 ID 列表,總熱榜和分類熱榜 (zset)。  
10、緩存用戶行爲歷史,進行惡意行爲過濾 (zset,hash)

以上提到的只是 Redis 的基礎應用,也是日常開發中最常見的應用

2.基礎:萬丈高樓平地起 ——Redis 基礎數據結構 

Redis 基礎數據結構 :  Redis 有 5 種基礎數據結構,分別爲:string (字符串)、list (列表)、set (集合)、hash (哈
希) 和 zset (有序集合)。

string (字符串)  


字符串 string 是 Redis 最簡單的數據結構。Redis 所有的數據結構都是以唯一的 key 
字符串作爲名稱,然後通過這個唯一 key 值來獲取相應的 value 數據。不同類型的數據結
構的差異就在於 value 的結構不一樣。  

字符串結構使用非常廣泛,一個常見的用途就是緩存用戶信息。我們將用戶信息結構體
使用 JSON 序列化成字符串,然後將序列化後的字符串塞進 Redis 來緩存。同樣,取用戶
信息會經過一次反序列化的過程。 

Redis 的字符串是動態字符串,是可以修改的字符串,內部結構實現上類似於 Java 的 
ArrayList,採用預分配冗餘空間的方式來減少內存的頻繁分配,如圖中所示,內部爲當前字
符串實際分配的空間 capacity 一般要高於實際字符串長度 len。當字符串長度小於 1M 時,
擴容都是加倍現有的空間,如果超過 1M,擴容時一次只會多擴 1M 的空間
。需要注意的是
字符串最大長度爲 512M

過期和 set 命令擴展

 可以對 key 設置過期時間,到點自動刪除,這個功能常用來控制緩存的失效時間

> set name codehole  > get name "codehole"  > expire name 5 # 5s 後過期  ... # wait for 5s  > get name  (nil)  > setex name 5 codehole # 5s 後過期,等價於 set+expire  > get name  "codehole"  ... # wait for 5s  > get name  (nil)

計數

如果 value 值是一個整數,還可以對它進行自增操作。自增是有範圍的,它的範圍是 
signed long 的最大最小值,超過了這個值,Redis 會報錯。 
> set age 30  OK  > incr age  (integer) 31  > incrby age 5  (integer) 36  > incrby age -5  (integer) 31 

list (列表) 

Redis 的列表相當於 Java 語言裏面的 LinkedList,注意它是鏈表而不是數組。這意味着 
list 的插入和刪除操作非常快,時間複雜度爲 O(1),但是索引定位很慢,時間複雜度爲 
O(n),這點讓人非常意外。

Redis 的列表結構常用來做異步隊列使用。將需要延後處理的任務結構體序列化成字符
串塞進 Redis 的列表,另一個線程從這個列表中輪詢數據進行處理

慢操作 

lindex 相當於 Java 鏈表的 get(int index)方法,它需要對鏈表進行遍歷,性能隨着參數
index 增大而變差。 ltrim 和字面上的含義不太一樣,個人覺得它叫 lretain(保留) 更合適一
些,因爲 ltrim 跟的兩個參數 start_index 和 end_index 定義了一個區間,在這個區間內的值,
ltrim 要保留,區間之外統統砍掉。我們可以通過 ltrim 來實現一個定長的鏈表,這一點非常
有用。index 可以爲負數,index=-1 表示倒數第一個元素,同樣 index=-2 表示倒數第二個元
。  

快速列表

如果再深入一點,你會發現 Redis 底層存儲的還不是一個簡單的 linkedlist,而是稱之爲
快速鏈表 quicklist 的一個結構

hash (字典) 

Redis 的字典相當於 Java 語言裏面的 HashMap,它是無序字典。內部實現結構上同 
Java 的 HashMap 也是一致的,同樣的數組 + 鏈表二維結構。第一維 hash 的數組位置碰撞
時,就會將碰撞的元素使用鏈表串接起來。 
 不同的是,Redis 的字典的值只能是字符串,另外它們 rehash 的方式不一樣,因爲 
Java 的 HashMap 在字典很大時,rehash 是個耗時的操作,需要一次性全部 rehash。Redis 
爲了高性能,不能堵塞服務,所以採用了漸進式 rehash 策略

漸進式 rehash 會在 rehash 的同時,保留新舊兩個 hash 結構,查詢時會同時查詢兩個 
hash 結構,然後在後續的定時任務中以及 hash 的子指令中,循序漸進地將舊 hash 的內容
一點點遷移到新的 hash 結構中
hash 結構也可以用來存儲用戶信息,不同於字符串一次性需要全部序列化整個對象,
hash 可以對用戶結構中的每個字段單獨存儲。這樣當我們需要獲取用戶信息時可以進行部分
獲取。
而以整個字符串的形式去保存用戶信息的話就只能一次性全部讀取,這樣就會比較浪
費網絡流量。 

hash 也有缺點,hash 結構的存儲消耗要高於單個字符串,到底該使用 hash 還是字符
串,需要根據實際情況再三權衡

 

同字符串一樣,hash 結構中的單個子 key 也可以進行計數,它對應的指令是 hincrby,
和 incr 使用基本一樣

set (集合) 

Redis 的集合相當於 Java 語言裏面的 HashSet,它內部的鍵值對是無序的唯一的。它的
內部實現相當於一個特殊的字典,字典中所有的 value 都是一個值 NULL

當集合中最後一個元素移除之後,數據結構自動刪除,內存被回收。 set 結構可以用來
存儲活動中獎的用戶 ID,因爲有去重功能,可以保證同一個用戶不會中獎兩次

zset (有序列表)  

zset 可能是 Redis 提供的最爲特色的數據結構,它也是在面試中面試官最愛問的數據結
構。它類似於 Java 的 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 
value 的唯一性,另一方面它可以給每個 value 賦予一個 score,代表這個 value 的排序權
重。它的內部實現用的是一種叫着「跳躍列表」的數據結構。 

zset 可以用來存
粉絲列表,value 值是粉絲的用戶 ID,score 是關注時間。我們可以對粉絲列表按關注時間
進行排序。 

zset 還可以用來存儲學生的成績,value 值是學生的 ID,score 是他的考試成績。我們
可以對成績按分數進行排序就可以得到他的名次

跳躍列表(類似java的跳錶Skiplist)

zset 內部的排序功能是通過「跳躍列表」數據結構來實現的,它的結構非常特殊,也比
較複雜。 
因爲 zset 要支持隨機的插入和刪除,所以它不好使用數組來表示。我們先看一個普通的
鏈表結構。 

跳躍列表就是類似於這種層級制,最下面一層所有的元素都會串起來。然後每隔幾個元
素挑選出一個代表來,再將這幾個代表使用另外一級指針串起來。然後在這些代表裏再挑出
二級代表,再串起來。最終就形成了金字塔結構。 

容器型數據結構的通用規則 

list/set/hash/zset 這四種數據結構是容器型數據結構,它們共享下面兩條通用規則:

1、create if not exists  
如果容器不存在,那就創建一個,再進行操作。比如 rpush 操作剛開始是沒有列表的,
Redis 就會自動創建一個,然後再 rpush 進去新元素。  
2、drop if no elements  
如果容器裏元素沒有了,那麼立即刪除元素,釋放內存。這意味着 lpop 操作到最後一
個元素,列表就消失了。

過期時間 

Redis 所有的數據結構都可以設置過期時間,時間到了,Redis 會自動刪除相應的對象。
需要注意的是過期是以對象爲單位,比如一個 hash 結構的過期是整個 hash 對象的過期,
而不是其中的某個子 key。  
還有一個需要特別注意的地方是如果一個字符串已經設置了過期時間,然後你調用了 
set 方法修改了它,它的過期時間會消失

應用 1:千帆競發 —— 分佈式鎖 

分佈式應用進行邏輯處理時經常會遇到併發問題。 
比如一個操作要修改用戶的狀態,修改狀態需要先讀出用戶的狀態,在內存裏進行修
改,改完了再存回去。如果這樣的操作同時進行了,就會出現併發問題,因爲讀取和保存狀
態這兩個操作不是原子的。(Wiki 解釋:所謂原子操作是指不會被線程調度機制打斷的操
作;這種操作一旦開始,就一直運行到結束,中間不會有任何 context switch 線程切換。) 
 這個時候就要使用到分佈式鎖來限制程序的併發執行。Redis 分佈式鎖使用非常廣泛,
它是面試的重要考點之一

分佈式鎖 
 

分佈式鎖本質上要實現的目標就是在 Redis 裏面佔一個“茅坑”,當別的進程也要來佔
時,發現已經有人蹲在那裏了,就只好放棄或者稍後再試。 
佔坑一般是使用 setnx(set if not exists) 指令,只允許被一個客戶端佔坑。先來先佔, 用
完了,再調用 del 指令釋放茅坑

但是有個問題,如果邏輯執行到中間出現異常了,可能會導致 del 指令沒有被調用,這樣
就會陷入死鎖,鎖永遠得不到釋放。 

於是我們在拿到鎖之後,再給鎖加上一個過期時間,比如 5s,這樣即使中間出現異常也
可以保證 5 秒之後鎖會自動釋放

但是以上邏輯還有問題。如果在 setnx 和 expire 之間服務器進程突然掛掉了,可能是因
爲機器掉電或者是被人爲殺掉的,就會導致 expire 得不到執行,也會造成死鎖。

爲了解決這個疑難,Redis 開源社區湧現了一堆分佈式鎖的 library,專門用來解決這個問
題。實現方法極爲複雜,小白用戶一般要費很大的精力纔可以搞懂。如果你需要使用分佈式鎖,
意味着你不能僅僅使用 Jedis 或者 redis-py 就行了,還得引入分佈式鎖的 library。 

 爲了治理這個亂象,Redis 2.8 版本中作者加入了 set 指令的擴展參數,使得 setnx 和 
expire 指令可以一起執行,徹底解決了分佈式鎖的亂象。從此以後所有的第三方分佈式鎖 
library 可以休息了。 > set lock:codehole true ex 5 nx OK ... do something critical ... > del 
lock:codehole 上面這個指令就是 setnx 和 expire 組合在一起的原子指令,它就是分佈式鎖的
奧義所在

超時問題 

Redis 的分佈式鎖不能解決超時問題,如果在加鎖和釋放鎖之間的邏輯執行的太長,以至
於超出了鎖的超時限制,就會出現問題。因爲這時候鎖過期了,第二個線程重新持有了這把鎖,
但是緊接着第一個線程執行完了業務邏輯,就把鎖給釋放了,第三個線程就會在第二個線程邏
輯執行完之間拿到了鎖。 
爲了避免這個問題,Redis 分佈式鎖不要用於較長時間的任務。如果真的偶爾出現了,數
據出現的小波錯亂可能需要人工介入解決
有一個更加安全的方案是爲 set 指令的 value 參數設置爲一個隨機數,釋放鎖時先匹配
隨機數是否一致,然後再刪除 key。但是匹配 value 和刪除 key 不是一個原子操作,Redis 也
沒有提供類似於 delifequals 這樣的指令,這就需要使用 Lua 腳本來處理了,因爲 Lua 腳本可
以保證連續多個指令的原子性執行。

可重入性  

可重入性是指線程在持有鎖的情況下再次請求加鎖,如果一個鎖支持同一個線程的多次加
鎖,那麼這個鎖就是可重入的。比如 Java 語言裏有個 ReentrantLock 就是可重入鎖。Redis 分
布式鎖如果要支持可重入,需要對客戶端的 set 方法進行包裝,使用線程的 Threadlocal 變量
存儲當前持有鎖的計數。 

應用 2:緩兵之計 —— 延時隊列

我們平時習慣於使用 Rabbitmq 和 Kafka 作爲消息隊列中間件,來給應用程序之間增加
異步消息傳遞功能。這兩個中間件都是專業的消息隊列中間件,特性之多超出了大多數人的理
解能力。 
使用過 Rabbitmq 的同學知道它使用起來有多複雜,發消息之前要創建 Exchange,再創
建 Queue,還要將 Queue 和 Exchange 通過某種規則綁定起來,發消息的時候要指定 routing
key,還要控制頭部信息。消費者在消費消息之前也要進行上面一系列的繁瑣過程。但是絕大
多數情況下,雖然我們的消息隊列只有一組消費者,但還是需要經歷上面這些繁瑣的過程。 
有了 Redis,它就可以讓我們解脫出來,對於那些只有一組消費者的消息隊列,使用 Redis 
就可以非常輕鬆的搞定。Redis 的消息隊列不是專業的消息隊列,它沒有非常多的高級特性,
沒有 ack 保證,如果對消息的可靠性有着極致的追求,那麼它就不適合使用
 

異步消息隊列 (服務器指定key,往list裏面插入消息,客戶端通過key去消費,消費一個值,list的那個值就消失了)

Redis 的 list(列表) 數據結構常用來作爲異步消息隊列使用,使用rpush/lpush操作入隊列,
使用 lpop 和 rpop 來出隊列。

隊列空了怎麼辦?

客戶端是通過隊列的 pop 操作來獲取消息,然後進行處理。處理完了再接着獲取消息,
再進行處理。如此循環往復,這便是作爲隊列消費者的客戶端的生命週期

可是如果隊列空了,客戶端就會陷入 pop 的死循環,不停地 pop,沒有數據,接着再 pop,
又沒有數據。這就是浪費生命的空輪詢。空輪詢不但拉高了客戶端的 CPU,redis 的 QPS 也
會被拉高,如果這樣空輪詢的客戶端有幾十來個,Redis 的慢查詢可能會顯著增多。 
通常我們使用 sleep 來解決這個問題,讓線程睡一會,睡個 1s 鍾就可以了。不但客戶端
的 CPU 能降下來,Redis 的 QPS 也降下來了

   隊列延遲

有沒有什麼辦法能顯著降低延遲呢?你當然可以很快想到:那就把睡覺的時間縮短點。這
種方式當然可以,不過有沒有更好的解決方案呢?當然也有,那就是 blpop/brpop。 
這兩個指令的前綴字符 b 代表的是 blocking,也就是阻塞讀。 
阻塞讀在隊列沒有數據的時候,會立即進入休眠狀態,一旦數據到來,則立刻醒過來。消
息的延遲幾乎爲零。用 blpop/brpop 替代前面的 lpop/rpop,就完美解決了上面的問題。...
 

空閒連接自動斷開 

你以爲上面的方案真的很完美麼?先別急着開心,其實他還有個問題需要解決。 
什麼問題?—— 空閒連接的問題。 
如果線程一直阻塞在哪裏,Redis 的客戶端連接就成了閒置連接,閒置過久,服務器一般
會主動斷開連接,減少閒置資源佔用。這個時候 blpop/brpop 會拋出異常來。 
所以編寫客戶端消費者的時候要小心,注意捕獲異常,還要重試。... 

鎖衝突處理 

上節課我們講了分佈式鎖的問題,但是沒有提到客戶端在處理請求時加鎖沒加成功怎麼辦。
一般有 3 種策略來處理加鎖失敗

1、直接拋出異常,通知用戶稍後重試; 
2、sleep 一會再重試; 
3、將請求轉移至延時隊列,過一會再試

直接拋出特定類型的異常 
這種方式比較適合由用戶直接發起的請求,用戶看到錯誤對話框後,會先閱讀對話框的內
容,再點擊重試,這樣就可以起到人工延時的效果。如果考慮到用戶體驗,可以由前端的代碼
替代用戶自己來進行延時重試控制。它本質上是對當前請求的放棄,由用戶決定是否重新發起
新的請求。 
sleep 
sleep 會阻塞當前的消息處理線程,會導致隊列的後續消息處理出現延遲。如果碰撞的比
較頻繁或者隊列裏消息比較多,sleep 可能並不合適。如果因爲個別死鎖的 key 導致加鎖不成
功,線程會徹底堵死,導致後續消息永遠得不到及時處理。 
延時隊列 
這種方式比較適合異步消息處理,將當前衝突的請求扔到另一個隊列延後處理以避開衝突

延時隊列的實現

延時隊列可以通過 Redis 的 zset(有序列表) 來實現。我們將消息序列化成一個字符串作
爲 zset 的 value,這個消息的到期處理時間作爲 score,然後用多個線程輪詢 zset 獲取到期
的任務進行處理,多個線程是爲了保障可用性,萬一掛了一個線程還有其它線程可以繼續處
理。因爲有多個線程,所以需要考慮併發爭搶任務,確保任務不能被多次執行。 


應用 3:節衣縮食 —— 位圖 

在我們平時開發過程中,會有一些 bool 型數據需要存取,比如用戶一年的簽到記錄,
簽了是 1,沒簽是 0,要記錄 365 天。如果使用普通的 key/value,每個用戶要記錄 365 
個,當用戶上億的時候,需要的存儲空間是驚人的。 
爲了解決這個問題,Redis 提供了位圖數據結構,這樣每天的簽到記錄只佔據一個位,
365 天就是 365 個位,46 個字節 (一個稍長一點的字符串) 就可以完全容納下,這就大大
節約了存儲空間。 
 

位圖不是特殊的數據結構,它的內容其實就是普通的字符串,也就是 byte 數組。我們
可以使用普通的 get/set 直接獲取和設置整個位圖的內容,也可以使用位圖操作 getbit/setbit 
等將 byte 數組看成「位數組」來處理。 
以老錢的經驗,在面試中有 Redis 位圖使用經驗的同學很少,如果你對 Redis 的位圖有
所瞭解,它將會是你的面試加分項。 

基本使用

Redis 的位數組是自動擴展,如果設置了某個偏移位置超出了現有的內容範圍,就會自
動將位數組進行零擴充。 
接下來我們使用位操作將字符串設置爲 hello (不是直接使用 set 指令),首先我們需要得
到 hello 的 ASCII 碼,用 Python 命令行可以很方便地得到每個字符的 ASCII 碼的二進制
值。 

接下來我們使用 redis-cli 設置第一個字符,也就是位數組的前 8 位,我們只需要設置
值爲 1 的位,如上圖所示,h 字符只有 1/2/4 位需要設置,e 字符只有 9/10/13/15 位需要
設置。值得注意的是位數組的順序和字符的位順序是相反的。 

 

上面這個例子可以理解爲「零存整取」,同樣我們還也可以「零存零取」,「整存零
取」。「零存」就是使用 setbit 對位值進行逐個設置,「整存」就是使用字符串一次性填充
所有位數組,覆蓋掉舊值。

\整存零取 

統計和查找 

Redis 提供了位圖統計指令 bitcount 和位圖查找指令 bitpos,bitcount 用來統計指定位
置範圍內 1 的個數,bitpos 用來查找指定範圍內出現的第一個 0 或 1。

比如我們可以通過 bitcount 統計用戶一共簽到了多少天,通過 bitpos 指令查找用戶從
哪一天開始第一次簽到。如果指定了範圍參數[start, end],就可以統計在某個時間範圍內用戶
簽到了多少天,用戶自某天以後的哪天開始簽到

魔術指令 bitfield 

前文我們設置 (setbit) 和獲取 (getbit) 指定位的值都是單個位的,如果要一次操作多個
位,就必須使用管道來處理。 不過 Redis 的 3.2 版本以後新增了一個功能強大的指令,有
了這條指令,不用管道也可以一次進行多個位的操作。 bitfield 有三個子指令,分別是 
get/set/incrby,它們都可以對指定位片段進行讀寫,但是最多隻能處理 64 個連續的位,如果
超過 64 位,就得使用多個子指令,bitfield 可以一次執行多個子指令。 

應用 4:四兩撥千斤 —— HyperLogLog 

在開始這一節之前,我們先思考一個常見的業務問題:如果你負責開發維護一個大型的
網站,有一天老闆找產品經理要網站每個網頁每天的 UV 數據,然後讓你來開發這個統計模
塊,你會如何實現

如果統計 PV 那非常好辦,給每個網頁一個獨立的 Redis 計數器就可以了,這個計數器
的 key 後綴加上當天的日期。這樣來一個請求,incrby 一次,最終就可以統計出所有的 PV 
數據。 

但是 UV 不一樣,它要去重,同一個用戶一天之內的多次訪問請求只能計數一次。這就
要求每一個網頁請求都需要帶上用戶的 ID,無論是登陸用戶還是未登陸用戶都需要一個唯一 
ID 來標識。 

你也許已經想到了一個簡單的方案,那就是爲每一個頁面一個獨立的 set 集合來存儲所
有當天訪問過此頁面的用戶 ID。當一個請求過來時,我們使用 sadd 將用戶 ID 塞進去就可
以了。通過 scard 可以取出這個集合的大小,這個數字就是這個頁面的 UV 數據。沒錯,這
是一個非常簡單的方案。 
但是,如果你的頁面訪問量非常大,比如一個爆款頁面幾千萬的 UV,你需要一個很大
的 set 集合來統計,這就非常浪費空間。如果這樣的頁面很多,那所需要的存儲空間是驚人
的。爲這樣一個去重功能就耗費這樣多的存儲空間,值得麼?其實老闆需要的數據又不需要
太精確,105w 和 106w 這兩個數字對於老闆們來說並沒有多大區別,So,有沒有更好的解
決方案呢? 

這就是本節要引入的一個解決方案,Redis 提供了 HyperLogLog 數據結構就是用來解決
這種統計問題的。HyperLogLog 提供不精確的去重計數方案,雖然不精確但是也不是非常不
精確,標準誤差是 0.81%,這樣的精確度已經可以滿足上面的 UV 統計需求了。 
HyperLogLog 數據結構是 Redis 的高級數據結構,它非常有用,但是令人感到意外的
是,使用過它的人非常少
。 

使用方法 
HyperLogLog 提供了兩個指令 pfadd 和 pfcount,根據字面意義很好理解,一個是增加
計數,一個是獲取計數。pfadd 用法和 set 集合的 sadd 是一樣的,來一個用戶 ID,就將用
戶 ID 塞進去就是。pfcount 和 scard 用法是一樣的,直接獲取計數值


 
簡單試了一下,發現還蠻精確的,一個沒多也一個沒少。接下來我們使用腳本,往裏面
灌更多的數據,看看它是否還可以繼續精確下去,如果不能精確,差距有多大

使用java代碼增加十萬個用戶,測試得到差了 277 個,按百分比是 0.277%,對於上面的 UV 統計需求來說,誤差率也不算高。
然後我們把上面的腳本再跑一邊,也就相當於將數據重複加入一邊,查看輸出,可以發現,
pfcount 的結果沒有任何改變,還是 99723,說明它確實具備去重功能。

pfadd 這個 pf 是什麼意思? 

它是 HyperLogLog 這個數據結構的發明人 Philippe Flajolet 的首字母縮寫

pfmerge 適合什麼場合用?

HyperLogLog 除了上面的 pfadd 和 pfcount 之外,還提供了第三個指令 pfmerge,用於
將多個 pf 計數值累加在一起形成一個新的 pf 值

比如在網站中我們有兩個內容差不多的頁面,運營說需要這兩個頁面的數據進行合併。
其中頁面的 UV 訪問量也需要合併,那這個時候 pfmerge 就可以派上用場了。

 注意事項 :

HyperLogLog 這個數據結構不是免費的,不是說使用這個數據結構要花錢,它需要佔據
一定 12k 的存儲空間,所以它不適合統計單個用戶相關的數據。如果你的用戶上億,可以算
算,這個空間成本是非常驚人的。但是相比 set 存儲方案,HyperLogLog 所使用的空間那真
是可以使用千斤對比四兩來形容了
不過你也不必過於當心,因爲 Redis 對 HyperLogLog 的存儲進行了優化,在計數比較
小時,它的存儲空間採用稀疏矩陣存儲,空間佔用很小,僅僅在計數慢慢變大,稀疏矩陣佔
用空間漸漸超過了閾值時纔會一次性轉變成稠密矩陣,纔會佔用 12k 的空間

應用 5:層巒疊嶂 —— 布隆過濾器 

上一節我們學會了使用 HyperLogLog 數據結構來進行估數,它非常有價值,可以解決
很多精確度不高的統計需求

 但是如果我們想知道某一個值是不是已經在 HyperLogLog 結構裏面了,它就無能爲力
了,它只提供了 pfadd 和 pfcount 方法,沒有提供 pfcontains 這種方法。 

講個使用場景,比如我們在使用新聞客戶端看新聞時,它會給我們不停地推薦新的內
容,它每次推薦時要去重,去掉那些已經看過的內容。問題來了,新聞客戶端推薦系統如何
實現推送去重的

你會想到服務器記錄了用戶看過的所有歷史記錄,當推薦系統推薦新聞時會從每個用戶
的歷史記錄裏進行篩選,過濾掉那些已經存在的記錄。問題是當用戶量很大,每個用戶看過
的新聞又很多的情況下,這種方式,推薦系統的去重工作在性能上跟的上麼

實際上,如果歷史記錄存儲在關係數據庫裏,去重就需要頻繁地對數據庫進行 exists 查
詢,當系統併發量很高時,數據庫是很難扛住壓力的。 
你可能又想到了緩存,但是如此多的歷史記錄全部緩存起來,那得浪費多大存儲空間
啊?而且這個存儲空間是隨着時間線性增長,你撐得住一個月,你能撐得住幾年麼?但是不
緩存的話,性能又跟不上,這該怎麼辦?

這時,布隆過濾器 (Bloom Filter) 閃亮登場了,它就是專門用來解決這種去重問題的。
它在起到去重的同時,在空間上還能節省 90% 以上,只是稍微有那麼點不精確,也就是有
一定的誤判概率

布隆過濾器是什麼?

布隆過濾器可以理解爲一個不怎麼精確的 set 結構,當你使用它的 contains 方法判斷某
個對象是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要參數設置的合
理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。

 套在上面的使用場景中,布隆過濾器能準確過濾掉那些已經看過的內容,那些沒有看過
的新內容,它也會過濾掉極小一部分 (誤判),但是絕大多數新內容它都能準確識別。這樣就
可以完全保證推薦給用戶的內容都是無重複的

Redis 中的布隆過濾器

Redis 官方提供的布隆過濾器到了 Redis 4.0 提供了插件(需要安裝)功能之後才正式登場。布隆過濾
器作爲一個插件加載到 Redis Server 中,給 Redis 提供了強大的布隆去重功能

布隆過濾器基本使用 
布隆過濾器有二個基本指令,bf.add 添加元素,bf.exists 查詢元素是否存在,它的用法
和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一個元素,如果想要
一次添加多個,就需要用到 bf.madd 指令。同樣如果需要一次查詢多個元素是否存在,就需
要用到 bf.mexists 指令

 執行上面的代碼後,你會張大了嘴巴發現居然沒有輸出,塞進去了 100000 個元素,還
是沒有誤判,這是怎麼回事?如果你不死心的話,可以將數字再加一個 0 試試,你會發現依
然沒有誤判。 
原因就在於布隆過濾器對於已經見過的元素肯定不會誤判,它只會誤判那些沒見過的元

所以我們要稍微改一下上面的腳本,使用 bf.exists 去查找沒見過的元素,看看它是不是
以爲自己見過了
如何降低誤差率 

我們上面使用的布隆過濾器只是默認參數的布隆過濾器,它在我們第一次 add 的時候自
動創建。Redis 其實還提供了自定義參數的布隆過濾器,需要我們在 add 之前使用 bf.reserve
指令顯式創建。如果對應的 key 已經存在,bf.reserve 會報錯。bf.reserve 有三個參數,分別 是 key, error_rate 和 initial_size。錯誤率越低,需要的空間越大。initial_size 參數表示預計放
入的元素數量,當實際數量超出這個數值時,誤判率會上升。 

所以需要提前設置一個較大的數值避免超出導致誤判率升高。如果不使用 bf.reserve,默
認的 error_rate 是 0.01,默認的 initial_size 是 100

接下來我們使用 bf.reserve 改造一下上面的腳本: 

我們看到了誤判率大約 0.012%,比預計的 0.1% 低很多,不過布隆的概率是有誤差
的,只要不比預計誤判率高太多,都是正常現象

注意事項

布隆過濾器的 initial_size 估計的過大,會浪費存儲空間,估計的過小,就會影響準確
率,用戶在使用之前一定要儘可能地精確估計好元素數量,還需要加上一定的冗餘空間以避
免實際元素可能會意外高出估計值很多。 
布隆過濾器的 error_rate 越小,需要的存儲空間就越大,對於不需要過於精確的場合,
error_rate 設置稍大一點也無傷大雅。比如在新聞去重上而言,誤判率高一點只會讓小部分文
章不能讓合適的人看到,文章的整體閱讀量不會因爲這點誤判率就帶來巨大的改變。

布隆過濾器的其它應用 

郵箱系統的垃圾郵件過濾功能也普遍用到了布隆過濾器,因爲用了這個過濾器,所以平
時也會遇到某些正常的郵件被放進了垃圾郵件目錄中,這個就是誤判所致,概率很低

在爬蟲系統中,我們需要對 URL 進行去重,已經爬過的網頁就可以不用爬了。但是 
URL 太多了,幾千萬幾個億,如果用一個集合裝下這些 URL 地址那是非常浪費空間的。這
時候就可以考慮使用布隆過濾器。它可以大幅降低去重存儲消耗,只不過也會使得爬蟲系統
錯過少量的頁面。 

應用 6:斷尾求生 —— 簡單限流 

 除了控制流量,限流還有一個應用目的是用於控制用戶行爲,避免垃圾請求。比如在 
UGC 社區,用戶的發帖、回覆、點贊等行爲都要嚴格受控,一般要嚴格限定某行爲在規定
時間內允許的次數,超過了次數那就是非法行爲。對非法行爲,業務必須規定適當的懲處策
略。 

如何使用 Redis 來實現簡單限流策略?

首先我們來看一個常見 的簡單的限流策略。系統要限定用戶的某個行爲在指定的時間裏
只能允許發生 N 次,如何使用 Redis 的數據結構來實現這個限流的功能? 

解決方案 

這個限流需求中存在一個滑動時間窗口,想想 zset 數據結構的 score 值,是不是可以
通過 score 來圈出這個時間窗口來。而且我們只需要保留這個時間窗口,窗口之外的數據都
可以砍掉。那這個 zset 的 value 填什麼比較合適呢?它只需要保證唯一性即可,用 uuid 會
比較浪費空間,那就改用毫秒時間戳吧

如圖所示,用一個 zset 結構記錄用戶的行爲歷史,每一個行爲都會作爲 zset 中的一個 
key 保存下來。同一個用戶同一種行爲用一個 zset 記錄。 
爲節省內存,我們只需要保留時間窗口內的行爲記錄,同時如果用戶是冷用戶,滑動時
間窗口內的行爲是空記錄,那麼這個 zset 就可以從內存中移除,不再佔用空間。 
通過統計滑動窗口內的行爲數量與閾值 max_count 進行比較就可以得出當前的行爲是否
允許。 

參考Redis的三種限流策略:setnx,zset以及令牌桶算法

https://blog.csdn.net/lmx125254/article/details/90700118

zset的缺點:。但這種方案也有缺點,因爲它要記錄時間窗口內所有的行爲記錄,如果這
個量很大,比如限定 60s 內操作不得超過 100w 次這樣的參數,它是不適合做這樣的限流
的,因爲會消耗大量的存儲空間

下面的令牌算法過程:


 

應用 7:一毛不拔 —— 漏斗限流 

漏斗限流是最常用的限流方法之一,顧名思義,這個算法的靈感源於漏斗(funnel)的結
構。 
 

漏洞的容量是有限的,如果將漏嘴堵住,然後一直往裏面灌水,它就會變滿,直至再也
裝不進去。如果將漏嘴放開,水就會往下流,流走一部分之後,就又可以繼續往裏面灌水。
如果漏嘴流水的速率大於灌水的速率,那麼漏斗永遠都裝不滿。如果漏嘴流水速率小於灌水
的速率,那麼一旦漏斗滿了,灌水就需要暫停並等待漏斗騰空

所以,漏斗的剩餘空間就代表着當前行爲可以持續進行的數量,漏嘴的流水速率代表着
系統允許該行爲的最大頻率

Funnel 對象的 make_space 方法是漏斗算法的核心,其在每次灌水前都會被調用以觸發
漏水,給漏斗騰出空間來。能騰出多少空間取決於過去了多久以及流水的速率。Funnel 對象
佔據的空間大小不再和行爲的頻率成正比,它的空間佔用是一個常量。 
問題來了,分佈式的漏斗算法該如何實現?能不能使用 Redis 的基礎數據結構來搞定? 
我們觀察 Funnel 對象的幾個字段,我們發現可以將 Funnel 對象的內容按字段存儲到一
個 hash 結構中,灌水的時候將 hash 結構的字段取出來進行邏輯運算後,再將新值回填到 
hash 結構中就完成了一次行爲頻度的檢測。 
但是有個問題,我們無法保證整個過程的原子性。從 hash 結構中取值,然後在內存裏
運算,再回填到 hash 結構,這三個過程無法原子化,意味着需要進行適當的加鎖控制。而
一旦加鎖,就意味着會有加鎖失敗,加鎖失敗就需要選擇重試或者放棄。 
如果重試的話,就會導致性能下降。如果放棄的話,就會影響用戶體驗。同時,代碼的
複雜度也跟着升高很多。這真是個艱難的選擇,我們該如何解決這個問題呢?Redis-Cell 救
星來了!

Redis-Cell

Redis 4.0 提供了一個限流 Redis 模塊,它叫 redis-cell。該模塊也使用了漏斗算法,並
提供了原子的限流指令。有了這個模塊,限流問題就非常簡單了

應用 8:近水樓臺 —— GeoHash

Redis 在 3.2 版本以後增加了地理位置 GEO 模塊,意味着我們可以使用 Redis 來實現
摩拜單車「附近的 Mobike」、美團和餓了麼「附近的餐館」這樣的功能了

用數據庫來算附近的人 

如果現在元素的經緯度座標使用關係數據庫 (元素 id, 經度 x, 緯度 y) 存儲,你該如何
計算? 
首先,你不可能通過遍歷來計算所有的元素和目標元素的距離然後再進行排序,這個計
算量太大了,性能指標肯定無法滿足。一般的方法都是通過矩形區域來限定元素的數量,然
後對區域內的元素進行全量距離計算再排序。這樣可以明顯減少計算量。如何劃分矩形區域呢?可以指定一個半徑 r,使用一條 SQL 就可以圈出來。當用戶對篩出來的結果不滿意,
那就擴大半徑繼續篩選

select id from positions where x0-r < x < x0+r and y0-r < y < y0+r

但是數據庫查詢性能畢竟有限,如果「附近的人」查詢請求非常多,在高併發場合,這
可能並不是一個很好的方案。 

GeoHash 算法

業界比較通用的地理位置距離排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算
法。GeoHash 算法將二維的經緯度數據映射到一維的整數,這樣所有的元素都將在掛載到一
條線上,距離靠近的二維座標映射到一維後的點之間距離也會很接近。當我們想要計算「附
近的人時」,首先將目標位置映射到這條線上,然後在這個一維的線上獲取附近的點就行

Redis 的 Geo 指令基本使用 

 Redis 提供的 Geo 指令只有 6 個,讀者們瞬間就可以掌握。使用時,讀者務必再次想
起,它只是一個普通的 zset 結構。 

 老錢也不是很能理解,爲什麼 Redis 沒有提供 geo 刪除指令,反正它就是沒有提供

 
 
獲取元素的 hash 值 

附近的公司 
georadiusbymember 指令是最爲關鍵的指令,它可以用來查詢指定元素附近的其它元
素,它的參數非常複雜

小結 & 注意事項

在一個地圖應用中,車的數據、餐館的數據、人的數據可能會有百萬千萬條,如果使用 
Redis 的 Geo 數據結構,它們將全部放在一個 zset 集合中。在 Redis 的集羣環境中,集合
可能會從一個節點遷移到另一個節點,如果單個 key 的數據過大,會對集羣的遷移工作造成
較大的影響,在集羣環境中單個 key 對應的數據量不宜超過 1M,否則會導致集羣遷移出現
卡頓現象,影響線上服務的正常運行。 

所以,這裏建議 Geo 的數據使用單獨的 Redis 實例部署,不使用集羣環境。 
如果數據量過億甚至更大,就需要對 Geo 數據進行拆分,按國家拆分、按省拆分,按
市拆分,在人口特大城市甚至可以按區拆分。這樣就可以顯著降低單個 zset 集合的大小。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章