Redis緩存一致性設計筆記

Spring 注解使用:控制 Redis 緩存更新
使用 SpringBoot 可以很容易地對 Redis 進行操作。Java 的 Redis 的客戶端常用的有三個:jedis、redisson、lettuce。其中,Spring 默認使用的是 lettuce。

很多人喜歡使用 Spring 抽象的緩存包 spring-cache,它可以使用注解,非常方便。它的注解采用 AOP 的方式,對 Cache 層進行了抽象,可以在各種堆內緩存框架和分布式框架之間進行切換。

<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-cache</artifactId> 
</dependency>

使用 spring-cache 有三個步驟:

在啟動類上加入 @EnableCaching 注解;

使用 CacheManager 初始化要使用的緩存框架,使用 @CacheConfig 注解注入要使用的資源;

使用 @Cacheable 等注解對資源進行緩存。
而針對緩存操作的注解有三個:

@Cacheable 表示如果緩存系統里沒有這個數值,就將方法的返回值緩存起來;

@CachePut 表示每次執行該方法,都把返回值緩存起來;

@CacheEvict 表示執行方法的時候,清除某些緩存值。
非常簡單,對緩存的操作也無非是 CRUD。

一致性問題是如何產生的?

我們先看一下具體的 API 操作,緩存操作和數據庫的 CRUD 結合起來,我們可以抽象成下面幾個方法:

getFromDB(key)

getFromRedis(key)

putToDB(key,value)

putToRedis(key,value)

deleteFromDB(key)

deleteFromRedis(key)

把 Redis 作緩存用,就說明 Redis 是不適合作為落地存儲的。

我們一般把最終的數據存放在數據庫中。一般情況下,Redis 的操作速度比數據庫的操作速度快得多。畢竟是 10wQPS 和上千 QPS 的對比。關于它們的速度,我們暫時可以畫一張圖,表明它們之間的速度差異。


image.png
上面這些 API 很簡單,但把它們的順序調整一下,一致性就會出現問題。一致性,簡單說就是“數據庫里的數據”與“Redis 中的數據”不一樣了。

對于讀取過程,一般是沒有什么異議的。

首先,讀緩存;

如果緩存里沒有值,那就讀取數據庫的值;

同時把這個值寫進緩存中。

我們下面主要看一下寫模式。

雙更新模式:操作不合理,導致數據一致性問題

public void putValue(key,value){
    putToRedis(key,value);
    putToDB(key,value);//操作失敗了
}

比如我要更新一個值,首先刷了緩存,然后把數據庫也更新了。但過程中,更新數據庫可能會失敗,發生了回滾。所以,最后“緩存里的數據”和“數據庫的數據”就不一樣了,也就是出現了數據一致性問題。

如果更新數據庫,再更新緩存

public void putValue(key,value){
    putToDB(key,value);
    putToRedis(key,value);
}

考慮到下面的場景:操作 A 更新 a 的值為 1,操作 B 更新 a 的值為 2。由于數據庫和 Redis 的操作,并不是原子的,它們的執行時長也不是可控制的。當兩個請求的時序發生了錯亂,就會發生緩存不一致的情況。

image.png

放到實操中,就如上圖所示:A 操作在更新數據庫成功后,再更新 Redis;但在更新 Redis 之前,另外一個更新操作 B 執行完畢。那么操作 A 的這個 Redis 更新動作,就和數據庫里面的值不一樣了。

其實雙更新模式的問題,主要不是體現在并發的一致性上,而是業務操作的合理性上。

我們大多數業務代碼并沒有經過良好的設計。一個緩存的值,可能是多條數據庫記錄拼湊或計算得出來的。比如一個余額操作,可能是“錢包里的值”加上“基金里的值”計算得出來的。

要是采用“更新”的方式,那這個計算代碼就分散在項目的多個地方,這就不合理了。

那么怎么辦呢?其實,我們把“緩存更新”改成“刪除”就好了。

“后刪緩存”能解決多數不一致

因為每次讀取時,如果判斷 Redis 里沒有值,就會重新讀取數據庫,這個邏輯是沒問題的。唯一的問題是:我們是先刪除緩存?還是后刪除緩存?

1.如果先刪緩存

public void putValue(key,value){
    deleteFromRedis(key);
    putToDB(key,value);
}

就和上面的圖一樣。操作 B 刪除了某個 key 的值,這時候有另外一個請求 A 到來,那么它就會擊穿到數據庫,讀取到舊的值。無論操作 B 更新數據庫的操作持續多長時間,都會產生不一致的情況。

2.如果后刪緩存

而把刪除的動作放在后面,就能夠保證每次讀到的值都是新鮮的,從數據庫里面拿到最新的。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);
}

這就是Cache-Aside Pattern,也是我們平常使用最多的模式。我們看一下它的具體方式。

先看一下數據的讀取過程,規則是“先讀 cache,再讀 db”,詳細步驟如下:

每次讀取數據,都從 cache 里讀;

如果讀到了,則直接返回,稱作 cache hit;

如果讀不到 cache 的數據,則從 db 里面撈一份,稱作 cache miss;

將讀取到的數據塞入到緩存中,下次讀取時,就可以直接命中。

再來看一下寫請求,規則是“先更新 db,再刪除緩存”,詳細步驟如下:

1.將變更寫入到數據庫中;

2.刪除緩存里對應的數據。
為什么說最常用呢?因為 Spring cache 就是默認實現了這個模式。

Spring 的源碼。緩存的移除,是在 Cache-Aside Pattern 中實現的


image.png

image.png

并發量更大時,“后刪緩存”依舊不一致

所以在高并發情況下,Cache Aside Pattern 會不夠用。下面就描述一個“先更新再刪除”這種場景下,依然會產生不一致的情況。場景很好理解、很極端,但在高并發多實例的情況下很常見。


image.png

如上圖所示,有一系列的高并發操作,一直執行著更新、刪除的動作。某個時刻,它更新數據庫的值為 1,然后刪除了緩存。

正在這時,有兩個請求發生了:

一個是讀操作,讀到的當然是數據庫的舊值 1,我們記作操作 A;

同時,另外一個請求發起了更新操作,把數據庫記錄更新為 2,我們記作操作 B。

一般情況下,讀取操作都是比寫入操作快的,但我們要考慮兩種極端情況:

一種是這個讀取操作 A,發生在更新操作 B 的尾部;

一種是操作 A 的這個 Redis 的操作時長,耗費了非常多的時間。比如,這個節點正好發生了 STW。
那么很容易地,讀操作 A 的結束時間就超過了操作 B 刪除的動作。就像上圖虛線部分畫的一樣,這個時候,數據也是不一致的。

實際上,你也無法控制它們的執行順序。只要發生這種情況,大概率數據庫和 Redis 的值會不一致。

如何解決

1.延時雙刪

而假如我有一種機制,能夠確保刪除動作一定被執行,那就可以解決問題,起碼能縮小數據不一致的時間窗口。常用的方法就是延時雙刪,依然是先更新再刪除,唯一不同的是:我們把這個刪除動作,在不久之后再執行一次,比如 5 秒之后。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);

    ...deleteFromRedis(key,after5sec);
}

而刪除動作也有多種選擇:

如果放在 DelayQueue 中,會有隨著 JVM 進程的死亡,丟失更新的風險;

如果放在 MQ 中,會增加編碼的復雜性。
所以到了這個時候,并沒有一個能夠行走天下的解決方案。我們得綜合評價很多因素去做設計,比如團隊的水平、工期、不一致的忍受程度等。

2.閃電緩存

還有一種不太常用的,那就是采用閃電緩存。就是把緩存的失效時間設置非常短,比如 3~4 秒。一旦失效,就會再次去數據庫讀取最新數據到緩存。但這種方式,在非常高的并發下,同一時間對某個 key 的請求擊穿到 DB,會鎖死數據庫,所以很少用。

對于一般并發場景,上面的各種修修補補,已經把不一致問題降低到很小的概率了。但是它仍然是有問題的,因為它引入了一個高可用問題:緩存擊穿。

緩存擊穿

兩種不同的解決方式:

1.讀操作互斥

我們依然采用 Cache-Aside Pattern,只不過在讀的時候進行一下處理。來看一下偽代碼,從 Redis 讀取不到值的時候,我們要上鎖去從數據庫中讀這個值。我們這里默認這個值是有的,否則就得處理緩存穿透的問題。

get(key){

    res = getFromRedis(key);

    //讀取緩存為null

    if(null == res){

        lock.lock(...);

        //再次讀取緩存為null

        res = getFromRedis(key);

        if(res == null){

            res = getFromDB(key);

            if(null != res){

                //讀取設值

                putToRedis(key,res);

            }

        }

        lock.unlock();

    }

    return res;

}

getFromDB(key){

    ...

}

使用分布式鎖和非分布式鎖的主要區別,還是在于數據一致性窗口上:

-對于多線程鎖來說,可能某些節點執行得非常慢,更新了舊的值到 Redis;

-對于分布式鎖來說,肯定又是一個效率上的話題。

2.集中更新

集中更新。這個很美好,但大多數業務很復雜,這對業務架構的前期設計要求非常高。比如通過 Binlog 方式,典型的如 Canal。我們不會在代碼里做任何 Redis 更新的操作,而是會設計一個服務,訂閱最新的 binlog 更新信息,然后解析它們,主動去更新緩存。這個一般在大并發大廠才會采用。

還有一種就是弱化數據庫。所有的數據首先在 Redis 落地,也就是把 Redis 作為數據庫使用,把數據庫作為備份庫使用。有定時任務,定期把 Redis 中的數據,保存到數據庫或其他地方。

一般,重要業務還要配備一個對賬系統,定時去掃描,以便快速發現不一致的情況

小結

針對 Redis 的緩存一致性問題,我們聊了很多。可以看到,無論你怎么做,一致性問題總是存在,只是幾率慢慢變小了。

隨著對不一致問題的忍受程度越來越低、并發量越來越高,我們所采用的方案也越來越極端。一般情況下,到了延時雙刪這一步,就證明你的并發量已經夠大了;再往下走,無不是對高可用、成本、一致性的權衡,進入到了特事特辦的場景,甚至要考慮基礎設施,關于這些每個公司的策略都是不一樣的。

除了 Cache-Aside Pattern,一致性常見的還有 Read-Through、Write-Through、Write-Behind 等模式,它們都有自己的應用場景

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容