Redis 6 的用戶端快取
[注意:本文已不再描述 Redis 6 最終實作中的 client side 實作,該實作已有大幅變更,請參見 https://redis.io/topics/client-side-caching]
紐約的 Redis Day 剛結束,我在飯店早上五點半起床,生理時鐘仍大致與義大利時區同步,便立刻走到曼哈頓的街上散步,完全沉醉於眼前的風景,以及那種身為數百萬個數字中僅僅一個數字的奇妙感受。然而我心裡想的卻是 Redis 6 的發布,總覺得或許最重要的功能——新版 Redis 協定(RESP3)——將會面臨非常緩慢的採用曲線,而且這是有充分理由的:明智的人不會沒有充分理由就更換工具。畢竟,我為何如此渴望改進協定?主要有兩個原因,一是為了讓用戶端獲得更具語意的回覆,二是為了開啟那些在舊協定下難以實作的新功能;而其中有一項功能對我而言最為重要:client side caching(用戶端快取)。
回到大約一年前。我抵達位於舊金山的 Redis Conf 2018,心中抱持著一個堅定的想法:client side caching 是 Redis 未來最重要的事。如果我們需要快速的儲存與快速的快取,那麼我們就需要在用戶端內部儲存資訊的子集。這是以極小延遲、大規模提供資料這個理念的自然延伸。事實上,幾乎每一家非常大型的公司都已經這麼做了,因為這最終是承受負載的唯一方法。然而 Redis 卻沒有任何方式能在這樣的過程中協助用戶端。幸運的巧合是,Ben Malec(班·馬雷克)正好在 Redis Conf 上發表了一場正是有關 client side caching 的演講 [1],僅僅運用了 Redis 提供的工具與許多非常聰明的點子。
[1] https://www.youtube.com/watch?v=kliQLwSikO4
班·馬雷克所採取的方法真的開啟了我的想像。有兩個關鍵想法讓他的設計得以運作。第一個是利用 Redis Cluster「hash slots」的概念,將鍵分為 1 萬 6 千多個群組。如此一來,用戶端就不需要追蹤每個鍵的有效性,而可以為一群鍵使用單一的中繼資料項目。班·馬雷克使用 Pub/Sub 來傳送鍵被修改時的通知,因此他需要應用程式各個部分的協助,然而其架構非常穩固。修改了一個鍵?同時也發布一則使其失效的訊息。在用戶端,你有在快取鍵嗎?記住你快取每個鍵的時間戳記,並且在收到失效訊息時,也記住每個 slot 的失效時間。當使用某個已快取的鍵時,進行惰性淘汰,檢查你所快取的鍵的時間戳記是否早於針對該鍵所屬 slot 所收到的失效時間戳記:若是如此,該鍵即為過時資料,你必須重新向伺服器請求。
看完這場演講後,我意識到這是一個很棒的點子,可以在伺服器內部運用,讓 Redis 為用戶端分擔部分工作,使 client side caching 變得更簡單、更有效,因此我回到家後寫了一份文件來描述我的設計 [2]。
[2] https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ
但要讓我的設計運作,我必須專注於將 Redis 協定切換為更好的版本,因此我開始撰寫 RESP3 的規格,之後撰寫相關程式碼,以及其他 Redis 6 的功能如 ACL 等等,而 client side caching 就這樣加入了眾多因時間不足而被我或多或少擱置的 Redis 點子那個巨大的房間。
然而當時我正走在紐約的街道上思考著這個點子。稍後與來自研討會的朋友們一起吃午餐、喝咖啡。回到飯店房間後,我還有整個晚上以及隔天搭機前的大半天時間,於是我開始為 Redis 6 實作 client side caching,緊密遵循一年前我寫給群組的提案:它看起來依然很棒。
Redis 的伺服器協助式 client side caching,最後被稱為「tracking」(但我可能會改變主意),是一個由少數幾個關鍵想法組成的非常簡單的功能。
鍵空間被分割為「caching slots」,但它們的數量遠多於班·馬雷克所使用的 hash slots。我們使用 CRC64 輸出的 24 個位元,因此有超過 1,600 萬個不同的 slot。為什麼需要這麼多?因為我認為你會希望擁有一台擁有 1 億個鍵的伺服器,而失效訊息卻不應該影響用戶端快取中的太多鍵。在 Redis 內部用於存放失效表的記憶體額外負擔是 1 億 3 千萬位元組:一個包含 1,600 萬個條目的 8 位元組指標陣列。這對我來說是可以接受的,如果你想要這個功能,你將會充分利用用戶端擁有的所有記憶體,因此在伺服器端使用 130MB 是合理的;你所獲得的是更加精細的失效粒度。
用戶端以「隨選啟用」的方式啟用此功能,使用一個簡單的指令:
CLIENT TRACKING on伺服器會回覆一貫的 +OK,而從那一刻起,每一個在指令表中被標記為「唯讀」的指令,不僅會將鍵回傳給呼叫者,還會作為副作用,記住該用戶端至今為止所請求的所有鍵所屬的 caching slots(但僅限於使用唯讀指令的那些鍵,這是伺服器與用戶端之間的約定)。Redis 儲存此資訊的方式很簡單。每個 Redis 用戶端都有唯一的 ID,因此如果 ID 為 123 的用戶端對雜湊至 slot 1、2 和 5 的鍵執行 MGET,我們的 Invalidation Table 中將會有以下項目:
1 -> [123]
2 -> [123]
5 -> [123]但稍後 ID 為 444 的用戶端也會請求 slot 5 中的鍵,因此表格將會變成:
5 -> [123, 444]現在有其他用戶端修改了 slot 5 中的某個鍵。發生的情況是 Redis 會檢查 Invalidation Table,發現用戶端 123 和 444 都可能已快取了該 slot 上的鍵。我們將會向這兩個用戶端發送失效訊息,結果他們可以自由地以任何形式處理:可以記住該 slot 最後一次失效的時間戳記,並在之後以惰性方式檢查已快取物件的時間戳記(或如果你更喜歡的話,用遞增的「epoch」:它更安全),並根據比較結果將其淘汰。否則,用戶端也可以直接回收物件,方法是維護一個關於它針對此特定 slot 所快取內容的表格。這種採用 24 位元雜湊函式的方法也不會造成問題,因為即使快取了數千萬個鍵,我們根本不會有非常長的清單。在發送失效訊息後,我們可以從失效表中移除這些項目,這樣直到它們再次讀取該 slot 的鍵之前,我們將不再向這些用戶端發送失效訊息。
請注意,用戶端並非被強制要真的使用雜湊函式的全部 24 個位元。它們可以例如僅使用 20 個位元,然後也對 Redis 發送給它們的失效訊息中的 slot 進行位移。不確定這樣做是否有許多充分的理由,但在記憶體受限的系統中或許是個想法。
如果你仔細跟隨我所說的內容,你會想到同一個連線同時接收正常的用戶端回覆與失效訊息。這在 RESP3 中是可行的,因為失效是以「push」訊息類型發送的。然而,如果用戶端是阻塞式的,而非事件驅動的用戶端,這就開始變得複雜:應用程式需要某種方式不時讀取新資料,而這看起來既複雜又脆弱。在這種情況下,或許更好的做法是使用另一個應用程式執行緒以及不同的用戶端連線來接收失效訊息。因此你可以執行以下操作:
CLIENT TRACKING on REDIRECT 1234基本上我們可以說,對於使用當前連線取得的所有鍵,我們希望將失效訊息發送到用戶端 1234。例如,多個用戶端可能會要求將失效訊息重新導向至單一用戶端,以用於連線池的情況。你所需要做的就是建立這個用於接收失效訊息的特殊連線,呼叫 CLIENT ID 來得知此用戶端連線的 ID,之後再啟用 tracking。
還有一個問題有待解決:如果我們與伺服器的失效連結斷線了,會發生什麼事?由於將不再收到失效訊息,我們可能會遇到麻煩。通常應用程式會偵測到連結已中斷,並重新連線,清空當前的快取(或採取更溫和的解決方案,例如將所有 slot 的時間戳記設定為未來幾秒,以爭取一些時間來重新填充快取,同時提供可能有幾秒過時的資料)。然而,如果失效執行緒不時對連線發送 ping 以確保其存活,或許會是更好的主意。然而,為了降低過時資料的風險,Redis 也會開始通知那些將失效訊息重新導向至其他用戶端的用戶端,告知該用戶端現已斷線的情況,僅使用特殊的 push 訊息:用戶端在下一次執行查詢時就會得知。
我所描述的內容剛剛被合併到 Redis 的不穩定版中。或許這還不是最終定案,但在第一個 Redis 6 候選版發布之前還有好幾個月的時間,還有時間改變一切:請將你的回饋傳送給我。我也在研究為 RESP2 啟用此功能的方法。那將僅在啟用重新導向時才有效,而監聽訊息的用戶端或許應該進入 Pub/Sub 模式,這樣我們就可以發送類似 Pub/Sub 訊息的內容。透過這種方式,舊有的用戶端可以被完全重複使用。
我希望這些已足以激起你的興趣:如果我們在 Redis 內部非常妥善地執行這項功能,並為用戶端作者提供相關文件以了解如何提供支援,資料或許會比以往任何時候都更靠近應用程式,即使是在那些至今仍避免嘗試實作 client side caching 的小型團隊所運行的應用程式中亦是如此。對於已經在這麼做的大型團隊與非常大型的應用程式而言,額外負擔與實作的複雜度則可以一併降低。
隨機一篇部落格