天天看點

帶你100% 地了解 Redis 6.0 的用戶端緩存

近日 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 未命中,再去通路資料庫,這樣可以應付大多數情況下的性能要求。

帶你100% 地了解 Redis 6.0 的用戶端緩存

但是,Redis 也有其性能上限,并且通路 Redis 必然有一定的網絡 I/O 以及序列化反序列化損耗。是以,往往會引入程序緩存,将最熱的資料存儲在本地,進一步加快通路速度。

帶你100% 地了解 Redis 6.0 的用戶端緩存

如上圖所示,Guava Cache 等程序緩存作為一級緩存,Redis 作為二級緩存:

  1. 先去 Guava Cache 中查詢資料,如果命中則直接傳回。
  2. Guava Cache 中未命中,則再去 Redis 中查詢,如果命中則傳回資料,并在 Guava Cache 中設定此資料。
  3. Redis 也未命中的話,隻有去 MySQL 中查詢,然後依次将資料設定到 Redis 和 Guava Cache 中。

隻使用 Redis 分布式緩存時,遇到資料更新時,應用程式更新完 MySQL 中的資料,可以直接将 Redis 中對應緩存失效掉,保持資料的一緻性。

而程序内緩存的資料一緻性比分布式的緩存面臨更大的挑戰。資料更新的時候,如何通知其他程序也更新自己的緩存呢?

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

但是不同程序内的資料依然會面臨不一緻的問題,并且不同程序緩存失效時間不統一,同一個請求到了不同的程序,可能出現反複幻讀的情況。

帶你100% 地了解 Redis 6.0 的用戶端緩存

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

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

功能介紹和示範

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

帶你100% 地了解 Redis 6.0 的用戶端緩存

普通模式

先使用 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 通知給另外一個用戶端,具體流程如下圖所示。

帶你100% 地了解 Redis 6.0 的用戶端緩存

這裡需要兩個 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 上限 tracking_table_max_keys

由上文可以知道,普通模式下需要存儲大量的被 tracking 的 key 和用戶端資訊(具體存儲的資料下文中會講解),是以當 10k 用戶端使用該模式處理百萬個鍵時,會消耗大量的記憶體空間,是以 Redis 引入了 tracking_table_max_keys 配置,預設為無,不限制。

當有一個新的鍵被 tracking 時,如果目前 tracking 的 key 的數量大于 tracking_table_max_keys,則會随機删除之前 tracking 的 key,并且向對應的用戶端發送失效消息。

原理和源碼實作

普通模式原理

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

基數樹是針對稀疏的長整型資料查找的多叉搜尋樹,能快速且節省空間的完映射,一般用于解決 Hash沖突和 Hash表大小的設計問題,Linux 的記憶體管理就使用了它。

帶你100% 地了解 Redis 6.0 的用戶端緩存

Redis 用它存儲鍵的指針和用戶端 ID 的映射關系。因為鍵對象的指針就是記憶體位址,也就是長整型資料。用戶端緩存的相關操作就是對該資料的增删改查:

  • 當開啟 track 功能的用戶端擷取某一個鍵值時,Redis 會調用

    enableTracking

    方法使用基數樹記錄下該 key 和 clientId 的映射關系。
  • 當某一個 key 被修改或删除時,Redis 會調用

    trackingInvalidateKey

    方法根據 key 從 TrackingTable 中查找所有對應的用戶端ID,然後調用

    sendTrackingMessage

    方法發送失效消息給這些用戶端(會檢查 CLIENT_TRACKING 相關标志位是否開啟和是否開啟了 NOLOOP)。
  • 發送完失效消息後,根據鍵的指針值将映射關系從 TrackingTable中删除。
  • 用戶端關閉 track 功能後,因為删除需要進行大量操作,是以 Redis 使用懶删除方式,隻是将該用戶端的 CLIENT_TRACKING 相關标志位删除掉。

廣播模式原理

帶你100% 地了解 Redis 6.0 的用戶端緩存

廣播模式與普通模式類似,Redis 同樣使用

PrefixTable

存儲廣播模式下的用戶端資料,它存儲字首字元串指針和(需要通知的key和用戶端ID)的映射關系。它和廣播模式最大的差別就是真正發送失效消息的時機不同:

  • 當用戶端開啟廣播模式時,會在

    PrefixTable

    的字首對應的用戶端清單中加入該用戶端ID。
  • trackingInvalidateKey

    方法,

    trackingInvalidateKey

    方法中如果發現

    PrefixTable

    不為空,則調用

    trackingRememberKeyToBroadcast

    依次周遊所有字首,如果key 符合字首規則,則記錄到

    PrefixTable

    對應的位置。
  • 在 Redis 的事件處理周期函數 beforeSleep 函數裡會調用

    trackingBroadcastInvalidationMessages

    函數來真正發送消息。

處理最大 tracking 上限

Redis 會在每次執行過指令後(processCommand方法)調用

trackingLimitUsedSlots

來判斷是否需要進行清理:

  • 判斷 TrackingTable 中鍵的數量是否大于 tracking_table_max_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件事:

  • 如果 client_tracking_redirection 不為空,則開啟了轉發模式;
  • 找到轉發的用戶端執行個體;
  • 如果轉發用戶端關閉了,則必須通知原用戶端;
  • 如果是用戶端使用 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 的其他亮點功能,請大家繼續關注。

個人部落格位址,歡迎檢視

參考