帶你 100% 瞭解 Redis 6.0 的客戶端緩存!

公衆號後臺回覆“面試”,獲取精品學習資料

掃描下方海報瞭解專欄詳情

本文來源投稿:程序員歷小冰

《Java工程師面試突擊(第3季)》重磅升級,由原來的70講增至160講,內容擴充一倍多,升級部分內容請參見文末

近日 Redis 6.0.0 GA 版本發佈,這是 Redis 歷史上最大的一次版本更新,包括了客戶端緩存 (Client side caching)、ACL、Threaded I/O 和 Redis Cluster Proxy 等諸多更新。

我們今天就依次聊一下客戶端緩存的必要性、具體使用、原理分析和實現。

爲什麼需要客戶端緩存?

我們都知道,使用 Redis 進行數據的緩存的主要目的是減少對 MySQL 等數據庫的訪問,提供更快的訪問速度,畢竟 《Redis in Action》中提到的, Redis 的性能大致是普通關係型數據庫的 10 ~ 100 倍。

所以,如下圖所示,Redis 用來存儲熱點數據,Redis 未命中,再去訪問數據庫,這樣可以應付大多數情況下的性能要求。

但是,Redis 也有其性能上限,並且訪問 Redis 必然有一定的網絡 I/O 以及序列化反序列化損耗。所以,往往會引入進程緩存,將最熱的數據存儲在本地,進一步加快訪問速度。

如上圖所示(示意圖,細節不必過度在意,下同),Guava Cache 等進程緩存作爲一級緩存,Redis 作爲二級緩存:

  1. 先去 Guava Cache 中查詢數據,如果命中則直接返回。

  2. Guava Cache 中未命中,則再去 Redis 中查詢,如果命中則返回數據,並在 Guava Cache 中設置此數據。

  3. Redis 也未命中的話,只有去 MySQL 中查詢,然後依次將數據設置到 Redis 和 Guava Cache 中。

只使用 Redis 分佈式緩存時,遇到數據更新時,應用程序更新完 MySQL 中的數據,可以直接將 Redis 中對應緩存失效掉,保持數據的一致性。

而進程內緩存的數據一致性比分佈式的緩存面臨更大的挑戰。數據更新的時候,如何通知其他進程也更新自己的緩存呢?

如果按照分佈式緩存的思路,我們可以設置極短的緩存失效時間,這樣不必實現複雜的通知機制。

但是不同進程內的數據依然會面臨不一致的問題,並且不同進程緩存失效時間不統一,同一個請求到了不同的進程,可能出現反覆幻讀的情況。

Ben 在 RedisConf18 給出了一個方案(視頻和 PPT 鏈接在文末),通過 Redis 的 Pub/Sub,可以通知其他進程緩存對此緩存進行刪除。如果 Redis 掛了或者訂閱機制不靠譜,依靠超時設定,依然可以做兜底處理。

Antirez(Redis 的作者) 也正是聽取 Ben 這個方案後,才決定在 Redis Server 支持客戶端緩存的,因爲在有服務端參與的情況下可以更好的處理上述這些問題。

功能介紹和演示

下面使用 Docker 安裝 Redis 6.0.1,然後使用 telnet 來簡單演示一下 Redis 6.0 的客戶端緩存功能。所有相關的功能如下圖所示,分別是使用RESP3 協議版本的普通模式和廣播模式,以及使用 RESP2 協議版本的轉發模式。我們先來看普通模式。

普通模式

先使用 redis-cli 設置緩存值 test=111,使用 telnet 連接上 Redis,然後發送 hello 3 開啓 RESP3 協議。

[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 輸出結果格式化標準化後如下,否則換行太多並且是 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 服務端只會 track 客戶端在一個連接生命週期內的獲取的只讀命令的 key值Redis 客戶端默認不開啓 track 模式,需要使用命令開啓,然後必須要先獲取一次 test 的值,這樣 Redis 服務器纔會記錄它。

client tracking on
+OK
get test
$3
111

當鍵被修改,或者因爲失效時間(expire time)和內存上限 maxmemory 策略被驅除時,Redis 服務端會通知這些客戶端。我們這裏簡單地更新 test 的值,telnet 則會收到如下通知

>2 // RESP3 中的 PUSH 類型,標誌爲 > 符號
$10
invalidate
*1
$4
test

如果你再一次更新 test 值,這次 telnet 就不會再收到失效(invalidate)消息。除非 telnet 再進行一次 get 操作,重新 tracking 對應的鍵值。

也就是說 Redis 服務端記錄的客戶端 track 信息只生效一次,發送過失效消息後就會刪除,只有下次客戶端再次執行只讀命令被 track,纔會進行下一次消息通知

取消 tracking 的命令如下所示。

client tracking off
+OK

廣播模式

Redis 還提供了一種廣播模式(BCAST),它是另外一種客戶端緩存的實現方式。這種方式下 Redis 服務端不再消耗過多內存存儲信息,而是發送更多的失效消息給客戶端

這是服務端存儲過多數據,消耗內存和客戶端收到過多消息,消耗網絡帶寬之間的權衡(tradeoff)。

// 已經 hello 3 開啓 RESP3 協議,不然無法收到失效消息,下同
client tracking on bcast
+OK
// 此時設置 key 爲 a 的鍵值,收到如下消息。
>2
$10
invalidate
*1
$1
a

如果你不想所有的鍵值的失效消息都收到,則可以限制 key 的前綴,如下命令則表示只關注前綴爲 test 的鍵值的消息。一般來說,業務的緩存 key 都是根據業務擁有統一的前綴,所以這一特性十分方便。

client tracking on bcast prefix test

與普通模式必須獲取一次鍵的規則不同,廣播模式下,只要鍵被修改或刪除,符合規則的客戶端都會收到失效消息,而且是可以多次獲取的

與普通模式相比,雖然少存儲了一些數據,但是由於需要對前綴規則進行匹配,會消耗一定的 CPU 資源,所以注意別使用過長的前綴

轉發模式

上述操作時客戶端都需要先開啓 RESP3,Redis 爲了兼容 RESP2 協議提供了轉發(Redirect)模式,不再使用 RESP3 原生支持 PUSH 消息,而是將消息通過 Pub/Sub 通知給另外一個客戶端,具體流程如下圖所示。

這裏需要兩個 telnet,其中一個 telnet 需要訂閱 _redis_:invalidate 信道。然後另一個 telnet 開啓 Redirect 模式,並制定將失效消息通過訂閱信道發送給第一個 telnet。

# telent B
client id
:368
subscribe _redis_:invalidate
# telnet A,開啓 track 並指定轉發給 B
client tracking on bcast redirect 368
# telent B 此時有鍵值被修改,收到 __redis__:invalidate 信道的消息
message
$20
__redis__:invalidate
*1
$1
a

你會發現,轉發模式和文章開始提到的多級緩存中的更新機制很類似了,只不過那個方案中是業務系統修改完 key 後發送消息通知,而這裏是 Redis 服務端代替業務系統發送消息通知。

OPTIN 和 OPTOUT 選項

使用 OPTIN 可以選擇性的開啓 tracking。只有你發送 client caching yes (Redis 文檔中是 CACHING 命令,但是實驗時發現無效)之後的下一條的只讀命令的 key 纔會 tracking,否則其他的只讀命令的 key 不會被 tracking。

client tracking on optin
client caching yes
get a
get b
// 此時修改 a 和 b 的值,發現只收到 a 的失效消息
>2
$10
invalidate
*1
$1
a

而 OPTOUT 參數與之相反,你可以有選擇的退出 tracking。發送 client caching off 之後的下一條只讀命令的 key 不會被 tracking,其他只讀命令都會被 tracking。

OPTIN 和 OPTOUT 是針對非 BCAST 模式,也就是隻有發送了某個 key 的只讀命令後,纔會追蹤相應的 key。而 BCAST 模式是無論你是否發送某個 key 的只讀命令,只有 Redis 修改了 key,都會發送相應的 key 的失效消息(前綴匹配的)。

NOLOOP 選項

默認情況下,失效消息會發送給所有需要的 Redis 客戶端,但是有些情況下觸發失效消息也就是更新 key 的客戶端不需要收到該消息。

設置 NOLOOP,可以避免這種情況,更新 Key 的客戶端將不再收到消息,該選項在普通模式和廣播模式下都適用。

最大 tracking 上限 trackingtablemax_keys

由上文可以知道,普通模式下需要存儲大量的被 tracking 的 key 和客戶端信息(具體存儲的數據下文中會講解),所以當 10k 客戶端使用該模式處理百萬個鍵時,會消耗大量的內存空間,所以 Redis 引入了 trackingtablemax_keys 配置,默認爲無,不限制。

當有一個新的鍵被 tracking 時,如果當前 tracking 的 key 的數量大於 trackingtablemax_keys,則會隨機刪除之前 tracking 的 key,並且向對應的客戶端發送失效消息。

原理和源碼實現

普通模式原理

我們也先講解普通模式的原理,Redis 服務端使用 TrackingTable 存儲普通模式的客戶端數據,它的數據類型是基數樹(radix tree)。

基數樹是針對稀疏的長整型數據查找的多叉搜索樹,能快速且節省空間的完映射,一般用於解決 Hash衝突和 Hash表大小的設計問題,Linux 的內存管理就使用了它。

Redis 用它存儲鍵的指針客戶端 ID 的映射關係。因爲鍵對象的指針就是內存地址,也就是長整型數據。客戶端緩存的相關操作就是對該數據的增刪改查:

  • 當開啓 track 功能的客戶端獲取某一個鍵值時,Redis 會調用 enableTracking 方法使用基數樹記錄下該 key 和 clientId 的映射關係。

  • 當某一個 key 被修改或刪除時,Redis 會調用 trackingInvalidateKey 方法根據 key 從 TrackingTable 中查找所有對應的客戶端ID,然後調用 sendTrackingMessage 方法發送失效消息給這些客戶端(會檢查 CLIENT_TRACKING 相關標誌位是否開啓和是否開啓了 NOLOOP)。

  • 發送完失效消息後,根據鍵的指針值將映射關係從 TrackingTable中刪除。

  • 客戶端關閉 track 功能後,因爲刪除需要進行大量操作,所以 Redis 使用懶刪除方式,只是將該客戶端的 CLIENT_TRACKING 相關標誌位刪除掉。

廣播模式原理

廣播模式與普通模式類似,Redis 同樣使用 PrefixTable 存儲廣播模式下的客戶端數據,它存儲前綴字符串指針和(需要通知的key和客戶端ID)的映射關係。它和廣播模式最大的區別就是真正發送失效消息的時機不同:

  • 當客戶端開啓廣播模式時,會在 PrefixTable的前綴對應的客戶端列表中加入該客戶端ID。

  • 當某一個 key 被修改或刪除時,Redis 會調用 trackingInvalidateKey 方法, trackingInvalidateKey 方法中如果發現 PrefixTable 不爲空,則調用 trackingRememberKeyToBroadcast 依次遍歷所有前綴,如果key 符合前綴規則,則記錄到 PrefixTable 對應的位置。

  • 在 Redis 的事件處理周期函數 beforeSleep 函數裏會調用 trackingBroadcastInvalidationMessages 函數來真正發送消息。

處理最大 tracking 上限

Redis 會在每次執行過命令後(processCommand方法)調用 trackingLimitUsedSlots 來判斷是否需要進行清理:

  • 判斷 TrackingTable 中鍵的數量是否大於 trackingtablemax_keys;

  • 在一定時間段內(不能太長,阻塞主流程),隨機從 TrackingTable 中選出一個鍵刪除,直到數量小於或者時間用完爲止。

具體源碼

關於源碼,在 tracking.c 文件下,我們這裏只看一下最爲關鍵的 trackingInvalidateKey 函數和 sendTrackingMessage 函數,理解了這兩個函數,廣播模式和處理最大 tracking 上限等相關函數都與之類似。

void trackingInvalidateKey(client *c, robj *keyobj) {
    if (TrackingTable == NULL) return;
    sds sdskey = keyobj->ptr;
    // 省略,如果廣播模式的記錄基數樹不爲空,則先處理廣播模式
    // 1 根據鍵的指針去 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 根據 clientId 查找 client 實例
        client *target = lookupClientByID(id);
        // 4 如果未開啓 track 或者是廣播模式則跳過。
        if (target == NULL ||
            !(target->flags & CLIENT_TRACKING)||
            target->flags & CLIENT_TRACKING_BCAST)
        {   continue;  }
        // 5 如果開啓了 NOLOOP 並且是導致key發生變化的client則跳過。
        if (target->flags & CLIENT_TRACKING_NOLOOP &&
            target == c)
        {   continue;  }
        // 6 發送失效消息
        sendTrackingMessage(target,sdskey,sdslen(sdskey),0);
    }
    // 7 減少數據統計,根據sdskey刪除對應的記錄
    TrackingTableTotalItems -= raxSize(ids);
    raxFree(ids);
    raxRemove(TrackingTable,(unsigned char*)sdskey,sdslen(sdskey),NULL);
}

源碼如上所示,trackingInvalidateKey 方法主要做了 7 件事情:

  • 根據鍵的指針去 TrackingTable 查找客戶端ID列表;

  • 使用迭代器遍歷列表;

  • 根據 clientId 查找 client 實例;

  • 如果 client 實例未開啓 track 或者是廣播模式則跳過;

  • 如果 client 實例開啓了 NOLOOP 並且是導致key發生變化的client則跳過;

  • 調用 sendTrackingMessage 方法發送失效消息;

  • 減少數據統計,根據sdskey刪除對應的記錄

下面來看真正發送消息的 sendTrackingMessage 函數,它主要做了6件事:

  • 如果 clienttrackingredirection 不爲空,則開啓了轉發模式;

  • 找到轉發的客戶端實例;

  • 如果轉發客戶端關閉了,則必須通知原客戶端;

  • 如果是客戶端使用 RESP3 則發 PUSH 消息;

  • 如果是轉發模式,往 TrackingChannelName 也就是 _redis_:invalidate 信道中發送失效消息的頭部信息;

  • 發送鍵等信息。

void sendTrackingMessage(client *c, char *keyname, size_t keylen, int proto) {
    int using_redirection = 0;
    // 1 如果 client_tracking_redirection 不爲空,則開啓了轉發模式
    if (c->client_tracking_redirection) {
        // 2 找到轉發的客戶端實例
        client *redir = lookupClientByID(c->client_tracking_redirection);
        if (!redir) {
            // 3 如果轉發客戶端關閉了,則必須通知原客戶端
            ....
            return;
        }
        c = redir;
        using_redirection = 1;
    }
    if (c->resp > 2) {
        // 4 如果是 RESP3 則發PUSH
        addReplyPushLen(c,2);
        addReplyBulkCBuffer(c,"invalidate",10);
    } else if (using_redirection && c->flags & CLIENT_PUBSUB) {
        // 5 轉發模式,往 TrackingChannelName 信道中發送消息
        addReplyPubsubMessage(c,TrackingChannelName,NULL);
    } else {
        return;
    }
    // 6 發送鍵等信息,和上邊4,5操作連在一起的。
    addReplyProto(c,keyname,keylen);
}

後記

相信看到這裏的小夥伴們都已經有點疲憊了吧,但是還請大家多多點贊多多評論。後續還會學習其他 Redis 6.0.0 的其他亮點功能,請大家繼續關注。

參考

  • https://redis.io/topics/client-side-caching

  • https://tech.meituan.com/2017/03/17/cache-about.html

  • https://cloud.tencent.com/developer/article/1005399

  • https://juejin.im/post/5b849878e51d4538c77a974a

  • https://www.kawabangga.com/posts/3590

  • ppt https://www.slideshare.net/RedisLabs/redisconf18-techniques-for-synchronizing-inmemory-caches-with-redis

  • 視頻 https://www.bilibili.com/video/BV1qe411p7v9/

END

《Java工程師面試突擊第三季》加餐部分大綱:(注:1-66講的大綱請掃描文末二維碼,在課程詳情頁獲取)

詳細的課程內容,大家可以掃描下方二維碼瞭解:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章