Redis的三大問題

通常咱們對緩存讀操做的時候有這麼一個固定的套路:html

  • 若是咱們的數據在緩存裏邊有,那麼就直接取緩存的。
  • 若是緩存裏沒有咱們想要的數據,咱們會先去查詢數據庫,而後將數據庫查出來的數據寫到緩存中。
  • 最後將數據返回給請求

代碼例子:java

 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 }   

但這樣寫的代碼是不行的,這代碼裏就有咱們緩存的三大問題的兩大問題.穿透,擊穿.git

一,緩存雪崩

1.1什麼是緩存雪崩?

第一種狀況:Redis掛掉了,請求所有走數據庫.github

第二種狀況:緩存數據設置的過時時間是相同的,而後恰好這些數據刪除了,所有失效了,這個時候所有請求會到數據庫redis

緩存雪崩若是發生了,頗有可能會把咱們的數據庫搞垮,致使整個服務器癱瘓.算法

1.2如何解決緩存雪崩?

對於第二種狀況,很是好解決:sql

  在存緩存的時候給過時時間加上一個隨機值,這樣大幅度的減小緩存同時過時.shell

第一種狀況:數據庫

  事發前:實現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

相關文章
相關標籤/搜索