Client side caching in Redis 6

Salvatore Sanfilippo

Redis 6 的客戶端快取

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

[註:本文已不再描述 Redis 6 最終實作中的客戶端實作方式,該實作已有大幅變更,請參閱 https://redis.io/topics/client-side-caching]

紐約的 Redis Day 剛結束,我在飯店清晨五點半就醒來,時差還停留在義大利時間,立刻就出門在曼哈頓的街道上散步,完全沉醉在眼前的景色,以及那種身為數百萬人之中一個小小數字的奇妙感覺。然而我腦中想的卻是 Redis 6 的發布,心裡有種感覺,其中最重要的功能——新版的 Redis 協定(RESP3)——恐怕會面臨非常緩慢的採用曲線,而且理由很充分:聰明的人不會沒有充分理由就更換工具。畢竟,為什麼我當初那麼想改進協定?主要有兩個原因,一是為了讓客戶端收到更具語意的回覆,二是為了開啟一些在舊協定下難以實作的新功能;而其中對我來說最重要的一項,就是客戶端快取。

把時間倒回大約一年前。我抵達舊金山的 Redis Conf 2018,心裡非常堅定地認為,客戶端快取是 Redis 未來最重要的事。如果我們需要高速的儲存與高速的快取,那就需要在客戶端內部也儲存一部分資訊。這是把資料以極低延遲、大規模提供出去這個理念的自然延伸。事實上,幾乎每一家超大型公司都已經這麼做了,因為最終這是撐住負載的唯一方法。然而 Redis 卻沒有任何方式來協助客戶端完成這個過程。巧合的是,Ben Malec 正好在 Redis Conf 上發表了一場關於客戶端快取的演講 [1],只用了 Redis 現有的工具加上一些非常聰明的點子。

[1] https://www.youtube.com/watch?v=kliQLwSikO4

Ben 採用的方法真的打開了我的想像。有兩個關鍵點子讓他的設計得以運作。第一個是借用 Redis Cluster「雜湊槽(hash slots)」的概念,把鍵分成一萬六千多個群組。這樣客戶端就不需要追蹤每個鍵的有效性,而可以用單一的詮釋資料項目來代表一整組鍵。Ben 用 Pub/Sub 來傳送鍵被修改時的通知,所以他需要應用程式各個部分配合,不過整體架構非常紮實。修改了一個鍵?同時也發布一則讓它失效的訊息。在客戶端這邊,有在快取鍵嗎?記住你快取每個鍵的時間戳記,同時在收到失效訊息時,也記住每個槽的失效時間。當要使用某個已快取的鍵時,就做一次惰性淘汰,檢查你快取的這個鍵的時間戳記,是否早於這個鍵所屬槽收到的失效時間戳記:如果是,就代表這是過時的資料,你得重新向伺服器請求。

看完這場演講後,我意識到這是個很棒的點子,應該放到伺服器內部來實作,讓 Redis 幫客戶端分擔一部分工作,讓客戶端快取變得更簡單、更有效,所以我回到家就寫了一份描述我設計的文件 [2]。

[2] https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ

但要讓我的設計真正運作,我得先把 Redis 的協定換成更好的東西,所以我開始撰寫 RESP3 的規格,之後又寫了程式碼,還有其他 Redis 6 的東西像是 ACL 等等,而客戶端快取就這樣加入了眾多 Redis 點子中那個巨大的房間——因為沒時間,總是以這樣或那樣的方式被我擱置了。

然而當時我正走在紐約的街頭,心裡想著這個點子。後來和研討會的朋友們一起去吃了午餐、喝了咖啡。回到飯店房間後,整個晚上加上隔天搭飛機前的大半天都還空著,於是我開始動手實作 Redis 6 的客戶端快取,緊緊依循一年前寫給社群的那份提案:它看起來依然很棒。

Redis 伺服器輔助的客戶端快取,最後被稱為「tracking」(追蹤)(不過我可能還會改名字),是一個由幾個關鍵想法組成的非常簡單的功能。

鍵空間被切分成「快取槽(caching slots)」,但數量比 Ben 用的雜湊槽多得多。我們取 CRC64 輸出的 24 個位元,所以有超過一千六百萬個不同的槽。為什麼要這麼多?因為我認為你會想在一台擁有上億個鍵的伺服器上運作,而一則失效訊息不應該影響到客戶端快取中的太多鍵。Redis 內部為了維護失效表格所付出的記憶體開銷是 130 MB:一個包含 1600 萬個項目的陣列,每個項目是 8 位元組的指標。這對我來說可以接受,如果你想用這個功能,你本來就會在客戶端用掉大量記憶體,那麼在伺服器端用掉 130MB 也沒什麼;你換來的是更細緻的失效粒度。

客戶端以「選擇加入」的方式啟用這個功能,只需要一個簡單的指令:

CLIENT TRACKING on

伺服器會回覆老樣子的 +OK,從那一刻起,每一個在指令表中被標記為「唯讀」的指令,不僅會把鍵回傳給呼叫者,還會在副作用上記住該客戶端至今為止所請求的所有鍵的快取槽(但只限於使用唯讀指令的那些,這就是伺服器與客戶端之間的約定)。Redis 儲存這份資訊的方式很簡單。每個 Redis 客戶端都有一個唯一的 ID,所以如果 ID 為 123 的客戶端對雜湊到槽 1、2 和 5 的鍵執行了 MGET,我們的失效表格(Invalidation Table)就會有以下項目:

1 -> [123]
2 -> [123]
5 -> [123]

但稍後 ID 為 444 的客戶端也會請求槽 5 中的鍵,所以表格會變成這樣:

5 -> [123, 444]

現在有其他客戶端修改了槽 5 中的某個鍵。接下來發生的事是,Redis 會去檢查失效表格,發現客戶端 123 和 444 都可能快取了這個槽上的鍵。我們會向這兩個客戶端都發送一則失效訊息,之後它們就可以自行決定要怎麼處理:可以選擇用時間戳記記住該槽最後一次失效的時間,之後再以惰性的方式檢查快取物件的時間戳記(或如果你比較喜歡,也可以叫它遞增的「epoch」:這樣更安全),透過比對來淘汰它。否則,客戶端也可以直接回收物件,方法是維護一張自己針對這個特定槽所快取內容的對照表。使用 24 位元雜湊函式的做法並不會造成問題,因為就算快取了數千萬個鍵,清單也不會變得很長。在發送完失效訊息後,我們就可以從失效表格中移除這些項目,這樣在這些客戶端再次讀取該槽的鍵之前,就不會再向它們發送失效訊息了。

要注意的是,客戶端並不一定得真的使用雜湊函式的全部 24 個位元。舉例來說,它們可以只用 20 個位元,然後也把 Redis 傳來的失效訊息中的槽位做位移。不確定這樣做有多少好理由,但在記憶體受限的系統中或許是個點子。

如果你有仔細跟上我剛剛說的,你會想到同一個連線同時接收了正常的客戶端回覆和失效訊息。這在 RESP3 中是可行的,因為失效訊息是以「push」訊息類型發送的。然而,如果客戶端是阻塞式的,而不是事件驅動的,這就開始變得複雜了:應用程式需要某種方式不時地去讀取新資料,而那看起來既複雜又脆弱。在那種情況下,或許更好的做法是使用另一個應用程式執行緒,以及不同的客戶端連線,來接收失效訊息。所以你可以這樣做:

CLIENT TRACKING on REDIRECT 1234

基本上我們可以說,透過目前這個連線取得的所有鍵,我們希望把失效訊息改送到客戶端 1234。例如,多個客戶端可以要求把失效訊息重新導向到同一個客戶端,像是在使用連線池的情況下。你所需要做的,就是建立這個專門用來接收失效訊息的特殊連線,呼叫 CLIENT ID 來知道這個客戶端連線的 ID,之後再啟用追蹤。

還剩下一個問題:如果我們與伺服器之間的失效連線斷線了會怎樣?我們可能會遇到麻煩,因為失效訊息將不再被接收。通常應用程式會偵測到連線已中斷,並重新連線,同時清空目前的快取(或採取更溫和的處理方式,像是把所有槽的時間戳記往未來推幾秒鐘,以便在提供可能過時幾秒的資料的同時,有時間重新填滿快取)。然而,如果失效執行緒不時地對連線發送 ping 來確認它還活著,或許會是個更好的主意。不過為了降低拿到過時資料的風險,如果接收重新導向失效訊息的目標客戶端現在已經斷線,Redis 也會開始通知那些重新導向的客戶端,告知這個狀況,只是透過特殊的 push 訊息:在下一次查詢時,客戶端就會知道。

我所描述的內容剛剛已經合併到 Redis 的 unstable 分支中。可能這還不是最終定案,但在第一個 Redis 6 的候選版發布前還有好幾個月的時間,還有時間改變一切:歡迎把你的回饋寄給我。我也在研究如何為 RESP2 啟用這個功能。那種做法只有在啟用重新導向時才會有效,而負責監聽訊息的客戶端大概得進入 Pub/Sub 模式,這樣我們才能發送類似 Pub/Sub 訊息的內容。這樣一來舊的客戶端也能被完整重複利用。

希望這些已經足以挑起你的胃口:如果我們在 Redis 內部把這件事做得非常好,並為客戶端作者提供完善的文件,讓他們知道如何提供支援,那麼資料就能比以往更靠近應用程式,甚至是在那些到目前為止一直避免嘗試實作客戶端快取的小團隊所開發的應用程式中也是如此。對於那些已經在做這件事的大型團隊與超大型應用程式來說,開銷與實作的複雜度也能一併降低。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言