一般我們對快取讀操作的時候有這么一個固定的套路:
- 如果我們的資料在快取里邊有,那么就直接取快取的,
- 如果快取里沒有我們想要的資料,我們會先去查詢資料庫,然后將資料庫查出來的資料寫到快取中,
- 最后將資料回傳給請求
代碼例子:
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操作快取
操作快取也有兩種方案:
- 更新快取
- 洗掉快取
一般我們都是采取洗掉快取快取策略的,原因如下:
- 高并發環境下,無論是先操作資料庫還是后操作資料庫而言,如果加上更新快取,那就更加容易導致資料庫與快取資料不一致問題,(洗掉快取直接和簡單很多)
- 如果每次更新了資料庫,都要更新快取【這里指的是頻繁更新的場景,這會耗費一定的性能】,倒不如直接洗掉掉,等再次讀取時,快取里沒有,那我到資料庫找,在資料庫找到再寫到快取里邊(體現懶加載)
基于這兩點,對于快取在更新時而言,都是建議執行洗掉操作!
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
轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/6064.html
標籤:NoSQL
