Redis的三大問題

2023-09-10 15:01:42

一般我們對快取讀操作的時候有這麼一個固定的套路:

  • 如果我們的資料在快取裡邊有,那麼就直接取快取的。
  • 如果快取裡沒有我們想要的資料,我們會先去查詢資料庫,然後將資料庫查出來的資料寫到快取中。
  • 最後將資料返回給請求

程式碼例子:

複製程式碼
 1 @Override
 2 public  R selectOrderById(Integer id) {
 3     //查詢快取
 4     Object redisObj = valueOperations.get(String.valueOf(id));
 5 
 6     //命中快取
 7     if(redisObj != null) {
 8         //正常返回資料
 9         return new R().setCode(200).setData(redisObj).setMsg("OK");
10     }
11     Order order = orderMapper.selectOrderById(id);
12     if (order != null) {
13          valueOperations.set(String.valueOf(id), order);  //加入快取
14          return new R().setCode(200).setData(order).setMsg("OK");
15      }
16      return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查詢無果");
17 }   
複製程式碼

但這樣寫的程式碼是不行的,這程式碼裡就有我們快取的三大問題的兩大問題.穿透,擊穿.

一,快取雪崩

1.1什麼是快取雪崩?

第一種情況:Redis掛掉了,請求全部走資料庫.

第二種情況:快取資料設定的過期時間是相同的,然後剛好這些資料刪除了,全部失效了,這個時候全部請求會到資料庫

快取雪崩如果發生了,很有可能會把我們的資料庫搞垮,導致整個伺服器癱瘓.

1.2如何解決快取雪崩?

對於第二種情況,非常好解決:

  在存快取的時候給過期時間加上一個隨機值,這樣大幅度的減少快取同時過期.

第一種情況:

  事發前:實現Redis的高可用(主從架構+Sentinel 或者Redis Cluster),儘量避免Redis掛掉這種情況發生。
  事發中:萬一Redis真的掛了,我們可以設定本地快取(ehcache)+限流(hystrix),儘量避免我們的資料庫被幹掉(起碼能保證我們的服務還是能正常工作的)
  事發後:redis持久化,重啟後自動從磁碟上載入資料,快速恢復快取資料。

二,快取穿透

2.1什麼是快取穿透?

比如你搶了你同事的女神,你同事很氣,想搞你,在你的專案裡,每次請求的ID為負數.這個時候快取肯定是沒有的,快取就沒用了,請求就會全部找資料庫,但資料庫也沒用這個值.所以每次返回空出去.

快取穿透是指查詢一個一定不存在的資料。由於快取不命中,並且出於容錯考慮,如果從資料庫查不到資料則不寫入快取,這將導致這個不存在的資料每次請求都要到資料庫去查詢,失去了快取的意義。

這就是快取穿透:

請求的資料在快取大量不命中,導致請求走資料庫。

快取穿透如果發生了,也可能把我們的資料庫搞垮,導致整個服務癱瘓!

2.2如何解決快取穿透?

解決快取穿透也有兩種方案:

  • 由於請求的引數是不合法的(每次都請求不存在的引數),於是我們可以使用布隆過濾器(BloomFilter)或者壓縮filter提前攔截,不合法就不讓這個請求到資料庫層!
  • 當我們從資料庫找不到的時候,我們也將這個空物件設定到快取裡邊去。下次再請求的時候,就可以從快取裡邊獲取了。這種情況我們一般會將空物件設定一個較短的過期時間。

快取空物件程式碼例子:

複製程式碼
1     public R selectOrderById(Integer id) {
2         return cacheTemplate.redisFindCache(String.valueOf(id), 10, TimeUnit.MINUTES, new CacheLoadble<Order>() {
3             @Override
4             public Order load() {
5                 return orderMapper.selectOrderById(id);
6             }
7         },false);
8     }
複製程式碼
複製程式碼
 1 public R redisFindCache(String key, long expire, TimeUnit unit, CacheLoadble<T> cacheLoadble, boolean b) {
 2         //查詢快取
 3         Object redisObj = valueOperations.get(String.valueOf(key));
 4         //命中快取
 5         if (redisObj != null) {
 6             if(redisObj instanceof NullValueResultDO){
 7                 return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查詢無果");
 8             }
 9             //正常返回資料
10             return new R().setCode(200).setData(redisObj).setMsg("OK");
11         }
12         try {
13             T load = cacheLoadble.load();//查詢資料庫
14             if (load != null) {
15                 valueOperations.set(key, load, expire, unit);  //加入快取
16                 return new R().setCode(200).setData(load).setMsg("OK");
17             }else{
18                 valueOperations.set(key,new NullValueResultDO(),expire,unit);
19             }
20 
21         } finally {
22 
23         }
24         return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查詢無果");
25     }
複製程式碼

這裡封裝了一個模板redisFindCache,不然每一個方法都要寫這個流程.注意在命中快取時,要判斷資料是否是空物件.

空物件:

1 @Getter
2 @Setter
3 @ToString
4 public class NullValueResultDO{
5 
6 }

快取空物件的缺點:有大量的空資料佔用redis的記憶體.治標不治本.

布隆過濾器:

  有谷歌的guava,但是是單機版的,不支援分散式.

  也可以用redis的位陣列bit手寫一個分散式布隆過濾器,程式碼就不寫了.過程就是先把id(比如你是用id為key的)存進布隆過濾器(會經過特定的演演算法),當我們請求介面的時候先讓它查詢布隆過濾器,判斷資料是否存在.

上面的程式碼還有個快取擊穿(快取當中沒有,資料庫中有)問題,就是並行的時候.比如99個人同時請求,還是會列印99條sql語句,還是會找資料庫.

這裡的程式碼是用的分散式鎖(互斥鎖)

複製程式碼
 1 public R redisFindCache(String key, long expire, TimeUnit unit, CacheLoadble<T> cacheLoadble,boolean b){
 2         //判斷是否走過濾器
 3         if(b){
 4             //先走過濾器
 5             boolean bloomExist = bloomFilter.isExist(String.valueOf(key));
 6             if(!bloomExist){
 7                 return new R().setCode(600).setData(null).setMsg("查詢無果");
 8             }
 9         }
10         //查詢快取
11         Object redisObj = valueOperations.get(String.valueOf(key));
12         //命中快取
13         if(redisObj != null) {
14             //正常返回資料
15             return new R().setCode(200).setData(redisObj).setMsg("OK");
16         }
17 //        RLock lock0 = redisson.getLock("{taibai0}:" + key);
18 //        RLock lock1 = redisson.getLock("{taibai1}:" + key);
19 //        RLock lock2 = redisson.getLock("{taibai2}:" + key);
20 //        RedissonMultiLock lock = new RedissonMultiLock(lock0,lock1, lock2);
21         try {
22         redisLock.lock(key);//上鎖
23 //        lock.lock();
24         //查詢快取
25         redisObj = valueOperations.get(String.valueOf(key));
26         //命中快取
27         if(redisObj != null) {
28             //正常返回資料
29             return new R().setCode(200).setData(redisObj).setMsg("OK");
30         }
31         T load = cacheLoadble.load();//查詢資料庫
32         if (load != null) {
33             valueOperations.set(key, load,expire, unit);  //加入快取
34             return new R().setCode(200).setData(load).setMsg("OK");
35         }
36             return new R().setCode(500).setData(new NullValueResultDO()).setMsg("查詢無果");
37         }finally {
38             redisLock.unlock(key);//解鎖
39 //            lock.unlock();
40         }
41     }
複製程式碼

三,快取與資料庫雙寫一致

3.1什麼是快取與資料庫雙寫一致問題?

如果僅僅查詢的話,快取的資料和資料庫的資料是沒問題的。但是,當我們要更新時候呢?各種情況很可能就造成資料庫和快取的資料不一致了。

  • 這裡不一致指的是:資料庫的資料跟快取的資料不一致

從理論上說,只要我們設定了鍵的過期時間,我們就能保證快取和資料庫的資料最終是一致的。因為只要快取資料過期了,就會被刪除。隨後讀的時候,因為快取裡沒有,就可以查資料庫的資料,然後將資料庫查出來的資料寫入到快取中。

除了設定過期時間,我們還需要做更多的措施來儘量避免資料庫與快取處於不一致的情況發生。

3.2對於更新操作

一般來說,執行更新操作時,我們會有兩種選擇:

  • 先運算元據庫,再操作快取
  • 先操作快取,再運算元據庫

首先,要明確的是,無論我們選擇哪個,我們都希望這兩個操作要麼同時成功,要麼同時失敗。所以,這會演變成一個分散式事務的問題。

所以,如果原子性被破壞了,可能會有以下的情況:

  • 運算元據庫成功了,操作快取失敗了。
  • 操作快取成功了,運算元據庫失敗了。

如果第一步已經失敗了,我們直接返回Exception出去就好了,第二步根本不會執行。

下面我們具體來分析一下吧。

3.2.1操作快取

操作快取也有兩種方案:

  • 更新快取
  • 刪除快取

一般我們都是採取刪除快取快取策略的,原因如下:

  1. 高並行環境下,無論是先運算元據庫還是後運算元據庫而言,如果加上更新快取,那就更加容易導致資料庫與快取資料不一致問題。(刪除快取直接和簡單很多)
  2. 如果每次更新了資料庫,都要更新快取【這裡指的是頻繁更新的場景,這會耗費一定的效能】,倒不如直接刪除掉。等再次讀取時,快取裡沒有,那我到資料庫找,在資料庫找到再寫到快取裡邊(體現懶載入)

基於這兩點,對於快取在更新時而言,都是建議執行刪除操作!

3.2.2先更新資料庫,再刪除快取

正常情況是這樣的:

  • 先運算元據庫,成功
  • 在刪除快取,也成功

如果原子性被破壞了:

  • 第一步成功(運算元據庫),第二步失敗(刪除快取),會導致資料庫裡是新資料,而快取裡是舊資料。
  • 如果第一步(運算元據庫)就失敗了,我們可以直接返回錯誤(Exception),不會出現資料不一致。

如果在高並行的場景下,出現資料庫與快取資料不一致的概率特別低,也不是沒有:

  • 快取剛好失效
  • 執行緒A查詢資料庫,得一箇舊值
  • 執行緒B將新值寫入資料庫
  • 執行緒B刪除快取
  • 執行緒A將查到的舊值寫入快取

要達成上述情況,還是說一句概率特別低:

因為這個條件需要發生在讀快取時快取失效,而且並行著有一個寫操作。而實際上資料庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入資料庫操作,而又要晚於寫操作更新快取,所有的這些條件都具備的概率基本並不大。

對於這種策略,其實是一種設計模式:Cache Aside Pattern

 

刪除快取失敗的解決思路:

  • 將需要刪除的key傳送到訊息佇列中
  • 自己消費訊息,獲得需要刪除的key
  • 不斷重試刪除操作,直到成功

 3.2.3先刪除快取,在更新資料庫

正常情況是這樣的:

  • 先刪除快取,成功;
  • 再更新資料庫,也成功;

如果原子性被破壞了:

  • 第一步成功(刪除快取),第二步失敗(更新資料庫),資料庫和快取的資料還是一致的。
  • 如果第一步(刪除快取)就失敗了,我們可以直接返回錯誤(Exception),資料庫和快取的資料還是一致的。

看起來是很美好,但是我們在並行場景下分析一下,就知道還是有問題的了:

  • 執行緒A刪除了快取
  • 執行緒B查詢,發現快取已不存在
  • 執行緒B去資料庫查詢得到舊值
  • 執行緒B將舊值寫入快取
  • 執行緒A將新值寫入資料庫

所以也會導致資料庫和快取不一致的問題。

並行下解決資料庫與快取不一致的思路:

  • 將刪除快取、修改資料庫、讀取快取等的操作積壓到佇列裡邊,實現序列化。

 

3.2.4對比著兩種策略

我們可以發現,兩種策略各自有優缺點:

  • 先刪除快取,再更新資料庫

    在高並行下表現不如意,在原子性被破壞時表現優異

  • 先更新資料庫,再刪除快取(Cache Aside Pattern設計模式)

    在高並行下表現優異,在原子性被破壞時表現不如意

 3.2.5其他保障資料一致的方案與資料

可以用databus或者阿里的canal監聽binlog進行更新。

參考資料:

  • 快取更新的套路

    https://coolshell.cn/articles/17416.html

  • 如何保證快取與資料庫雙寫時的資料一致性?

    https://github.com/doocs/advanced-java/blob/master/docs/high-concurrency/redis-consistence.md

  • 分散式之資料庫和快取雙寫一致性方案解析

    https://zhuanlan.zhihu.com/p/48334686

  • Cache Aside Pattern

    https://blog.csdn.net/z50l2o08e2u4aftor9a/article/details/81008933