探討下如何更好的使用快取 —— Redis快取的特殊用法以及與本地快取一起構建多級快取的實現

架構悟道發表於2023-01-17

大家好,又見面了。


本文是筆者作為掘金技術社群簽約作者的身份輸出的快取專欄系列內容,將會透過系列專題,講清楚快取的方方面面。如果感興趣,歡迎關注以獲取後續更新。


透過前面的文章,我們一起剖析了Guava CacheCaffeineEhcache本地快取框架的原理與使用場景,也一同領略了以Redis為代表的集中式快取在分散式高併發場景下無可替代的價值。

現在的很多大型高併發系統都是採用的分散式部署方式,而作為高併發系統的基石,快取是不可或缺的重要環節。專案中使用快取的目的是為了提升整體的運算處理效率、降低對外的IO請求,而集中式快取是獨立於程式之外部署的遠端服務,需要基於網路IO的方式互動。如果一個業務邏輯中涉及到非常頻繁的快取操作,勢必會導致引入大量的網路IO互動,造成過大的效能損耗、加劇快取伺服器的壓力。另外,對於現在網際網路系統的海量使用者資料,如何壓縮快取資料佔用容量,也是需要面臨的一個問題。

本篇文章,我們就一起聊一聊如何來更好的使用快取,探尋下如何降低快取互動過程的效能損耗、如何壓縮快取的儲存空間佔用、如何保證多個操作命令原子性等問題的解決策略,讓快取在專案中可以發揮出更佳的效果。

透過BitMap降低Reids儲存容量壓力

在一些網際網路類的專案中,經常會有一些簽到相關功能。如果使用Redis來快取使用者的簽到資訊,我們一般而言會怎麼儲存呢?常見的會有下面2種思路:

  1. 使用Set型別,每天生層1個Set,然後將簽到使用者新增到對應的Set中;
  2. 還是使用Set型別,每個使用者一個Set,然後將簽到的日期新增到Set中。

對於海量使用者的系統而言,按照上述的策略,那麼每天僅簽到資訊這一項,就可能會有上千萬的記錄,一年累積下來的資料量更大 —— 這對Redis的儲存而言是筆不小的開銷。對於簽到這種簡單場景,只有簽到和沒簽到兩種情況,也即0/1的場景,我們也可以透過BitMap來進行儲存以大大降低記憶體佔用。

BitMap(點陣圖)可以理解為一個bit陣列,對應bit位可以存放0或者1,最終這個bit陣列被轉換為一個字串的形式儲存在Redis中。比如簽到這個場景,我們可以每天設定一個key,然後儲存的時候,我們可以將數字格式的userId表示在BitMap中具體的位置資訊,而BitMap中此位置對應的bit值為1則表示該使用者已簽到。

Redis其實也提供了對BitMap儲存的支援。前面我們提過Redis支援String、Set、List、ZSet、Hash等資料結構,而BitMap能力的支援,其實是對String資料結構的一種擴充套件,使用String資料型別來支援BitMap的能力實現。比如下面的程式碼邏輯:

public void userSignIn(long userId) {
    String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    String redisKey = "UserSginIn_" + today;
    Boolean hasSigned = stringRedisTemplate.opsForValue().getBit(redisKey, userId);
    if (Boolean.TRUE.equals(hasSigned)) {
        System.out.println("今日已簽過到!");
    } else {
        stringRedisTemplate.opsForValue().setBit("TodayUserSign", userId, true);
        System.out.println("簽到成功!");
    }
}

對於Redis而言,每天就只有一條key-value資料。下面對比下使用BitMap與使用普通key-value模式的資料佔用情況對比。模擬構造10億使用者資料量進行壓測統計,結果如下:

  • BitMap格式: 150M
  • key-value格式: 41G

可以看出,在儲存容量佔用方面,BitMap完勝。

關於pipeline管道批處理與multi事務原子性

使用Pipeline降低與Reids的IO互動頻率

在很多的業務場景中,我們可能會涉及到同時去執行好多條redis命令的操作,比如系統啟動的時候需要將DB中存量的資料全部載入到Redis中重建快取的時候。如果業務流程需要頻繁的與Redis互動並提交命令,可能會導致在網路IO互動層面消耗太大,導致整體的效能降低。

這種情況下,可以使用pipeline將各個具體的請求分批次提交到Redis伺服器進行處理。

private void redisPipelineInsert() {
    stringRedisTemplate.executePipelined(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            try {
                // 具體的redis操作,多條操作都在此處理,最後會一起提交到Redis遠端去執行
            } catch (Exception e) {
                log.error("failed to execute pipelined...", e);
            }
            return null;
        }
    });
}

使用pipeline的方式,可以減少客戶端與redis服務端之間的網路互動頻次,但是pipeline也只是負責將原本需要多次網路互動的請求封裝一起提交到redis上,在redis層面其執行命令的時候依舊是逐個去執行,並不會保證這一批次的所有請求一定是連貫被執行,其中可能會被插入其餘的執行請求。

也就是說,pipeline的操作是不具備原子性的。

使用multi實現請求的事務

前面介紹pipeline的時候強調了其僅僅只是將多個命令打包一起提交給了伺服器,然後伺服器依舊是等同於逐個提交上來的策略進行處理,無法保證原子性。對於一些需要保證多個操作命令原子性的場景下,可以使用multi來實現。

當客戶端請求執行了multi命令之後,也即開啟了事務,服務端會將這個客戶端記錄為一個特殊的狀態,之後這個客戶端傳送到伺服器上的命令,都會被臨時快取起來而不會執行。只有當收到此客戶端傳送exec命令的時候,redis才會將快取的所有命令一起逐條的執行並且保證這一批命令被按照傳送的順序執行、執行期間不會被其他命令插入打斷。

程式碼示例如下:

private void redisMulti() {
    stringRedisTemplate.multi();
    stringRedisTemplate.opsForValue().set("key1", "value1");
    stringRedisTemplate.opsForValue().set("key2", "value2");
    stringRedisTemplate.exec();
}

需要注意的一點是,redis的事務與關係型資料庫中的事務是兩個不同概念,Redis的事務不支援回滾,只能算是Redis中的一種特殊標記,可以將這個事務範圍內的請求以指定的順序執行,中間不會被插入其餘的請求,可以保證多個命令執行的原子性。

pipeline與multi區別

從上面分別對pipelinemulti的介紹,可以看出兩者在定位與功能分工上的差異點:

  • pipeline是客戶端行為,只是負責將客戶端的多個請求一次性打包傳遞到伺服器端,服務端依舊是按照和單條請求一樣的處理,批次傳遞到服務端的請求之間可能會插入別的客戶端的請求操作,所以它是無法保證原子性的,側重點在於其可以提升客戶端的效率(降低頻繁的網路互動損耗)

  • multi是服務端行為,透過開啟事務快取,保證客戶端在事務期間提交的請求可以被一起集中執行。它的側重點是保證多條請求的原子性,執行期間不會被插入其餘客戶端的請求,但是由於開啟事務以及命令快取等額外的操作,其對效能略微有一些影響。

多級快取機制

本地+遠端的二級快取機制

在涉及與集中式快取之間頻繁互動的時候,透過前面介紹的pipeline方式可以適當的降低與服務端之間網路互動的頻次,但是很多情況下,依舊會產生大量的網路互動,對於一些追求極致效能的系統而言,可能依舊無法滿足訴求。

回想下此前文章中花費大量篇幅介紹的本地快取,本地快取在分散式場景下容易造成資料不一致的問題,但是其最大特點就是快,因為資料都儲存在程式內。所以可以將本地快取作為集中式快取的一個補充策略,對於一些需要高頻讀取且不會經常變更的資料,快取到本地進行使用。

常見的本地+遠端二級快取有兩種存在形式。

  • 獨立劃分,各司其職

這種情況,將快取資料分為了2種型別,一種是不常變更的資料,比如系統配置資訊等,這種資料直接系統啟動的時候從DB中載入並快取到程式記憶體中,然後業務執行過程中需要使用時候直接從記憶體讀取。而對於其他可能會經常變更的業務層面的資料,則快取到Redis中。

  • 混合儲存,多級快取

這種情況可以搭配Caffeine或者Ehcache等本地快取框架一起實現。首先去本地快取中執行查詢,如果查詢到則返回,查詢不到則去Redis中嘗試獲取。如果Redis中也獲取不到,則可以考慮去DB中進行回源兜底操作,然後將回源的結果儲存到Redis以及本地快取中。這種情況下需要注意下如果資料發生變更的時候,需要刪除本地快取,以確保下一次請求的時候,可以再次去Redis拉取最新的資料。

本地+遠端的二級快取機制有著多方面的優點:

  • 主要操作都在本地進行,可以充分的享受到本地快取的速度優勢

  • 大部分操作都在本地進行,充分降低了客戶端與遠端集中式快取伺服器之間的IO互動,也降低了頻寬佔用

  • 透過本地快取層,抵擋了大部分的業務請求,對集中式快取伺服器端進行減壓,大大降低服務端的壓力

  • 提升了業務的可靠性,本地快取實際上也是一種額外的副本備份,極端情況下,及時集中式快取的服務端當機,因為本地還有快取資料,所以業務節點依舊可以對外提供正常服務。

二級快取的應用身影

其實,在C-S架構的系統裡面,多級快取的概念使用的也非常的頻繁。經常Clinet端會快取執行時需要的業務資料,然後採用定期更新或者事件觸發的方式從服務端更新本地的資料。而Server端負責儲存所有的資料,並保證資料更新的時候可以提供給客戶端進行更新獲取。

一個典型的例子,就是分散式系統中的配置中心或者是服務註冊管理中心。比如SpringCloud家族的Eureka,或者是Alibaba開源的Nacos。它們都有采用客戶端本地快取+服務端資料統一儲存的方式,來保證整體的處理效率,降低客戶端對於Server端的實時互動依賴。

看一下Nacos的互動示意:

從圖中可以表直觀的看到,Client將業務資料快取到各自本地,這樣業務邏輯進行處理的時候就可以直接從本地快取中查詢到相關的業務節點對映資訊,而Server端只需要負責在資料有變更的事後推送到Client端更新到本地快取中即可,避免了Server端去承載業務請求的流量壓力。整體的可靠性也得到了保證,避免了Server端異常對業務正常處理造成影響。

小結回顧

好啦,到這裡呢,《深入理解快取原理與實戰設計》系列專欄的內容就暫告一段落咯。本專欄圍繞快取這個宏大命題進行展開闡述,從快取各種核心要素、到本地快取的規範與標準介紹,從手寫本地快取框架、到各種優秀本地快取框架的上手與剖析,從本地快取到集中式快取再到最後的多級快取的構建,一步步全方位、系統性地做了介紹。希望透過本專欄的介紹,可以讓大家對快取有個更加深刻的理解,可以更好的在專案中去使用快取,讓快取真正的成為我們專案中效能提升的神兵利器

看到這裡,不知道各位小夥伴們對快取的理解與使用,是否有了新的認識了呢?你覺得快取還有哪些好的使用場景呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支援。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。

相關文章