原文:https://redis.io/topics/client-side-caching
翻譯:Wen Hui
轉(zhuǎn)載:中間件小哥
客戶端緩存是用于提供高性能服務(wù)的一項技術(shù)。它使用應(yīng)用服務(wù)器節(jié)點(通常情況下和數(shù)據(jù)庫服務(wù)器使用不同的物理機)的可用內(nèi)存,用來在應(yīng)用端直接存儲一部分?jǐn)?shù)據(jù)庫信息。
正常情況下當(dāng)客戶端請求應(yīng)用服務(wù)器一些數(shù)據(jù)時,應(yīng)用服務(wù)器會請求數(shù)據(jù)庫這些信息,如下圖所示:
當(dāng)使用客戶端緩存時,應(yīng)用服務(wù)器端會存儲經(jīng)常訪問的數(shù)據(jù)請求,以便在下次客戶端請求過程中重用之前的數(shù)據(jù)庫查詢回復(fù),而無需再向數(shù)據(jù)庫進(jìn)行查詢。
盡管用于本地緩存的應(yīng)用程序內(nèi)存可能不是很大,但是與請求諸如數(shù)據(jù)庫之類的網(wǎng)絡(luò)服務(wù)相比,訪問本地計算機內(nèi)存所需的時間要小幾個數(shù)量級。由于在通常情況下,少量比例數(shù)據(jù)會經(jīng)常頻繁的被訪問,因此該模式可以極大地減少應(yīng)用程序獲取數(shù)據(jù)的延遲,并同時減少數(shù)據(jù)庫端的負(fù)載。
此外,在許多數(shù)據(jù)集中,信息很少進(jìn)行更改。例如,社交網(wǎng)絡(luò)中的大多數(shù)用戶帖子要么是不變的,要么很少被用戶編輯。再加上通常只有一小部分帖子非常受歡迎的事實,要么是因為一小群用戶擁有大量關(guān)注者,或者因為最近的帖子具有更高的曝光度,由此可見為什么這種模式在實際情況下會非常有用。
通常來說,客戶端緩存的兩個主要優(yōu)點是:
1. 可用的數(shù)據(jù)延遲非常短。
2. 數(shù)據(jù)庫系統(tǒng)接收的查詢較少,從而可以使用更少的節(jié)點來提供相同的數(shù)據(jù)服務(wù)。
在計算機科學(xué)中只有兩大問題
上述模式的問題是在數(shù)據(jù)被修改或過期時,如何使應(yīng)用程序保存的信息無效,以避免向用戶顯示陳舊數(shù)據(jù)。例如,在上面的應(yīng)用程序本地緩存了user:1234信息之后,Alice可以將其用戶名更新為Flora。但是應(yīng)用程序可能會繼續(xù)為用戶1234提供舊的用戶名。
有時,取決于我們要建模的應(yīng)用程序,這個問題并不重要,因此客戶端將只使用固定的最大“生存時間”來緩存信息。一旦過了給定的時間,該信息將不再被視為有效。使用Redis時,更復(fù)雜的模式會利用發(fā)布/訂閱系統(tǒng),以便向偵聽的客戶端發(fā)送無效消息。從使用的帶寬的角度來看,這是可行的,但卻是棘手且昂貴的,因為這種模式通常涉及向應(yīng)用程序中的每個客戶端發(fā)送無效消息,即使某些客戶端可能沒有無效數(shù)據(jù)的任何副本。 此外,每個更改數(shù)據(jù)的應(yīng)用程序查詢都需要使用PUBLISH命令,從而使數(shù)據(jù)庫花費更多的CPU時間來處理此命令。
無論使用哪種模式,都有一個簡單的事實:許多非常大的應(yīng)用程序都實現(xiàn)某種形式的客戶端緩存,因為這是擁有快速存儲或快速緩存服務(wù)器的下一個邏輯步驟。因此,Redis 6實現(xiàn)了對客戶端緩存的直接支持,以使該模式更易于實現(xiàn),更易于訪問,可靠且高效。
客戶端緩存的Redis實現(xiàn)
Redis客戶端緩存支持稱為跟蹤(tracking),并具有兩種模式:
在默認(rèn)模式下,服務(wù)器會記住給定客戶端訪問了哪些鍵,并在別的客戶端修改相同的鍵時向客戶端發(fā)送無效消息。這將花費服務(wù)器端的內(nèi)存,但僅對在內(nèi)存中擁有修改的鍵的客戶端發(fā)送無效消息。
相反,在廣播模式下,服務(wù)器不會嘗試記住給定客戶端訪問了哪些鍵,因此該模式在服務(wù)器端根本不會花費任何內(nèi)存。相反,客戶端會訂閱鍵前綴(例如object:或user :),并且每次其他客戶端修改與該前綴匹配的鍵值時都會收到通知消息。
回顧一下,現(xiàn)在讓我們暫時忘記廣播模式,重點關(guān)注第一種模式。我們將在后面詳細(xì)介紹廣播模式。
客戶可以根據(jù)需要啟用跟蹤。連接開始時未啟用跟蹤。
啟用跟蹤后,服務(wù)器會記住每個客戶端在連接生存期內(nèi)請求過的鍵(通過發(fā)送有關(guān)此鍵的讀取命令)。
當(dāng)某個客戶端修改了某個鍵,或者由于鍵具有關(guān)聯(lián)的到期時間而將其逐出,或者由于最大內(nèi)存策略而將其逐出時,所有啟用了跟蹤且可能已緩存鍵的客戶端都會收到無效消息通知。
當(dāng)客戶端收到無效消息時,要求它們刪除相應(yīng)的鍵值信息,以避免提供過時的數(shù)據(jù)。
這是協(xié)議的示例:
從表面上看,這看起來很棒,但是如果你想到有10萬個已連接的客戶端在每個持久連接中都請求數(shù)百萬個鍵,則服務(wù)器將會因為存儲太多信息而崩潰。因此,Redis使用兩個關(guān)鍵思想來限制服務(wù)器端使用的內(nèi)存量以及處理實現(xiàn)該功能的數(shù)據(jù)結(jié)構(gòu)的CPU成本:
服務(wù)器會在一個全局列表中記住可能已將給定鍵值緩存過的客戶端列表。該表稱為無效表。這個無效表可以設(shè)置最大數(shù)量的記錄,如果插入了新鍵值,則服務(wù)器可以通過假裝已修改(即使沒有修改)并將其發(fā)送到客戶端來驅(qū)逐舊條目。這樣做,它可以使服務(wù)器端回收用于此鍵值的內(nèi)存,即使這樣會迫使鍵值在本地客戶端緩存被逐出。
在無效表內(nèi)部,我們實際上不需要存儲指向客戶端結(jié)構(gòu)的指針,這將在客戶端斷開連接時在無效表中需要強制執(zhí)行垃圾回收過程:相反,我們要做的只是存儲客戶端ID(每個Redis客戶端都有唯一的數(shù)字ID)。如果客戶端斷開連接,則隨著緩存槽無效,將逐步收集垃圾信息。
在這里只有一個鍵空間,不以數(shù)據(jù)庫編號做劃分。因此,如果客戶端在數(shù)據(jù)庫2中緩存鍵foo,而其他一些客戶端在數(shù)據(jù)庫3中更改了鍵foo的值,則仍然會發(fā)送無效消息。這樣,我們可以忽略數(shù)據(jù)庫編號,從而減少了內(nèi)存使用量和實現(xiàn)的復(fù)雜度。
兩種連接方式
使用Redis 6支持的新版本的Redis協(xié)議RESP3,可以在同一連接中運行數(shù)據(jù)查詢并接收無效消息。但是,許多客戶端實現(xiàn)可能更喜歡使用兩個獨立的連接來實現(xiàn)客戶端緩存:一個用于數(shù)據(jù),另一個用于無效消息。因此,當(dāng)客戶端啟用跟蹤時,它可以通過指定不同連接的“客戶端ID”來指定將無效消息重定向到另一個連接。許多數(shù)據(jù)連接可以將無效消息重定向到同一連接,這對于實現(xiàn)連接池的客戶端很有用。這兩個連接模型是RESP2唯一支持的模型(缺乏在同一連接中復(fù)用不同類型信息的能力)。
這次我們將通過在舊的RRESP2模式下使用實際的Redis協(xié)議顯示一個示例,如何完成一個完整的會話,包括以下步驟:啟用跟蹤重定向到另一個連接,請求鍵的值信息以及在鍵的內(nèi)容被其他客戶端修改的情況下,獲取服務(wù)器發(fā)送的鍵失效信息。
首先,客戶端打開第一個用于失效的連接,請求返回連接的ID,并通過Pub / Sub訂閱專用通道,該通道在RESP2模式下用于獲得失效消息(請記住,RESP2是通常的Redis協(xié)議,而不是你可以使用的更高級的協(xié)議):
客戶端可以決定在本地內(nèi)存中緩存“ foo” =>“ bar”。
現(xiàn)在,另一個客戶端將修改“ foo”鍵的值:
因此,失效連接將收到一條失效信息,該信息使指定的鍵值失效。
客戶端將檢查在此緩存槽中是否有緩存的鍵值信息,并將逐出不再有效的信息。
請注意,Pub / Sub消息的第三個元素不是單個鍵,而是只有一個元素的Redis數(shù)組。因為我們發(fā)送一個數(shù)組,所以如果有成組的鍵無效,我們可以在一條消息中做到這一點。
關(guān)于理解使用RESP2的客戶端緩存以及為了讀取無效消息而進(jìn)行的Pub / Sub連接非常重要的一點是,使用Pub / Sub完全是一個為了重用舊的客戶端實現(xiàn)的技巧,但實際上并未真正發(fā)送并被所有訂閱該頻道的客戶所接收。只有我們在CLIENT命令的REDIRECT參數(shù)中指定的連接才會實際收到Pub / Sub消息,從而使此功能具有更大的可伸縮性。
如果改用RESP3,則將無效消息作為推送消息發(fā)送(在同一連接中,或者在使用重定向時在輔助連接中)(請參閱RESP3規(guī)范以獲取更多信息)。
追蹤用來追蹤什么
如上面例子可以看到,默認(rèn)情況下,客戶端不需要告訴服務(wù)器它們正在緩存哪些鍵。服務(wù)器會追蹤只讀命令上下文中提到的每個鍵,因為它可能會被緩存。
這具有明顯的優(yōu)點,即不需要客戶端告訴服務(wù)器它正在緩存什么。此外,在許多客戶端實現(xiàn)中,這就是你想要的,因為一個好的解決方案可能是使用先進(jìn)先出的方法僅緩存尚未緩存的所有內(nèi)容:我們可能希望緩存固定數(shù)量的對象,每個對象我們檢索到新數(shù)據(jù)后,就可以對其進(jìn)行緩存,丟棄最早的緩存對象。更高級的實現(xiàn)可能會刪除最不常用的對象或類似對象。
請注意,無論如何,如果服務(wù)器上有寫流量,則緩存槽將在一段時間內(nèi)失效。通常,當(dāng)服務(wù)器假設(shè)我們得到的東西也緩存時,我們就要進(jìn)行權(quán)衡:
1. 當(dāng)客戶端傾向于使用歡迎新對象的策略來緩存更多內(nèi)容時,這樣做會更有效率。
2. 服務(wù)器將被迫保留有關(guān)客戶端鍵的更多數(shù)據(jù)。
3. 客戶端將收到有關(guān)其未緩存的對象的無效消息。
因此,下一節(jié)將介紹另一種方法
OPT-IN 模式
客戶端實現(xiàn)可能只希望緩存選定的鍵,并明確地與服務(wù)器通信它們將緩存的內(nèi)容和不緩存的內(nèi)容:緩存新對象時,這將需要更多的帶寬,但同時會減少服務(wù)器需要記住的數(shù)據(jù)量,以及客戶端收到的無效消息數(shù)量。
為此,必須使用OPTIN選項啟用跟蹤:
在這種模式下,默認(rèn)情況下,不應(yīng)緩存讀取查詢中的鍵,而是當(dāng)客戶端要緩存某些內(nèi)容時,它必須在實際命令檢索數(shù)據(jù)之前立即發(fā)送一個特殊命令CACHING:
為了使協(xié)議更有效率,可以使用NOREPLY選項發(fā)送CACHING命令:在這種情況下,CACHING命令中客戶端不會收到服務(wù)器返回的信息:
CACHING命令會影響隨后執(zhí)行的命令,但是,如果下一個命令是MULTI,則將跟蹤事務(wù)中的所有命令。同樣,對于Lua腳本,將跟蹤該腳本執(zhí)行的所有命令。
廣播模式
到目前為止,我們描述了Redis實現(xiàn)的第一個客戶端緩存模型。還有一個稱為廣播模式,它從另一個折衷的角度來看問題,它不消耗服務(wù)器端的任何內(nèi)存,而是向客戶端發(fā)送更多的無效消息。在這種模式下,我們有以下主要行為:
客戶端使用BCAST選項啟用客戶端緩存,并使用PREFIX選項指定一個或多個前綴。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object:PREFIX user:。如果根本沒有指定任何前綴,則假定該前綴為空字符串,因此客戶端將會接收每個被修改的鍵的無效消息。相反,如果使用一個或多個前綴,則僅在失效消息中發(fā)送與指定前綴之一匹配的鍵。
服務(wù)器未在失效表中存儲任何內(nèi)容。相反,它僅使用不同的前綴表,其中每個前綴都與客戶端列表相關(guān)聯(lián)。
每次修改與任何前綴匹配的鍵時,所有訂閱該前綴的客戶端都將收到無效消息。
服務(wù)器的CPU消耗與注冊前綴數(shù)量成正比。如果只有幾個,幾乎看不出任何區(qū)別。使用大量前綴,CPU成本可能變得非常高。
在這種模式下,服務(wù)器可以優(yōu)化為訂閱給定前綴的所有客戶端創(chuàng)建單個回復(fù)的過程,并將相同的回復(fù)發(fā)送給所有客戶端。這有助于降低CPU使用率。
避免競爭條件
在實施客戶端緩存以將無效消息重定向到其他連接時,你應(yīng)該意識到存在競爭狀況。請參見以下示例交互,在此我們將數(shù)據(jù)連接稱為“ D”,并將失效連接稱為“ I”
如上所示,由于對GET的回復(fù)返回給客戶端會有較長延時,因此在已經(jīng)不再有效的實際數(shù)據(jù)之前,我們收到了無效消息。因此,我們將繼續(xù)提供舊版本的foo鍵的值信息。為避免此問題,當(dāng)我們使用占位符發(fā)送命令時,填充緩存是一個好主意:
當(dāng)對數(shù)據(jù)和無效消息使用單個連接時,這種競爭條件是不可能的,因為在這種情況下消息的順序始終是已知的。
與服務(wù)器斷開連接時該怎么辦
同樣,如果丟失與套接字的連接以獲取無效消息,則可能會以客戶端收到陳舊數(shù)據(jù)結(jié)束。為了避免這個問題,我們需要做以下事情:
1. 確保如果連接丟失,則刷新本地緩存。
2. 在將RESP2與Pub / Sub一起使用時,或者在RESP3上,都定期對無效訂閱連接進(jìn)行ping操作(即使連接處于Pub / Sub模式下,也可以發(fā)送PING命令!)。如果連接看起來斷開并且我們無法接收ping回復(fù),請在設(shè)定的最長時間后關(guān)閉連接并刷新緩存。
什么需要緩存
客戶可能希望運行有關(guān)給定緩存的鍵在實際請求中被調(diào)用的次數(shù)的內(nèi)部統(tǒng)計信息,以了解以后對哪些鍵使用客戶端緩存。一般來說:
· 我們不想緩存很多不斷變化的鍵。
· 我們不想緩存很多很少被請求的鍵。
· 我們希望緩存經(jīng)常請求的鍵并以合理的速率進(jìn)行更改。有關(guān)鍵沒有以合理的速度更改的示例,請考慮一個不斷增加的全局計數(shù)器。
但是,更簡單的客戶端可能只是使用一些隨機采樣來逐出數(shù)據(jù),只是記住最后一次被查詢的特定鍵值,從而試圖逐出最近未被查詢的鍵。
有關(guān)客戶端庫實現(xiàn)的建議
· 處理TTL:如果要支持帶TTL的緩存鍵,請確保查詢鍵的TTL值并在本地緩存中設(shè)置TTL。
· 即使沒有TTL,在每個鍵中都放置一個最大TTL是一個比較好的做法。這是個很好的保護(hù)措施,可避免可能導(dǎo)致客戶端在本地副本中包含舊數(shù)據(jù)的錯誤或連接問題。
· 絕對需要限制客戶端使用的內(nèi)存量。添加新鍵時,必須有一種方法可以將舊鍵逐出。
限制Redis使用的內(nèi)存量
只需確保為Redis記住的最大鍵數(shù)配置一個合適的值,或者使用BCAST模式,該模式在Redis端根本不占用任何內(nèi)存。請注意,當(dāng)不使用BCAST時,Redis消耗的內(nèi)存與跟蹤的鍵數(shù)量以及請求此類鍵的客戶端數(shù)量成正比。