程序員的 Redis 面試金典

學好一門技術最有價值的體現就是“面試”,對於大部分人來說 “面試”是漲薪的主要途徑之一,因此我們需要認真的準備面試,因爲它直接決定着你今後幾年內的薪資水平,所以在面試這件事上花費再多的時間和精力都是值得的。

你會發現有時候一個知識點回答的好壞能決定你的月薪是漲 500 還是漲 5000,我相信大部分人都想成爲後者,但所有人都這樣想,所以你應該出類拔萃,所以你應該學習更多的技能,所以你應該好好的準備面試,而本專欄將會很好的幫助到你。

專欄亮點

  • 不用死記硬背面試題,本專欄採用 理論 + 實踐的方式,讓你在懂得原理和代碼實踐的基礎上“順便”記住了相關的面試題;
  • 內容全面,本專欄的特點是從一個知識點入手,把該知識點相關的所有知識點和麪試題進行全面的解析
  • 深入而又詳細的講解了 Redis 中最熱門的面試問題;
  • 內容最新,本專欄是基於最新版本 Redis 6.x 開發的課程;
  • 針對面試羣體,本專欄的內容主要的目標是針對面試羣體和準備面試的羣體,提供了考題分析、知識擴展等版塊。

內容介紹

image

內容目錄

  1. Redis 屬於單線程還是多線程?不同的版本有什麼區別?
  2. Redis 如何實現限流功能?
  3. Redis 有哪些數據類型?
  4. 如何實現查詢附近的人?
  5. Redis 內存用完會怎樣?
  6. Redis 如何處理已經過期的數據?
  7. Redis 如何實現分佈式鎖?
  8. 如何保證 Redis 消息隊列中的數據不丟失?
  9. 使用 Redis 如何實現延遲消息隊列?
  10. 如何在海量數據中查詢一個值是否存在?
  11. 常用的 Redis 優化手段有哪些?
  12. 如何設計不宕機的 Redis 高可用服務?
  13. Redis 面試題補充與彙總

你將收穫什麼?

  1. Redis 線程模型的相關知識點;
  2. Redis 數據類型的相關知識:String、Hash、List、Set、ZSet、GEO、HyperLogLog、Stream;
  3. Redis 限流算法:漏桶算法、令牌算法介紹和具體的代碼實現;
  4. Redis 內存淘汰策略和算法;
  5. Redis 鍵值過期淘汰策略;
  6. 分佈式鎖的相關知識點和具體代碼實現;
  7. Redis 消息隊列實現的 4 種方法還有延遲消息隊列的具體實現;
  8. Redis 常見的 10+ 種優化手段;
  9. Redis 主從、哨兵、集羣相關知識。

作者介紹

王磊 GitChat 暢銷作者、2019 年騰訊雲最佳年度作者,十餘年編程從業經驗,曾就職 360,有着豐富的系統設計、開發和調優的經驗,在不斷探索和學習的過程中,積累了寶貴的經驗,希望以技術傳播爲使命,幫助更多的人在技術的世界裏持續精進。

本專欄是我結合自己近十年使用 Redis 的經驗,曾依靠 Redis 爲多個大廠,如騰訊遊戲、360 遊戲、迅雷遊戲、多玩、17173、遊久等知名公司,提供了數據支持的經驗開發了這門專欄。

適宜人羣

  1. 準備跳槽的後端工程師(初、中、高級)
  2. 即將畢業的在校學生
  3. 自學的準備轉行的“準程序員”

購買須知

  • 本專欄爲圖文內容,共計 13 篇。每週更新 2 篇,預計 7 月中旬更新完畢。
  • 付費用戶可享受文章永久閱讀權限。
  • 本專欄爲虛擬產品,一經付費概不退款,敬請諒解。
  • 本專欄可在 GitChat 服務號、App 及網頁端 gitbook.cn 上購買,一端購買,多端閱讀。

訂閱福利

  • 訂購本專欄可獲得專屬海報(在 GitChat 服務號領取),分享專屬海報每成功邀請一位好友購買,即可獲得 25% 的返現獎勵,多邀多得,上不封頂,立即提現。

  • 提現流程:在 GitChat 服務號中點擊「我-我的邀請-提現」。

  • ①點擊這裏跳轉至》第 3 篇《翻閱至文末獲得入羣口令。

  • ②購買本專欄後,服務號會自動彈出入羣二維碼和暗號。如果你沒有收到那就先關注微信服務號「GitChat」,或者加我們的小助手「GitChatty6」諮詢。

課程內容

Redis 屬於單線程還是多線程?不同的版本有什麼區別?

Redis 是普及率最高的技術之一,同時也是面試中必問的一個技術模塊,所以從今天開始我們將從最熱門的 Redis 面試題入手,更加深入的學習和了解一下 Redis。

我們本文的面試題是 Redis 屬於單線程還是多線程?

典型回答

本文的問題在不同的 Redis 版本下答案是不同的,在 Redis 4.0 之前,Redis 是單線程運行的,但單線程並不意味着性能低,類似單線程的程序還有 Nginx 和 NodeJs 他們都是單線程程序,但是效率並不低。Redis 的 FAQ(Frequently Asked Questions,常見問題)也回到過這個問題,具體內容如下:

Redis is single threaded. How can I exploit multiple CPU / cores?

It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

However, to maximize CPU usage you can start multiple instances of Redis in the same box and treat them as different servers. At some point a single box may not be enough anyway, so if you want to use multiple CPUs you can start thinking of some way to shard earlier.

You can find more information about using multiple Redis instances in the Partitioning page.

However with Redis 4.0 we started to make Redis more threaded. For now this is limited to deleting objects in the background, and to blocking commands implemented via Redis modules. For future releases, the plan is to make Redis more and more threaded.

詳情請見:https://redis.io/topics/faq

他的大體意思是說 Redis 是基於內存操作的,因此他的瓶頸可能是機器的內存或者網絡帶寬而並非 CPU,既然 CPU 不是瓶頸,那麼自然就採用單線程的解決方案了,況且使用多線程比較麻煩。但是在 Redis 4.0 中開始支持多線程了,例如後臺刪除等功能。 

簡單來說 Redis 之所以在 4.0 之前一直採用單線程的模式是因爲以下三個原因:

  • 使用單線程模型是 Redis 的開發和維護更簡單,因爲單線程模型方便開發和調試;
  • 即使使用單線程模型也併發的處理多客戶端的請求,主要使用的是多路複用(詳見本文下半部分);
  • 對於 Redis 系統來說,主要的性能瓶頸是內存或者網絡帶寬而並非 CPU。

Redis 在 4.0 中引入了惰性刪除(也可以叫異步刪除),意思就是說我們可以使用異步的方式對 Redis 中的數據進行刪除操作了,例如 unlink key / flushdb async / flushall async 等命令,他們的執行示例如下:

> unlink key # 後臺刪除某個 key> OK # 執行成功> flushall async # 清空所有數據> OK # 執行成功

這樣處理的好處是不會導致 Redis 主線程卡頓,會把這些刪除操作交給後臺線程來執行。

小貼士:通常情況下使用 del 指令可以很快的刪除數據,而當被刪除的 key 是一個非常大的對象時,例如時包含了成千上萬個元素的 hash 集合時,那麼 del 指令就會造成 Redis 主線程卡頓,因此使用惰性刪除可以有效的避免 Redis 卡頓的問題。

考點分析

關於 Redis 線程模型的問題(單線程或多線程)幾乎是 Redis 必問的問題之一,但能回答好的人卻寥寥無幾,大部分的人只能回到上來 Redis 是單線程的以及說出來單線程的衆多好處,但對於 Redis 4.0 和 Redis 6.0 中,尤其是 Redis 6.0 中的多線程能回答上來的人少之又少,和這個知識點相關的面試題還有以下這些。

  • Redis 主線程既然是單線程的,爲什麼還這麼快?
  • 介紹一下 Redis 中的多路複用?
  • 介紹一下 Redis 6.0 中的多線程?

知識擴展

1.Redis 爲什麼這麼快?

我們知道 Redis 4.0 之前是單線程的,那既然是單線程爲什麼還能這麼快?

Redis 速度比較快的原因有以下幾點:

  • 基於內存操作:Redis 的所有數據都存在內存中,因此所有的運算都是內存級別的,所以他的性能比較高;
  • 數據結構簡單:Redis 的數據結構比較簡單,是爲 Redis 專門設計的,而這些簡單的數據結構的查找和操作的時間複雜度都是 O(1),因此性能比較高;
  • 多路複用和非阻塞 I/O:Redis 使用 I/O 多路複用功能來監聽多個 socket 連接客戶端,這樣就可以使用一個線程連接來處理多個請求,減少線程切換帶來的開銷,同時也避免了 I/O 阻塞操作,從而大大提高了 Redis 的性能;
  • 避免上下文切換:因爲是單線程模型,因此就避免了不必要的上下文切換和多線程競爭,這就省去了多線程切換帶來的時間和性能上的消耗,而且單線程不會導致死鎖問題的發生。

官方使用基準測試的結果是,單線程的 Redis 吞吐量可以達到 10W/每秒,如下圖所示:image.png

2.I/O 多路複用

套接字的讀寫方法默認情況下是阻塞的,例如當調用讀取操作 read 方法時,緩衝區沒有任何數據,那麼這個線程就會阻塞卡在這裏,直到緩衝區有數據或者是連接被關閉時,read 方法纔可以返回,線程纔可以繼續處理其他業務。

但這樣顯然降低了程序的整體執行效率,而 Redis 使用的就是非阻塞的 I/O,這就意味着 I/O 的讀寫流程不再是阻塞的,讀寫方法都是瞬間完成並返回的,也就是他會採用能讀多少讀多少能寫多少寫多少的策略來執行 I/O 操作,這顯然更符合我們對性能的追求。

但這種非阻塞的 I/O 依然存在一個問題,那就是當我們執行讀取數據操作時,有可能只讀取了一部分數據,同樣寫入數據也是這種情況,當緩存區滿了之後我們的數據還沒寫完,剩餘的數據何時寫何時讀就成了一個問題。

而 I/O 多路複用就是解決上面的這個問題的,使用 I/O 多路複用最簡單的實現方式就是使用 select 函數,此函數爲操作系統提供給用戶程序的 API 接口,是用於監控多個文件描述符的可讀和可寫情況的,這樣就可以監控到文件描述符的讀寫事件了,當監控到相應的事件之後就可以通知線程處理相應的業務了,這樣就保證了 Redis 讀寫功能的正常執行了。

I/O 多路複用執行流程如下圖所示:image.png

小貼士:現在的操作系統已經很少使用 select 函數了,改爲調用 epoll(linux)和 kqueue(MacOS)等函數了,因爲 select 函數在文件描述符特別多時性能非常的差。

3.Redis 6.0 多線程

Redis 單線程的優點很明顯,不但降低了 Redis 內部的實現複雜性,也讓所有操作都可以在無鎖的情況下進行操作,並且不存在死鎖和線程切換帶來的性能和時間上的消耗,但缺點也很明顯,單線程的機制導致 Redis 的 QPS(Query Per Second,每秒查詢率)很難得到有效的提高。

Redis 4.0 版本中雖然引入了多線程,但此版本中的多線程只能用於大數據量的異步刪除,然而對於非刪除操作的意義並不是很大。

如果我們使用多線程就可以分攤 Redis 同步讀寫 I/O 的壓力,以及充分的利用多核 CPU 的資源,並且可以有效的提升 Redis 的 QPS。在 Redis 中雖然使用了 I/O 多路複用,並且是基於非阻塞 I/O 進行操作的,但 I/O 的讀和寫本身是堵塞的,比如當 socket 中有數據時,Redis 會通過調用先將數據從內核態空間拷貝到用戶態空間,再交給 Redis 調用,而這個拷貝的過程就是阻塞的,當數據量越大時拷貝所需要的時間就越多,而這些操作都是基於單線程完成的。

因此在 Redis 6.0 中新增了多線程的功能來提高 I/O 的讀寫性能,他的主要實現思路是將主線程的 IO 讀寫任務拆分給一組獨立的線程去執行,這樣就可以使多個 socket 的讀寫可以並行化了,但 Redis 的命令依舊是由主線程串行執行的。

需要注意的是 Redis 6.0 默認是禁用多線程的,可以通過修改 Redis 的配置文件 redis.conf 中的 io-threads-do-reads 等於 true 來開啓多線程,完整配置爲 io-threads-do-reads true,除此之外我們還需要設置線程的數量才能正確的開啓多線程的功能,同樣是修改 Redis 的配置,例如設置 io-threads 4 表示開啓 4 個線程。

小貼士:關於線程數的設置,官方的建議是如果爲 4 核的 CPU,建議線程數設置爲 2 或 3,如果爲 8 核 CPU 建議線程數設置爲 6,線程數一定要小於機器核數,線程數並不是越大越好。

關於 Redis 的性能,Redis 作者 antirez 在 RedisConf 2019 分享時曾提到,Redis 6 引入的多線程 I/O 特性對性能提升至少是一倍以上。國內也有人在阿里雲使用 4 個線程的 Redis 版本和單線程的 Redis 進行比較測試,發現測試的結果和 antirez 給出的結論基本吻合,性能基本可以提高一倍。

總結

本文我們介紹了 Redis 在 4.0 之前單線程依然很快的原因:基於內存操作、數據結構簡單、多路複用和非阻塞 I/O、避免了不必要的線程上下文切換,在 Redis 4.0 中已經添加了多線程的支持,主要體現在大數據的異步刪除功能上,例如 unlink key、flushdb async、flushall async 等,Redis 6.0 新增了多線程 I/O 的讀寫併發能力,用於更好的提高 Redis 的性能。

Redis 有哪些數據類型?

Redis 的數據類型可謂是 Redis 的精華所在,同樣的數據類型,例如字符串存儲不同的值對應的實際存儲結構也是不同,當你存儲的 int 值是實際的存儲結構也是 int,如果是短字符串(小於 44 字節)實際存儲的結構爲 embstr,長字符串對應的實際存儲結構是 raw,這樣設計的目的是爲了更好的節約內存。

我們本文的面試題是 Redis 有哪些數據類型?

典型回答

Redis 最常用的數據類型有 5 種:String(字符串類型)、Hash(字典類型)、List(列表類型)、Set(集合類型)、ZSet(有序集合類型)。

1.字符串類型

字符串類型(Simple Dynamic Strings 簡稱 SDS),譯爲:簡單動態字符串,它是以鍵值對 key-value 的形式進行存儲的,根據 key 來存儲和獲取 value 值,它的使用相對來說比較簡單,但在實際項目中應用非常廣泛。

字符串的使用如下:

127.0.0.1:6379> set k1 v1 # 添加數據 OK127.0.0.1:6379> get k1 # 查詢數據"v1"127.0.0.1:6379> strlen k1 # 查詢字符串的長度(integer) 5

我們也可以在存儲字符串時設置鍵值的過期時間,如下代碼所示:

127.0.0.1:6379> set k1 v1 ex 1000 # 設置 k1 1000s 後過期(刪除)OK

我們還可以使用 SDS 來存儲 int 類型的值,並且可以使用 incr 指令和 decr 指令來操作存儲的值 +1 或者 -1,具體實現代碼如下:

127.0.0.1:6379> get k1 # 查詢 k1=3"3"127.0.0.1:6379> incr k1 # 執行 +1 操作(integer) 4127.0.0.1:6379> get k1 # 查詢 k1=4"4"127.0.0.1:6379> decr k1 # 執行 -1 操作(integer) 3127.0.0.1:6379> get k1 # 查詢 k1=3"3"

字符串的常見使用場景:

  • 存放用戶(登錄)信息;
  • 存放文章詳情和列表信息;
  • 存放和累計網頁的統計信息(存儲 int 值)。

……

2.字典類型

字典類型 (Hash) 又被成爲散列類型或者是哈希表類型,它是將一個鍵值 (key) 和一個特殊的“哈希表”關聯起來,這個“哈希表”表包含兩列數據:字段和值。例如我們使用字典類型來存儲一篇文章的詳情信息,存儲結構如下圖所示:哈希表存儲結構.png同理我們也可以使用字典類型來存儲用戶信息,並且使用字典類型來存儲此類信息就無需手動序列化和反序列化數據了,所以使用起來更加的方便和高效。

字典類型的使用如下:

127.0.0.1:6379> hset myhash key1 value1 # 添加數據(integer) 1127.0.0.1:6379> hget myhash key1 # 查詢數據"value1"

字典類型的數據結構,如下圖所示:

Redis-HashType-02.png

通常情況下字典類型會使用數組的方式來存儲相關的數據,但發生哈希衝突時纔會使用鏈表的結構來存儲數據。

3.列表類型

列表類型 (List) 是一個使用鏈表結構存儲的有序結構,它的元素插入會按照先後順序存儲到鏈表結構中,因此它的元素操作 (插入和刪除) 時間複雜度爲 O(1),所以相對來說速度還是比較快的,但它的查詢時間複雜度爲 O(n),因此查詢可能會比較慢。

列表類型的使用如下:

127.0.0.1:6379> lpush list 1 2 3 # 添加數據(integer) 3127.0.0.1:6379> lpop list # 獲取並刪除列表的第一個元素1

列表的典型使用場景有以下兩個:

  • 消息隊列:列表類型可以使用 rpush 實現先進先出的功能,同時又可以使用 lpop 輕鬆的彈出(查詢並刪除)第一個元素,所以列表類型可以用來實現消息隊列;
  • 文章列表:對於博客站點來說,當用戶和文章都越來越多時,爲了加快程序的響應速度,我們可以把用戶自己的文章存入到 List 中,因爲 List 是有序的結構,所以這樣又可以完美的實現分頁功能,從而加速了程序的響應速度。

4.集合類型

集合類型 (Set) 是一個無序並唯一的鍵值集合。

集合類型的使用如下:

127.0.0.1:6379> sadd myset v1 v2 v3 # 添加數據(integer) 3127.0.0.1:6379> smembers myset # 查詢集合中的所有數據1) "v1"2) "v3"3) "v2"

集合類型的經典使用場景如下:

  • 微博關注我的人和我關注的人都適合用集合存儲,可以保證人員不會重複;
  • 中獎人信息也適合用集合類型存儲,這樣可以保證一個人不會重複中獎。

集合類型(Set)和列表類型(List)的區別如下:

  • 列表可以存儲重複元素,集合只能存儲非重複元素;
  • 列表是按照元素的先後順序存儲元素的,而集合則是無序方式存儲元素的。

5.有序集合類型

有序集合類型 (Sorted Set) 相比於集合類型多了一個排序屬性 score(分值),對於有序集合 ZSet 來說,每個存儲元素相當於有兩個值組成的,一個是有序結合的元素值,一個是排序值。有序集合的存儲元素值也是不能重複的,但分值是可以重複的。

當我們把學生的成績存儲在有序集合中時,它的存儲結構如下圖所示:

學生存儲值.png

有序集合類型的使用如下:

127.0.0.1:6379> zadd zset1 3 golang 4 sql 1 redis # 添加數據(integer) 3127.0.0.1:6379> zrange zset 0 -1 # 查詢所有數據1) "redis"2) "mysql"3) "java"

有序集合的經典使用場景如下:

  • 學生成績排名;
  • 粉絲列表,根據關注的先後時間排序。

考點分析

關於 Redis 數據類型的這個問題,對於大多數人既熟悉又陌生,熟悉的是每天都在使用 Redis 存取數據,陌生的是對於 Redis 的數據類型知之甚少,因爲對於普通的開發工作使用字符串類型就可以搞定了。但是善用 Redis 的數據類型可以到達意想不到的效果,不但可以提高程序的運行速度又可以減少業務代碼,可謂一舉兩得。

例如我們經常會把用戶的登錄信息存儲在 Redis 中,但通常的做法是先將用戶登錄實體類轉爲 JSON 字符串存儲在 Redis 中,然後讀取時先查詢數據再反序列化爲 User 對象,這個過程看似沒什麼問題,但我們可以有更優的解決方案來處理此問題,比如我們可以使用 Hash 存儲用戶的信息,這樣就無需序列化的過程了,並且讀取之後無需反序列化,直接使用 Map 來接收就可以了,這樣既提高了程序的運行速度有省去了序列化和反序列化的業務代碼。

與此知識點相關的面試題還有以下幾個:

  • 有序列表的實際存儲結構是什麼?
  • 除了五種基本的數據類型之外,還有什麼數據類型?

知識擴展

有序列表的內部實現

有序集合是由 ziplist (壓縮列表) 或 skiplist (跳躍表) 組成的。

ziplist 介紹

當數據比較少時,有序集合使用的是 ziplist 存儲的,如下代碼所示:

127.0.0.1:6379> zadd myzset 1 db 2 redis 3 mysql(integer) 3127.0.0.1:6379> object encoding myzset"ziplist"

從結果可以看出,有序集合把 myset 鍵值對存儲在 ziplist 結構中了。有序集合使用 ziplist 格式存儲必須滿足以下兩個條件:

  • 有序集合保存的元素個數要小於 128 個;
  • 有序集合保存的所有元素成員的長度都必須小於 64 字節。

如果不能滿足以上兩個條件中的任意一個,有序集合將會使用 skiplist 結構進行存儲。接下來我們來測試以下,當有序集合中某個元素長度大於 64 字節時會發生什麼情況?代碼如下:

127.0.0.1:6379> zadd zmaxleng 1.0 redis(integer) 1127.0.0.1:6379> object encoding zmaxleng"ziplist"127.0.0.1:6379> zadd zmaxleng 2.0 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(integer) 1127.0.0.1:6379> object encoding zmaxleng"skiplist"

通過以上代碼可以看出,當有序集合保存的所有元素成員的長度大於 64 字節時,有序集合就會從 ziplist 轉換成爲 skiplist。

小貼士:可以通過配置文件中的 zset-max-ziplist-entries(默認 128)和 zset-max-ziplist-value(默認 64)來設置有序集合使用 ziplist 存儲的臨界值。

skiplist 介紹

skiplist 數據編碼底層是使用 zset 結構實現的,而 zset 結構中包含了一個字典和一個跳躍表,源碼如下:

typedef struct zset {    dict *dict;    zskiplist *zsl;} zset;

跳躍表的結構如下圖所示:有序集合-跳躍表.png

根據以上圖片展示,當我們在跳躍表中查詢值 32 時,執行流程如下:

  • 從最上層開始找,1 比 32 小,在當前層移動到下一個節點進行比較;
  • 7 比 32 小,當前層移動下一個節點比較,由於下一個節點指向 Null,所以以 7 爲目標,移動到下一層繼續向後比較;
  • 18 小於 32,繼續向後移動查找,對比 77 大於 32,以 18 爲目標,移動到下一層繼續向後比較;
  • 對比 32 等於 32,值被順利找到。

從上面的流程可以看出,跳躍表會想從最上層開始找起,依次向後查找,如果本層的節點大於要找的值,或者本層的節點爲 Null 時,以上一個節點爲目標,往下移一層繼續向後查找並循環此流程,直到找到該節點並返回,如果對比到最後一個元素仍未找到,則返回 Null。

高級數據類型

除了有 5 大基本數據類型外,還有 GEO(地理位置類型)、HyperLogLog(統計類型)、Stream(流類型)。

GEO(地理位置類型)是 Redis 3.2 版本中新增的數據類型,用於存儲和查詢地理位置的,使用它我們可以實現查詢附近的人或查詢附近的商家等功能(這部分的內容會在後面的章節單獨講解)。

Stream(流類型)是 Redis 5.0 版本中新增的數據類型,因爲使用 Stream 可以實現消息消費確認的功能,使用“xack key group-key ID”命令,所以此類型的出現給 Redis 更好的實現消息隊列提供了很大的幫助。

HyperLogLog(統計類型)是本文介紹的重點,HyperLogLog (下文簡稱爲 HLL) 是 Redis 2.8.9 版本添加的數據結構,它用於高性能的基數 (去重) 統計功能,它的缺點就是存在極低的誤差率。

HLL 具有以下幾個特點:

  • 能夠使用極少的內存來統計巨量的數據,它只需要 12K 空間就能統計 2^64 的數據;
  • 統計存在一定的誤差,誤差率整體較低,標準誤差爲 0.81%;
  • 誤差可以被設置輔助計算因子進行降低。

HLL 的命令只有 3 個,但都非常的實用,下面分別來看。

1.添加元素

127.0.0.1:6379> pfadd key "redis"(integer) 1127.0.0.1:6379> pfadd key "java" "sql"(integer) 1

相關語法: pfadd key element [element ...]此命令支持添加一個或多個元素至 HLL 結構中。

2.統計不重複的元素

127.0.0.1:6379> pfadd key "redis"(integer) 1127.0.0.1:6379> pfadd key "sql"(integer) 1127.0.0.1:6379> pfadd key "redis"(integer) 0127.0.0.1:6379> pfcount key(integer) 2

從 pfcount 的結果可以看出,在 HLL 結構中鍵值爲 key 的元素, 有 2 個不重複的值:redis 和 sql,可以看出結果還是挺準的。相關語法: pfcount key [key ...]
此命令支持統計一個或多個 HLL 結構。

3.合併一個或多個 HLL 至新結構

新增 k 和 k2 合併至新結構 k3 中,代碼如下:

127.0.0.1:6379> pfadd k "java" "sql"(integer) 1127.0.0.1:6379> pfadd k2 "redis" "sql"(integer) 1127.0.0.1:6379> pfmerge k3 k k2OK127.0.0.1:6379> pfcount k3(integer) 3

相關語法:pfmerge destkey sourcekey [sourcekey ...]**pfmerge 使用場景:當我們需要合併兩個或多個同類頁面的訪問數據時,我們可以使用 pfmerge 來操作。

總結

本文我們介紹了 Redis 的 5 大基礎數據類型的概念以及簡單的使用:String(字符串類型)、Hash(字典類型)、List(列表類型)、Set(集合類型)、ZSet(有序集合類型),還深入的介紹了 ZSet 的底層數據存儲結構:ziplist (壓縮列表) 或 skiplist (跳躍表)。除此之外我們還介紹了 Redis 中的提前 3 個高級的數據類型:GEO(地理位置類型)用於實現查詢附近的人、HyperLogLog(統計類型)用於高效的實現數據的去重統計(存在一定的誤差)、Stream(流類型)主要應用於消息隊列的實現。

使用 Redis 如何實現延遲隊列?

延遲消息隊列在我們的日常工作中經常會被用到,比如支付系統中超過 30 分鐘未支付的訂單,將會被取消,這樣就可以保證此商品庫存可以釋放給其他人購買,還有外賣系統如果商家超過 5 分鐘未接單的訂單,將會被自動取消,以此來保證用戶可以更及時的喫到自己點的外賣,等等諸如此類的業務場景都需要使用到延遲消息隊列,又因爲它在業務中比較常見,因此這個知識點在面試中也會經常被問到。

我們本文的面試題是,使用 Redis 如何實現延遲消息隊列?

典型回答

延遲消息隊列的常見實現方式是通過 ZSet 的存儲於查詢來實現,它的核心思想是在程序中開啓一個一直循環的延遲任務的檢測器,用於檢測和調用延遲任務的執行,如下圖所示:image.pngZSet 實現延遲任務的方式有兩種,第一種是利用 zrangebyscore 查詢符合條件的所有待處理任務,循環執行隊列任務;第二種實現方式是每次查詢最早的一條消息,判斷這條信息的執行時間是否小於等於此刻的時間,如果是則執行此任務,否則繼續循環檢測。

方式一:zrangebyscore 查詢所有任務此實現方式是一次性查詢出所有的延遲任務,然後再進行執行,實現代碼如下:

import redis.clients.jedis.Jedis;import utils.JedisUtils;import java.time.Instant;import java.util.Set;/** * 延遲隊列 */public class DelayQueueExample {    // zset key    private static final String _KEY = "myDelayQueue";    public static void main(String[] args) throws InterruptedException {        Jedis jedis = JedisUtils.getJedis();        // 延遲 30s 執行(30s 後的時間)        long delayTime = Instant.now().plusSeconds(30).getEpochSecond();        jedis.zadd(_KEY, delayTime, "order_1");        // 繼續添加測試數據        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_2");        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_3");        jedis.zadd(_KEY, Instant.now().plusSeconds(7).getEpochSecond(), "order_4");        jedis.zadd(_KEY, Instant.now().plusSeconds(10).getEpochSecond(), "order_5");        // 開啓延遲隊列        doDelayQueue(jedis);    }    /**     * 延遲隊列消費     * @param jedis Redis 客戶端     */    public static void doDelayQueue(Jedis jedis) throws InterruptedException {        while (true) {            // 當前時間            Instant nowInstant = Instant.now();            long lastSecond = nowInstant.plusSeconds(-1).getEpochSecond(); // 上一秒時間            long nowSecond = nowInstant.getEpochSecond();            // 查詢當前時間的所有任務            Set<String> data = jedis.zrangeByScore(_KEY, lastSecond, nowSecond);            for (String item : data) {                // 消費任務                System.out.println("消費:" + item);            }            // 刪除已經執行的任務            jedis.zremrangeByScore(_KEY, lastSecond, nowSecond);            Thread.sleep(1000); // 每秒輪詢一次        }    }}

以上程序執行結果如下:

消費:order2消費:order3 消費:order4消費:order5 消費:order_1

方式二:判斷最早的任務此實現方式是每次查詢最早的一條任務,再與當前時間進行判斷,如果任務執行時間大於當前時間則表示應該立即執行延遲任務,實現代碼如下:

import redis.clients.jedis.Jedis;import utils.JedisUtils;import java.time.Instant;import java.util.Set;/** * 延遲隊列 */public class DelayQueueExample {    // zset key    private static final String _KEY = "myDelayQueue";    public static void main(String[] args) throws InterruptedException {        Jedis jedis = JedisUtils.getJedis();        // 延遲 30s 執行(30s 後的時間)        long delayTime = Instant.now().plusSeconds(30).getEpochSecond();        jedis.zadd(_KEY, delayTime, "order_1");        // 繼續添加測試數據        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_2");        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_3");        jedis.zadd(_KEY, Instant.now().plusSeconds(7).getEpochSecond(), "order_4");        jedis.zadd(_KEY, Instant.now().plusSeconds(10).getEpochSecond(), "order_5");        // 開啓延遲隊列        doDelayQueue2(jedis);    }    /**     * 延遲隊列消費(方式 2)     * @param jedis Redis 客戶端     */    public static void doDelayQueue2(Jedis jedis) throws InterruptedException {        while (true) {            // 當前時間            long nowSecond = Instant.now().getEpochSecond();            // 每次查詢一條消息,判斷此消息的執行時間            Set<String> data = jedis.zrange(_KEY, 0, 0);            if (data.size() == 1) {                String firstValue = data.iterator().next();                // 消息執行時間                Double score = jedis.zscore(_KEY, firstValue);                if (nowSecond >= score) {                    // 消費消息(業務功能處理)                    System.out.println("消費消息:" + firstValue);                    // 刪除已經執行的任務                    jedis.zrem(_KEY, firstValue);                }            }            Thread.sleep(100); // 執行間隔        }    }}

以上程序執行結果和實現方式一相同,結果如下:

消費:order2消費:order3 消費:order4消費:order5 消費:order_1

其中,執行間隔代碼 Thread.sleep(100) 可根據實際的業務情況刪減或配置。

考點分析

延遲消息隊列的實現方法有很多種,不同的公司可能使用的技術也是不同的,我上面是從 Redis 的角度出發來實現了延遲消息隊列,但一般面試官不會就此罷休,會藉着這個問題來問關於更多的延遲消息隊列的實現方法,因此除了 Redis 實現延遲消息隊列的方式,我們還需要具備一些其他的常見的延遲隊列的實現方法。

和此知識點相關的面試題還有以下這些:

  • 使用 Java 語言如何實現一個延遲消息隊列?
  • 你還知道哪些實現延遲消息隊列的方法?

知識擴展

Java 中的延遲消息隊列

我們可以使用 Java 語言中自帶的 DelayQueue 數據類型來實現一個延遲消息隊列,實現代碼如下:

public class DelayTest {    public static void main(String[] args) throws InterruptedException {        DelayQueue delayQueue = new DelayQueue();        delayQueue.put(new DelayElement(1000));        delayQueue.put(new DelayElement(3000));        delayQueue.put(new DelayElement(5000));        System.out.println("開始時間:" +  DateFormat.getDateTimeInstance().format(new Date()));        while (!delayQueue.isEmpty()){            System.out.println(delayQueue.take());        }        System.out.println("結束時間:" +  DateFormat.getDateTimeInstance().format(new Date()));    }    static class DelayElement implements Delayed {        // 延遲截止時間(單面:毫秒)        long delayTime = System.currentTimeMillis();        public DelayElement(long delayTime) {            this.delayTime = (this.delayTime + delayTime);        }        @Override        // 獲取剩餘時間        public long getDelay(TimeUnit unit) {            return unit.convert(delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);        }        @Override        // 隊列裏元素的排序依據        public int compareTo(Delayed o) {            if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) {                return 1;            } else if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) {                return -1;            } else {                return 0;            }        }        @Override        public String toString() {            return DateFormat.getDateTimeInstance().format(new Date(delayTime));        }    }}

以上程序執行的結果如下:

開始時間:2019-6-13 20:40:38 2019-6-13 20:40:39 2019-6-13 20:40:41 2019-6-13 20:40:43 結束時間:2019-6-13 20:40:43

此實現方式的優點是開發比較方便,可以直接在代碼中使用,實現代碼也比較簡單,但它缺點是數據保存在內存中,因此可能存在數據丟失的風險,最大的問題是它無法支持分佈式系統。

使用 MQ 實現延遲消息隊列

我們使用主流的 MQ 中間件也可以方便的實現延遲消息隊列的功能,比如 RabbitMQ,我們可以通過它的 rabbitmq-delayed-message-exchange 插件來實現延遲隊列。

首先我們需要配置並開啓 rabbitmq-delayed-message-exchange 插件,然後再通過以下代碼來實現延遲消息隊列。

配置消息隊列:

import com.example.rabbitmq.mq.DirectConfig;import org.springframework.amqp.core.*;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import java.util.HashMap;import java.util.Map;@Configurationpublic class DelayedConfig {    final static String QUEUE_NAME = "delayed.goods.order";    final static String EXCHANGE_NAME = "delayedec";    @Bean    public Queue queue() {        return new Queue(DelayedConfig.QUEUE_NAME);    }    // 配置默認的交換機    @Bean    CustomExchange customExchange() {        Map<String, Object> args = new HashMap<>();        args.put("x-delayed-type", "direct");        //參數二爲類型:必須是 x-delayed-message        return new CustomExchange(DelayedConfig.EXCHANGE_NAME, "x-delayed-message", true, false, args);    }    // 綁定隊列到交換器    @Bean    Binding binding(Queue queue, CustomExchange exchange) {        return BindingBuilder.bind(queue).to(exchange).with(DelayedConfig.QUEUE_NAME).noargs();    }}

發送者實現代碼如下:

import org.springframework.amqp.AmqpException;import org.springframework.amqp.core.AmqpTemplate;import org.springframework.amqp.core.Message;import org.springframework.amqp.core.MessagePostProcessor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import java.text.SimpleDateFormat;import java.util.Date;@Componentpublic class DelayedSender {    @Autowired    private AmqpTemplate rabbitTemplate;    public void send(String msg) {        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");        System.out.println("發送時間:" + sf.format(new Date()));        rabbitTemplate.convertAndSend(DelayedConfig.EXCHANGE_NAME, DelayedConfig.QUEUE_NAME, msg, new MessagePostProcessor() {            @Override            public Message postProcessMessage(Message message) throws AmqpException {                message.getMessageProperties().setHeader("x-delay", 3000);                return message;            }        });    }}

從上述代碼我們可以看出,我們配置 3s 之後再進行任務執行。

消費者實現代碼如下:

import org.springframework.amqp.rabbit.annotation.RabbitHandler;import org.springframework.amqp.rabbit.annotation.RabbitListener;import org.springframework.stereotype.Component;import java.text.SimpleDateFormat;import java.util.Date;@Component@RabbitListener(queues = "delayed.goods.order")public class DelayedReceiver {    @RabbitHandler    public void process(String msg) {        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");        System.out.println("接收時間:" + sdf.format(new Date()));        System.out.println("消息內容:" + msg);    }}

測試代碼如下:

import com.example.rabbitmq.RabbitmqApplication;import com.example.rabbitmq.mq.delayed.DelayedSender;import org.junit.Test;import org.junit.runner.RunWith;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.junit4.SpringRunner;import java.text.SimpleDateFormat;import java.util.Date;@RunWith(SpringRunner.class)@SpringBootTestpublic class DelayedTest {    @Autowired    private DelayedSender sender;    @Test    public void Test() throws InterruptedException {        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd");        sender.send("Hi Admin.");        Thread.sleep(5 * 1000); //等待接收程序執行之後,再退出測試    }}

以上程序的執行結果爲:

發送時間:2020-06-11 20:47:51 接收時間:2018-06-11 20:47:54 消息內容:Hi Admin.

從上述結果中可以看出,當消息進入延遲隊列 3s 之後才被正常消費,執行結果符合我的預期,RabbitMQ 成功的實現了延遲消息隊列。

總結

本文我們講了延遲消息隊列的兩種使用場景:支付系統中的超過 30 分鐘未支付的訂單,將會被自動取消,以此來保證此商品的庫存可以正常釋放給其他人購買,還有外賣系統如果商家超過 5 分鐘未接單的訂單,將會被自動取消,以此來保證用戶可以更及時的喫到自己點的外賣。並且我們講了延遲隊列的 4 種實現方式,使用 ZSet 的 2 種實現方式,以及 Java 語言中的 DelayQueue 的實現方式,還有 RabbitMQ 的插件 rabbitmq-delayed-message-exchange 的實現方式。

如何實現查詢附近的人?
Redis 如何實現限流功能?
Redis 如何處理已經過期的數據?
Redis 內存用完會怎樣?
Redis 如何實現分佈式鎖?
如何保證 Redis 消息隊列中的數據不丟失?
如何在海量數據中查詢一個值是否存在?
常用的 Redis 優化手段有哪些?
如何設計不宕機的 Redis 高可用服務?
Redis 面試題補充與彙總

閱讀全文: http://gitbook.cn/gitchat/column/5ee1d22c4a99494972797132

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