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