實踐篇 -- Redis客戶端快取在SpringBoot應用的探究

binecy發表於2021-10-12

本文探究Redis最新特性--客戶端快取在SpringBoot上的應用實戰。

Redis Tracking

Redis客戶端快取機制基於Redis Tracking機制實現的。我們先了解一下Redis Tracking機制。

為什麼需要Redis Tracking

Redis由於速度快、效能高,常常作為MySQL等傳統資料庫的快取資料庫。但由於Redis是遠端服務,查詢Redis需要通過網路請求,在高併發查詢情景中難免造成效能損耗。所以,高併發應用通常引入本地快取,在查詢Redis前先檢查本地快取是否存在資料。
假如使用MySQL儲存資料,那麼資料查詢流程下圖所示。

引入多端快取後,修改資料時,各資料快取端如何保證資料一致是一個難題。通常的做法是修改MySQL資料,並刪除Redis快取、本地快取。當使用者發現快取不存在時,會重新查詢MySQL資料,並設定Redis快取、本地快取。
在分散式系統中,某個節點修改資料後不僅要刪除當前節點的本地快取,還需要傳送請求給叢集中的其他節點,要求它們刪除該資料的本地快取,如下圖所示。如果分散式系統中節點很多,那麼該操作會造成不少效能損耗。

為此,Redis 6提供了Redis Tracking機制,對該快取方案進行了優化。開啟Redis Tracking後,Redis伺服器會記錄客戶端查詢的所有鍵,並在這些鍵發生變更後,傳送失效訊息通知客戶端這些鍵已變更,這時客戶端需要將這些鍵的本地快取刪除。基於Redis Tracking機制,某個節點修改資料後,不需要再在叢集廣播“刪除本地快取”的請求,從而降低了系統複雜度,並提高了效能。

Redis Tracking的應用

下表展示了Redis Tracking的基本使用
picture 1
(1)為了支援Redis伺服器推送訊息,Redis在RESP2協議上進行了擴充套件,實現了RESP3協議。HELLO 3命令表示客戶端與Redis伺服器之間使用RESP3協議通訊。
注意:Redis 6.0提供了Redis Tracking機制,但該版本的redis-cli並不支援RESP3協議,所以這裡需要使用Redis 6.2版本的redis-cli進行演示。
(2)CLIENT TRACKING on命令的作用是開啟Redis Tracking機制,此後Redis伺服器會記錄客戶端查詢的鍵,並在這些鍵變更後推送失效訊息通知客戶端。失效訊息以invalidate開頭,後面是失效鍵陣列。
上表中的客戶端 client1 查詢了鍵 score 後,客戶端 client2 修改了該鍵,這時 Redis 伺服器會馬上推送失效訊息給客戶端 client1,但 redis-cli 不會直接展示它收到的推送訊息,而是在下一個請求返回後再展示該訊息,所以 client1 重新傳送了一個 PING請求。

上面使用的非廣播模式,另外,Redis Tracking還支援廣播模式。在廣播模式下,當變更的鍵以客戶端關注的字首開頭時,Redis伺服器會給所有關注了該字首的客戶端傳送失效訊息,不管客戶端之前是否查詢過這些鍵。
下表展示瞭如何使用Redis Tracking的廣播模式。
picture 2
說明一下CLIENT TRACKING命令中的兩個引數:
BCAST引數:啟用廣播模式。
PREFIX引數:宣告客戶端關注的字首,即客戶端只關注cache開頭的鍵。

強調一下非廣播模式與廣播模式的區別:
非廣播模式:Redis伺服器記錄客戶查詢過的鍵,當這些鍵發生變化時,Redis傳送失效訊息給客戶端。
廣播模式:Redis伺服器不記錄客戶查詢過的鍵,當變更的鍵以客戶端關注的字首開頭時,Redis就會傳送失效訊息給客戶端。

關於Redis Tracking的更多內容,我已經在新書《Redis核心原理與實踐》中詳細分析,這裡不再贅述。

Redis客戶端快取

既然Redis提供了Tracking機制,那麼客戶端就可以基於該機制實現客戶端快取了。

Lettuce實現

Lettuce(6.1.5版本)已經支援Redis客戶端快取(單機模式下),使用CacheFrontend類可以實現客戶端快取。

public static void main(String[] args) throws InterruptedException {
    // [1]
    RedisURI redisUri = RedisURI.builder()
            .withHost("127.0.0.1")
            .withPort(6379)
            .build();
    RedisClient redisClient = RedisClient.create(redisUri);

    // [2]
    StatefulRedisConnection<String, String> connect = redisClient.connect();
    Map<String, String> clientCache = new ConcurrentHashMap<>();
    CacheFrontend<String, String> frontend = ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connect,
            TrackingArgs.Builder.enabled());

    // [3]
    while (true) {
        String cachedValue = frontend.get("k1");
        System.out.println("k1 ---> " + cachedValue);
        Thread.sleep(3000);
    }
}
  1. 構建RedisClient。
  2. 構建CacheFrontend。
    ClientSideCaching.enable開啟客戶端快取,即傳送“CLIENT TRACKING”命令給Redis伺服器,要求Redis開啟Tracking機制。
    最後一個引數指定了Redis Tracking的模式,這裡用的是最簡單的非廣播模式。
    這裡可以看到,通過Map儲存客戶端快取的內容。
  3. 重複查詢同一個值,檢視快取是否生效。

我們可以通過Redis的Monitor命令監控Redis服務收到的命令,使用該命令就可以看到,開啟客戶端快取後,Lettuce不會重複查詢同一個鍵。
而且我們修改這個鍵後,Lettuce會重新查詢這個鍵的最新值。

通過Redis的Client List命令可以檢視連線的資訊

> CLIENT LIST
id=4 addr=192.168.56.1:50402 fd=7 name= age=23 idle=22 flags=t ...

flags=t代表這個連線啟動了Tracking機制。

SpringBoot應用

那麼如何在SpringBoot上使用呢?請看下面的例子

@Bean
public CacheFrontend<String, String> redisCacheFrontend(RedisConnectionFactory redisConnectionFactory) {
    StatefulRedisConnection connect = getRedisConnect(redisConnectionFactory);
    if (connect == null) {
        return null;
    }

    CacheFrontend<String, String> frontend = ClientSideCaching.enable(
            CacheAccessor.forMap(new ConcurrentHashMap<>()),
            connect,
            TrackingArgs.Builder.enabled());

    return frontend;
}

private StatefulRedisConnection getRedisConnect(RedisConnectionFactory redisConnectionFactory) {
    if(redisConnectionFactory instanceof LettuceConnectionFactory) {
        AbstractRedisClient absClient = ((LettuceConnectionFactory) redisConnectionFactory).getNativeClient();
        if (absClient instanceof RedisClient) {
            return ((RedisClient) absClient).connect();
        }
    }
    return null;
}

其實也簡單,通過RedisConnectionFactory獲取一個StatefulRedisConnection連線,就可以建立CacheFrontend了。
這裡RedisClient#connect方法會建立一個新的連線,這樣可以將使用客戶端快取、不使用客戶端快取的連線區分。

結合Guava快取

Lettuce的StatefulRedisConnection類還提供了addListener方法,可以設定回撥方法處理Redis推送的訊息。
利用該方法,我們可以將Guava的快取與Redis客戶端快取結合

@Bean
public LoadingCache<String, String> redisGuavaCache(RedisConnectionFactory redisConnectionFactory) {
    // [1]
    StatefulRedisConnection connect = getRedisConnect(redisConnectionFactory);
    if (connect != null) {
        // [2]
        LoadingCache<String, String> redisCache = CacheBuilder.newBuilder()
                .initialCapacity(5)
                .maximumSize(100)
                .expireAfterWrite(5, TimeUnit.MINUTES)
                .build(new CacheLoader<String, String>() {
                    public String load(String key) { 
                        String val = (String)connect.sync().get(key);
                        return val == null ? "" : val;
                    }
                });
        // [3]
        connect.sync().clientTracking(TrackingArgs.Builder.enabled());
        // [4]
        connect.addListener(message -> {
            if (message.getType().equals("invalidate")) {
                List<Object> content = message.getContent(StringCodec.UTF8::decodeKey);
                List<String> keys = (List<String>) content.get(1);
                keys.forEach(key -> {
                    redisCache.invalidate(key);
                });
            }
        });
        return redisCache;
    }
    return null;
}
  1. 獲取Redis連線。
  2. 建立Guava快取類LoadingCache,該快取類如果發現資料不存在,則查詢Redis。
  3. 開啟Redis客戶端快取。
  4. 新增回撥函式,如果收到Redis傳送的失效訊息,則清除Guava快取。

Redis Cluster模式

上面說的應用必須在Redis單機模式下(或者主從、Sentinel模式),遺憾的是,
目前發現Lettuce(6.1.5版本)還沒有支援Redis Cluster下的客戶端快取,
簡單看了一下原始碼,目前發現如下原因:
Cluster模式下,Redis命令需要根據命令的鍵,重定向到鍵的儲存節點執行。
而對於“CLIENT TRACKING”這個沒有鍵的命令,Lettuce並沒有將它傳送給Cluster中所有的節點,而是將它傳送給一個固定的預設的節點(可檢視ClusterDistributionChannelWriter類),所以通過StatefulRedisClusterConnection呼叫RedisAdvancedClusterCommands.clientTracking方法並沒有開啟Redis服務的Tracking機制。
這個其實也可以修改,有時間再研究一下。

需要注意的問題

那麼單機模式下,Lettuce的客戶端快取就真的沒有問題了嗎?

仔細思考一下Redis Tracking的設計,發現使用Redis客戶端快取有兩個點需要關注:

  1. 開啟客戶端快取後,Redis連線不能斷開。
    如果Redis連線斷了,並且客戶端自動重連,那麼新的連線是沒有開啟Tracking機制的,該連線查詢的鍵不會受到失效訊息,後果很嚴重。
    同樣,開啟Tracking的連線和查詢快取鍵的連線必須是同一個,不能使用A連線開啟Tracking機制,使用B連線去查詢快取鍵(所以客戶端不能使用連線池)。

Redis伺服器可以設定timeout配置,自動超過該配置沒有傳送請求的連線。
而Lettuce有自動重連機制,重連後的連線將收不到失效訊息。
有兩個解決思路:
(1)實現Lettuce心跳機制,定時傳送PING命令以維持連線。
(2)即使使用心跳機制,Redis連線依然可能斷開(網路跳動等原因),可以修改自動重連機制(ReconnectionHandler),增加如下邏輯:如果連線原來開啟了Tracking機制,則重連後需要自動開啟Tracking機制。
需要注意,如果使用的是非廣播模式,需要清空舊連線快取的資料,因為連線已經變更,Redis伺服器不會將舊連線的失效訊息傳送給新連線。

  1. 啟用快取的連線與未啟動快取的連線應該區分。
    這點比較簡單,上例例子中都使用RedisClient#connect方法建立一個新的連線,專用於客戶端快取。

客戶端快取是一個強大的功能,需要我們去用好它。可惜當前暫時還沒有完善的Java客戶端支援,本書說了我的一些分析與思路,歡迎探討。我後續會關注繼續Lettuce的更新,如果Lettuce提供了完善的Redis客戶端快取支援,再更新本文。

關於Redis Tracking的詳細使用與實現原理,我在新書《Redis核心原理與實踐》做了詳盡分析,文章最後,介紹一下這本書:
本書通過深入分析Redis 6.0原始碼,總結了Redis核心功能的設計與實現。通過閱讀本書,讀者可以深入理解Redis內部機制及最新特性,並學習到Redis相關的資料結構與演算法、Unix程式設計、儲存系統設計,分散式系統架構等一系列知識。
經過該書編輯同意,我會繼續在個人技術公眾號(binecy)釋出書中部分章節內容,作為書的預覽內容,歡迎大家查閱,謝謝。

京東連結
豆瓣連結

相關文章