帶你100% 地了解 Redis 6.0 的客戶端緩存
近日 Redis 6.0.0 GA 版本發(fā)布,這是 Redis 歷史上最大的一次版本更新,包括了客戶端緩存 (Client side caching)、ACL、Threaded I/O 和 Redis Cluster Proxy 等諸多更新。
我們今天就依次聊一下客戶端緩存的必要性、具體使用、原理分析和實(shí)現(xiàn)。
為什么需要客戶端緩存?
我們都知道,使用 Redis 進(jìn)行數(shù)據(jù)的緩存的主要目的是減少對(duì) MySQL 等數(shù)據(jù)庫(kù)的訪問(wèn),提供更快的訪問(wèn)速度,畢竟 《Redis in Action》中提到的, Redis 的性能大致是普通關(guān)系型數(shù)據(jù)庫(kù)的 10 ~ 100 倍。
所以,如下圖所示,Redis 用來(lái)存儲(chǔ)熱點(diǎn)數(shù)據(jù),Redis 未命中,再去訪問(wèn)數(shù)據(jù)庫(kù),這樣可以應(yīng)付大多數(shù)情況下的性能要求。
但是,Redis 也有其性能上限,并且訪問(wèn) Redis 必然有一定的網(wǎng)絡(luò) I/O 以及序列化反序列化損耗。所以,往往會(huì)引入進(jìn)程緩存,將最熱的數(shù)據(jù)存儲(chǔ)在本地,進(jìn)一步加快訪問(wèn)速度。
如上圖所示(示意圖,細(xì)節(jié)不必過(guò)度在意,下同),Guava Cache 等進(jìn)程緩存作為一級(jí)緩存,Redis 作為二級(jí)緩存:
先去 Guava Cache 中查詢數(shù)據(jù),如果命中則直接返回。
Guava Cache 中未命中,則再去 Redis 中查詢,如果命中則返回?cái)?shù)據(jù),并在 Guava Cache 中設(shè)置此數(shù)據(jù)。
Redis 也未命中的話,只有去 MySQL 中查詢,然后依次將數(shù)據(jù)設(shè)置到 Redis 和 Guava Cache 中。
只使用 Redis 分布式緩存時(shí),遇到數(shù)據(jù)更新時(shí),應(yīng)用程序更新完 MySQL 中的數(shù)據(jù),可以直接將 Redis 中對(duì)應(yīng)緩存失效掉,保持?jǐn)?shù)據(jù)的一致性。
而進(jìn)程內(nèi)緩存的數(shù)據(jù)一致性比分布式的緩存面臨更大的挑戰(zhàn)。數(shù)據(jù)更新的時(shí)候,如何通知其他進(jìn)程也更新自己的緩存呢?
如果按照分布式緩存的思路,我們可以設(shè)置極短的緩存失效時(shí)間,這樣不必實(shí)現(xiàn)復(fù)雜的通知機(jī)制。
但是不同進(jìn)程內(nèi)的數(shù)據(jù)依然會(huì)面臨不一致的問(wèn)題,并且不同進(jìn)程緩存失效時(shí)間不統(tǒng)一,同一個(gè)請(qǐng)求到了不同的進(jìn)程,可能出現(xiàn)反復(fù)幻讀的情況。
Ben 在 RedisConf18 給出了一個(gè)方案(視頻和 PPT 鏈接在文末),通過(guò) Redis 的 Pub/Sub,可以通知其他進(jìn)程緩存對(duì)此緩存進(jìn)行刪除。如果 Redis 掛了或者訂閱機(jī)制不靠譜,依靠超時(shí)設(shè)定,依然可以做兜底處理。
Antirez(Redis 的作者) 也正是聽(tīng)取 Ben 這個(gè)方案后,才決定在 Redis Server 支持客戶端緩存的,因?yàn)樵谟蟹?wù)端參與的情況下可以更好的處理上述這些問(wèn)題。
功能介紹和演示
下面使用 Docker 安裝 Redis 6.0.1,然后使用 telnet 來(lái)簡(jiǎn)單演示一下 Redis 6.0 的客戶端緩存功能。所有相關(guān)的功能如下圖所示,分別是使用RESP3 協(xié)議版本的普通模式和廣播模式,以及使用 RESP2 協(xié)議版本的轉(zhuǎn)發(fā)模式。我們先來(lái)看普通模式。
普通模式
先使用 redis-cli 設(shè)置緩存值 test=111,使用 telnet 連接上 Redis,然后發(fā)送 hello 3 開(kāi)啟 RESP3 協(xié)議。
- [root@VM_0_3_centos ~]# telnet 127.0.0.1 6379
- Trying 127.0.0.1...
- Connected to 127.0.0.1.
- Escape character is '^]'.
- hello 3
- // telnet 輸出結(jié)果格式化標(biāo)準(zhǔn)化后如下,否則換行太多并且是 RESP3 格式,不需要了解格式。
- > HELLO 3
- 1# "server" => "redis"
- 2# "version" => "6.0.1"
- 3# "proto" => (integer) 3
- 4# "id" => (integer) 10
- 5# "mode" => "standalone"
- 6# "role" => "master"
- 7# "modules" => (empty array)
這里需要注意,Redis 服務(wù)端只會(huì) track 客戶端在一個(gè)連接生命周期內(nèi)的獲取的只讀命令的 key值。Redis 客戶端默認(rèn)不開(kāi)啟 track 模式,需要使用命令開(kāi)啟,然后必須要先獲取一次 test 的值,這樣 Redis 服務(wù)器才會(huì)記錄它。
- client tracking on
- +OK
- get test
- $3
- 111
當(dāng)鍵被修改,或者因?yàn)槭r(shí)間(expire time)和內(nèi)存上限 maxmemory 策略被驅(qū)除時(shí),Redis 服務(wù)端會(huì)通知這些客戶端。我們這里簡(jiǎn)單地更新 test 的值,telnet 則會(huì)收到如下通知
- >2 // RESP3 中的 PUSH 類型,標(biāo)志為 > 符號(hào)
- $10
- invalidate
- *1
- $4
- test
如果你再一次更新 test 值,這次 telnet 就不會(huì)再收到失效(invalidate)消息。除非 telnet 再進(jìn)行一次 get 操作,重新 tracking 對(duì)應(yīng)的鍵值。
也就是說(shuō) Redis 服務(wù)端記錄的客戶端 track 信息只生效一次,發(fā)送過(guò)失效消息后就會(huì)刪除,只有下次客戶端再次執(zhí)行只讀命令被 track,才會(huì)進(jìn)行下一次消息通知 。
取消 tracking 的命令如下所示。
- client tracking off
- +OK
廣播模式
Redis 還提供了一種廣播模式(BCAST),它是另外一種客戶端緩存的實(shí)現(xiàn)方式。這種方式下Redis 服務(wù)端不再消耗過(guò)多內(nèi)存存儲(chǔ)信息,而是發(fā)送更多的失效消息給客戶端。
這是服務(wù)端存儲(chǔ)過(guò)多數(shù)據(jù),消耗內(nèi)存和客戶端收到過(guò)多消息,消耗網(wǎng)絡(luò)帶寬之間的權(quán)衡(tradeoff)。
- // 已經(jīng) hello 3 開(kāi)啟 RESP3 協(xié)議,不然無(wú)法收到失效消息,下同
- client tracking on bcast
- +OK
- // 此時(shí)設(shè)置 key 為 a 的鍵值,收到如下消息。
- >2
- $10
- invalidate
- *1
- $1
- a
如果你不想所有的鍵值的失效消息都收到,則可以限制 key 的前綴,如下命令則表示只關(guān)注前綴為 test 的鍵值的消息。一般來(lái)說(shuō),業(yè)務(wù)的緩存 key 都是根據(jù)業(yè)務(wù)擁有統(tǒng)一的前綴,所以這一特性十分方便。
- client tracking on bcast prefix test
與普通模式必須獲取一次鍵的規(guī)則不同,廣播模式下,只要鍵被修改或刪除,符合規(guī)則的客戶端都會(huì)收到失效消息,而且是可以多次獲取的
與普通模式相比,雖然少存儲(chǔ)了一些數(shù)據(jù),但是由于需要對(duì)前綴規(guī)則進(jìn)行匹配,會(huì)消耗一定的 CPU 資源,所以注意別使用過(guò)長(zhǎng)的前綴。
轉(zhuǎn)發(fā)模式
上述操作時(shí)客戶端都需要先開(kāi)啟 RESP3,Redis 為了兼容 RESP2 協(xié)議提供了轉(zhuǎn)發(fā)(Redirect)模式,不再使用 RESP3 原生支持 PUSH 消息,而是將消息通過(guò) Pub/Sub 通知給另外一個(gè)客戶端,具體流程如下圖所示。
這里需要兩個(gè) telnet,其中一個(gè) telnet 需要訂閱 _redis_:invalidate 信道。然后另一個(gè) telnet 開(kāi)啟 Redirect 模式,并制定將失效消息通過(guò)訂閱信道發(fā)送給第一個(gè) telnet。
- # telent B
- client id
- :368
- subscribe _redis_:invalidate
- # telnet A,開(kāi)啟 track 并指定轉(zhuǎn)發(fā)給 B
- client tracking on bcast redirect 368
- # telent B 此時(shí)有鍵值被修改,收到 __redis__:invalidate 信道的消息
- message
- $20
- __redis__:invalidate
- *1
- $1
- a
你會(huì)發(fā)現(xiàn),轉(zhuǎn)發(fā)模式和文章開(kāi)始提到的多級(jí)緩存中的更新機(jī)制很類似了,只不過(guò)那個(gè)方案中是業(yè)務(wù)系統(tǒng)修改完 key 后發(fā)送消息通知,而這里是 Redis 服務(wù)端代替業(yè)務(wù)系統(tǒng)發(fā)送消息通知。
OPTIN 和 OPTOUT 選項(xiàng)
使用 OPTIN 可以選擇性的開(kāi)啟 tracking。只有你發(fā)送 client caching yes (Redis 文檔中是 CACHING 命令,但是實(shí)驗(yàn)時(shí)發(fā)現(xiàn)無(wú)效)之后的下一條的只讀命令的 key 才會(huì) tracking,否則其他的只讀命令的 key 不會(huì)被 tracking。
- client tracking on optin
- client caching yes
- get a
- get b
- // 此時(shí)修改 a 和 b 的值,發(fā)現(xiàn)只收到 a 的失效消息
- >2
- $10
- invalidate
- *1
- $1
- a
而 OPTOUT 參數(shù)與之相反,你可以有選擇的退出 tracking。發(fā)送 client caching off 之后的下一條只讀命令的 key 不會(huì)被 tracking,其他只讀命令都會(huì)被 tracking。
OPTIN 和 OPTOUT 是針對(duì)非 BCAST 模式,也就是只有發(fā)送了某個(gè) key 的只讀命令后,才會(huì)追蹤相應(yīng)的 key。而 BCAST 模式是無(wú)論你是否發(fā)送某個(gè) key 的只讀命令,只有 Redis 修改了 key,都會(huì)發(fā)送相應(yīng)的 key 的失效消息(前綴匹配的)。
NOLOOP 選項(xiàng)
默認(rèn)情況下,失效消息會(huì)發(fā)送給所有需要的 Redis 客戶端,但是有些情況下觸發(fā)失效消息也就是更新 key 的客戶端不需要收到該消息。
設(shè)置 NOLOOP,可以避免這種情況,更新 Key 的客戶端將不再收到消息,該選項(xiàng)在普通模式和廣播模式下都適用。
最大 tracking 上限 trackingtablemax_keys
由上文可以知道,普通模式下需要存儲(chǔ)大量的被 tracking 的 key 和客戶端信息(具體存儲(chǔ)的數(shù)據(jù)下文中會(huì)講解),所以當(dāng) 10k 客戶端使用該模式處理百萬(wàn)個(gè)鍵時(shí),會(huì)消耗大量的內(nèi)存空間,所以 Redis 引入了 trackingtablemax_keys 配置,默認(rèn)為無(wú),不限制。
當(dāng)有一個(gè)新的鍵被 tracking 時(shí),如果當(dāng)前 tracking 的 key 的數(shù)量大于 trackingtablemax_keys,則會(huì)隨機(jī)刪除之前 tracking 的 key,并且向?qū)?yīng)的客戶端發(fā)送失效消息。
原理和源碼實(shí)現(xiàn)
普通模式原理
我們也先講解普通模式的原理,Redis 服務(wù)端使用 TrackingTable 存儲(chǔ)普通模式的客戶端數(shù)據(jù),它的數(shù)據(jù)類型是基數(shù)樹(shù)(radix tree)。
基數(shù)樹(shù)是針對(duì)稀疏的長(zhǎng)整型數(shù)據(jù)查找的多叉搜索樹(shù),能快速且節(jié)省空間的完映射,一般用于解決 Hash沖突和 Hash表大小的設(shè)計(jì)問(wèn)題,Linux 的內(nèi)存管理就使用了它。
Redis 用它存儲(chǔ)鍵的指針和客戶端 ID 的映射關(guān)系。因?yàn)殒I對(duì)象的指針就是內(nèi)存地址,也就是長(zhǎng)整型數(shù)據(jù)。客戶端緩存的相關(guān)操作就是對(duì)該數(shù)據(jù)的增刪改查:
- 當(dāng)開(kāi)啟 track 功能的客戶端獲取某一個(gè)鍵值時(shí),Redis 會(huì)調(diào)用 enableTracking 方法使用基數(shù)樹(shù)記錄下該 key 和 clientId 的映射關(guān)系。
- 當(dāng)某一個(gè) key 被修改或刪除時(shí),Redis 會(huì)調(diào)用 trackingInvalidateKey 方法根據(jù) key 從 TrackingTable 中查找所有對(duì)應(yīng)的客戶端ID,然后調(diào)用 sendTrackingMessage 方法發(fā)送失效消息給這些客戶端(會(huì)檢查 CLIENT_TRACKING 相關(guān)標(biāo)志位是否開(kāi)啟和是否開(kāi)啟了 NOLOOP)。
- 發(fā)送完失效消息后,根據(jù)鍵的指針值將映射關(guān)系從 TrackingTable中刪除。
- 客戶端關(guān)閉 track 功能后,因?yàn)閯h除需要進(jìn)行大量操作,所以 Redis 使用懶刪除方式,只是將該客戶端的 CLIENT_TRACKING 相關(guān)標(biāo)志位刪除掉。
廣播模式原理
廣播模式與普通模式類似,Redis 同樣使用 PrefixTable 存儲(chǔ)廣播模式下的客戶端數(shù)據(jù),它存儲(chǔ)前綴字符串指針和(需要通知的key和客戶端ID)的映射關(guān)系。它和廣播模式最大的區(qū)別就是真正發(fā)送失效消息的時(shí)機(jī)不同:
- 當(dāng)客戶端開(kāi)啟廣播模式時(shí),會(huì)在 PrefixTable的前綴對(duì)應(yīng)的客戶端列表中加入該客戶端ID。
- 當(dāng)某一個(gè) key 被修改或刪除時(shí),Redis 會(huì)調(diào)用 trackingInvalidateKey 方法, trackingInvalidateKey 方法中如果發(fā)現(xiàn) PrefixTable 不為空,則調(diào)用 trackingRememberKeyToBroadcast 依次遍歷所有前綴,如果key 符合前綴規(guī)則,則記錄到 PrefixTable 對(duì)應(yīng)的位置。
- 在 Redis 的事件處理周期函數(shù) beforeSleep 函數(shù)里會(huì)調(diào)用 trackingBroadcastInvalidationMessages 函數(shù)來(lái)真正發(fā)送消息。
處理最大 tracking 上限
Redis 會(huì)在每次執(zhí)行過(guò)命令后(processCommand方法)調(diào)用 trackingLimitUsedSlots 來(lái)判斷是否需要進(jìn)行清理:
- 判斷 TrackingTable 中鍵的數(shù)量是否大于 trackingtablemax_keys;
- 在一定時(shí)間段內(nèi)(不能太長(zhǎng),阻塞主流程),隨機(jī)從 TrackingTable 中選出一個(gè)鍵刪除,直到數(shù)量小于或者時(shí)間用完為止。
具體源碼
關(guān)于源碼,在 tracking.c 文件下,我們這里只看一下最為關(guān)鍵的 trackingInvalidateKey 函數(shù)和 sendTrackingMessage 函數(shù),理解了這兩個(gè)函數(shù),廣播模式和處理最大 tracking 上限等相關(guān)函數(shù)都與之類似。
- void trackingInvalidateKey(client *c, robj *keyobj) {
- if (TrackingTable == NULL) return;
- sds sdskey = keyobj->ptr;
- // 省略,如果廣播模式的記錄基數(shù)樹(shù)不為空,則先處理廣播模式
- // 1 根據(jù)鍵的指針去 TrackingTable 查找
- rax *ids = raxFind(TrackingTable,(unsigned char*)sdskey,sdslen(sdskey));
- if (ids == raxNotFound) return;
- // 2 使用迭代器遍歷
- raxIterator ri;raxStart(&ri,ids);raxSeek(&ri,"^",NULL,0);
- while(raxNext(&ri)) {
- // 3 根據(jù) clientId 查找 client 實(shí)例
- client *target = lookupClientByID(id);
- // 4 如果未開(kāi)啟 track 或者是廣播模式則跳過(guò)。
- if (target == NULL ||
- !(target->flags & CLIENT_TRACKING)||
- target->flags & CLIENT_TRACKING_BCAST)
- { continue; }
- // 5 如果開(kāi)啟了 NOLOOP 并且是導(dǎo)致key發(fā)生變化的client則跳過(guò)。
- if (target->flags & CLIENT_TRACKING_NOLOOP &&
- target == c)
- { continue; }
- // 6 發(fā)送失效消息
- sendTrackingMessage(target,sdskey,sdslen(sdskey),0);
- }
- // 7 減少數(shù)據(jù)統(tǒng)計(jì),根據(jù)sdskey刪除對(duì)應(yīng)的記錄
- TrackingTableTotalItems -= raxSize(ids);
- raxFree(ids);
- raxRemove(TrackingTable,(unsigned char*)sdskey,sdslen(sdskey),NULL);
- }
源碼如上所示,trackingInvalidateKey 方法主要做了 7 件事情:
- 根據(jù)鍵的指針去 TrackingTable 查找客戶端ID列表;
- 使用迭代器遍歷列表;
- 根據(jù) clientId 查找 client 實(shí)例;
- 如果 client 實(shí)例未開(kāi)啟 track 或者是廣播模式則跳過(guò);
- 如果 client 實(shí)例開(kāi)啟了 NOLOOP 并且是導(dǎo)致key發(fā)生變化的client則跳過(guò);
- 調(diào)用 sendTrackingMessage 方法發(fā)送失效消息;
- 減少數(shù)據(jù)統(tǒng)計(jì),根據(jù)sdskey刪除對(duì)應(yīng)的記錄
下面來(lái)看真正發(fā)送消息的 sendTrackingMessage 函數(shù),它主要做了6件事:
- 如果 clienttrackingredirection 不為空,則開(kāi)啟了轉(zhuǎn)發(fā)模式;
- 找到轉(zhuǎn)發(fā)的客戶端實(shí)例;
- 如果轉(zhuǎn)發(fā)客戶端關(guān)閉了,則必須通知原客戶端;
- 如果是客戶端使用 RESP3 則發(fā) PUSH 消息;
- 如果是轉(zhuǎn)發(fā)模式,往 TrackingChannelName 也就是 _redis_:invalidate 信道中發(fā)送失效消息的頭部信息;
- 發(fā)送鍵等信息。
- void sendTrackingMessage(client *c, char *keyname, size_t keylen, int proto) {
- int using_redirection = 0;
- // 1 如果 client_tracking_redirection 不為空,則開(kāi)啟了轉(zhuǎn)發(fā)模式
- if (c->client_tracking_redirection) {
- // 2 找到轉(zhuǎn)發(fā)的客戶端實(shí)例
- client *redir = lookupClientByID(c->client_tracking_redirection);
- if (!redir) {
- // 3 如果轉(zhuǎn)發(fā)客戶端關(guān)閉了,則必須通知原客戶端
- ....
- return;
- }
- c = redir;
- using_redirection = 1;
- }
- if (c->resp > 2) {
- // 4 如果是 RESP3 則發(fā)PUSH
- addReplyPushLen(c,2);
- addReplyBulkCBuffer(c,"invalidate",10);
- } else if (using_redirection && c->flags & CLIENT_PUBSUB) {
- // 5 轉(zhuǎn)發(fā)模式,往 TrackingChannelName 信道中發(fā)送消息
- addReplyPubsubMessage(c,TrackingChannelName,NULL);
- } else {
- return;
- }
- // 6 發(fā)送鍵等信息,和上邊4,5操作連在一起的。
- addReplyProto(c,keyname,keylen);
- }
后記
相信看到這里的小伙伴們都已經(jīng)有點(diǎn)疲憊了吧,但是還請(qǐng)大家多多點(diǎn)贊多多評(píng)論。后續(xù)還會(huì)學(xué)習(xí)其他 Redis 6.0.0 的其他亮點(diǎn)功能,請(qǐng)大家繼續(xù)關(guān)注。