在項目中,我們所需要的數據通常存儲在數據庫中,但是數據庫的數據保存在硬盤上,硬盤的讀寫操作很慢,為了避免直接訪問數據庫,我們可以使用?Redis?作為緩存層,緩存通常存儲在內存中,內存的讀寫速度遠超硬盤?,而引入緩存層后,會出現三種緩存異常問題:緩存穿透、緩存雪崩和緩存擊穿,以及數據庫與緩存的雙寫不一致問題。
1.緩存擊穿(Cache Penetration)
定義:緩存擊穿是指一個設置了過期時間的緩存項在過期之后,恰好有很多并發請求訪問這個過期數據,這些請求發現緩存中沒有值后,會去查詢數據庫,引起數據庫壓力驟增。
解決方案:
● 互斥鎖:在訪問緩存的代碼塊中,使用互斥鎖(如 Redis 的 SETNX 命令)來保證在緩存失效的瞬間只有一個請求能查詢數據庫并更新緩存,其他請求則在鎖釋放后從緩存中獲取數據。(這篇博客有講:如何通過redis實現分布式鎖)。
● 永久緩存:對于某些不經常改變的數據,可以考慮設置為永久緩存,這樣就不會有擊穿的問題。
● 同步鎖:使用synchronized加鎖排隊,通過雙重檢查鎖(DCL)即在進入synchronized前查詢一次redis,進入后再查詢一次,防止上一個搶到鎖的線程已經更新過redis(適用于僅查詢的場景,如果要進行更新操作就不太適用該方法,因為synchronized不是分布式鎖,在多臺服務器上操作可能會導致數據不一致問題)。
String key = "product:" + id;
//先查一次緩存
String data = redisTemplate.opsForValue().get(key);
if (data != null) {//如果查到直接返回return data;
}
synchronized (this) {// 再次檢查緩存,防止當很多請求到這里直接訪問數據庫data = redisTemplate.opsForValue().get(key);if (data != null) {return data;}// 查詢數據庫data = userMapper.queryFromDatabase(id);if (data != null) {// 將數據存入緩存redisTemplate.opsForValue().set(key,data);}
}
2.緩存雪崩(Cache Avalanche)
定義:緩存雪崩是指緩存在同一時間大面積的失效,這時又來了一波請求,所有的請求都去查數據庫,造成數據庫壓力瞬間過大,宕機,結果是緩存和數據庫都掛了,就像雪崩一樣。(緩存集中過期或者服務器宕機都會造成雪崩)
解決方案:
● 設置過期時間的隨機性:給緩存的過期時間增加一個隨機值,避免集體失效。(可通過random隨機生成)
● 使用集群:通過增加機器來分散壓力或者哨兵模式避免單一節點壓力過大。
● 限流降級:在應用層面對請求進行限流,或者在系統負載過高時進行服務降級。
● 服務熔斷:使用熔斷機制,當檢測到系統負載過高時,自動熔斷一部分服務,減輕系統壓力。
3. 緩存穿透(Cache Bypass)
定義:緩存穿透是指查詢一個一定不存在的數據,由于緩存不命中后,還需要去查詢數據庫,引起大量請求直接穿透到數據庫,給數據庫造成巨大壓力,甚至宕機。
解決方案:
● 布隆過濾器:布隆過濾器可以快速判斷一個元素是否存在于某個集合中,可以用來過濾掉不存在的數據請求。
基于redisson實現布隆過濾器示例代碼:
@Autowired
private RedissonClient redissonClient;
?
public RBloomFilter<String> initBloomFilter() {RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productBloomFilter");// 預期元素數量為 100000,誤判率 1%bloomFilter.tryInit(100000L, 0.01);// 預熱數據(示例:從數據庫加載所有合法ID)List<Long> productIds = orderService.getAllProductIds();productIds.forEach(id -> bloomFilter.add("product_" + id));return bloomFilter;}
● 空值緩存:如果一個查詢返回的數據為空(null),也將其存儲到緩存中,設置一個較短的過期時間,例如幾分鐘。這樣再次請求相同數據時會快速響應,不會每次都去查詢數據庫。
public String getData(Long id) {String key = "product:" + id;// 先從緩存中獲取數據String data = redisTemplate.opsForValue().get(key);if (data == null) {return null;}else{return data;}// 查詢數據庫data = userMapper.queryFromDatabase(id);if (data == null) {// 將空值存入緩存redisTemplate.opsForValue().set(key,null);} else {// 將數據存入緩存redisTemplate.opsForValue().set(key,data);}return data;
}
● 參數校驗:提前校驗一些數據庫中沒有的數據。
4.數據庫緩存雙寫不一致問題
數據庫和緩存雙寫不一致?是指在多用戶并發操作中,數據庫和緩存中的數據未能保持一致的狀態。這種情況通常發生在以下場景:
1?)先更新數據庫再更新緩存?:如果在更新數據庫后,更新緩存之前發生故障,緩存中的數據會與數據庫中的數據不一致。此外,多個并發請求可能導致緩存被多次更新,而數據庫中的數據未能及時更新,從而產生不一致?。
2)先更新緩存再更新數據庫?:如果在更新緩存后,更新數據庫之前發生故障,數據庫中的數據會與緩存中的數據不一致。多個并發請求可能導致緩存被覆蓋,而數據庫中的數據未能及時更新,從而產生不一致?。
解決方案:為了解決數據庫和緩存雙寫不一致的問題,有以下幾種常見的解決方案
1.先更新數據庫再刪除緩存?:這種方法避免了緩存和數據庫之間的數據不一致問題。更新數據庫后刪除緩存,下次讀取時會從數據庫中重新加載最新數據到緩存中。
缺點:然而,在高并發情況下,可能會在刪除緩存和更新數據庫之間出現短暫的數據不一致,可以通過設置緩存過期時間或使用緩存更新策略來減少這種情況的發生?。
@Transactional//保證事物原子性public void updateUser(User user) {// 先更新數據庫userMapper.update(user);// 再刪除緩存String key = "user:" + user.getId();redisTemplate.delete(key);}
2?.異步更新緩存?:通過消息隊列來實現異步更新緩存。數據庫更新完成后,將操作命令放入消息隊列,由緩存系統消費這些命令進行更新。這種方法可以保證數據操作順序一致性,確保緩存系統的數據正常?。
缺點:引入了額外的中間件,增加了系統的復雜度和維護成本。
@Transactional
public void updateUser(User user) {// 先更新數據庫userMapper.update(user);// 發送消息到消息隊列rabbitTemplate.convertAndSend("workExchange", "workRoutingKey", user);
}
?
?
@RabbitListener(queues = "workExchange")
public void handleCacheUpdate(User user) {// 刪除緩存String key = "user:" + user.getId();redisTemplate.delete(key);
}
3.延遲雙刪策略:先刪除緩存,再更新數據庫,然后在一段時間后再次刪除緩存 ,以確保在更新數據庫和刪除緩存之間讀取到舊緩存數據的線程在后續操作中也能獲取到最新數據。
第二次刪除緩存的原因:
為了解決讀寫并發請求導致數據不一致的情況,所以要再刪除一次緩存(延遲)。
如果不延遲,可能存在如下情況:
- ?寫請求:刪除緩存
- ?讀請求:緩存未命中、讀取數據庫的值20
- ?寫請求:更新數據庫的值為21
- ?寫請求:第二次刪除緩存
- ?讀請求:更新緩存值為20還是會導致數據不一致,所以第二次刪除緩存需要延遲一段時間,直到 并發的讀請求都已經將舊值緩存好這時候再去刪除,可以刪除掉舊的緩存值。
需要延遲多久這個時間非常不好設定,因為我們不知道并發的讀請求寫入緩存什么時候能夠結束,能夠保證第二次刪除緩存是能夠刪除舊值,經驗值一般設置為500ms-1s, 如果發生了并發讀請求,那么在這段時間內數據是不一致的,外界讀取的是舊值第二次刪除就一定能成功嗎。
如果第二次刪除失敗了怎么辦,會導致長時間的數據不一致,所以還是要引入重試機制(消息隊列重試)
缺點:但是需要額外設置延遲時間,若時間設置不合理,可能仍會出現數據不一致的情況;增加了系統的處理時間,降低了系統的響應性能。
@Transactional
public void updateUser(User user) {// 先更新數據庫userMapper.update(user);// 再刪除緩存String key = "user:" + user.getId();redisTemplate.delete(key);// 延遲一段時間后再次刪除緩存new Thread(() -> {try {TimeUnit.MILLISECONDS.sleep(500);redisTemplate.delete(key);} catch (InterruptedException e) {e.printStackTrace();}}).start();
}
4.基于數據庫的 Binlog 同步:借助數據庫的 Binlog(二進制日志)來捕捉數據庫的變更信息,再通過中間件將這些變更信息同步到 Redis 中。
操作步驟
- ?開啟數據庫的 Binlog 功能。
- 利用 Canal(以 MySQL 為例)等中間件監聽數據庫的 Binlog 變化。
- 中間件將捕獲到的變更信息發送給處理程序。
- 處理程序根據變更信息更新 Redis 中的緩存數據。?
缺點:引入了額外的中間件,增加了系統的復雜度和維護成本;需要處理中間件的高可用和數據傳輸的穩定性問題。
5.基于讀寫鎖:讀寫鎖允許多個讀操作同時進行,但在寫操作時會阻塞其他讀操作和寫操作,確保在同一時間內只有一個寫操作可以訪問資源,以此來保證數據的完整性和一致性。
缺點:在一些復雜的業務場景下,可能存在多個數據源之間的數據依賴和更新順序問題,如果不同數據源之間的更新順序沒有正確處理,仍然可能導致數據不一致。。
6.使用分布式鎖?:在更新操作時使用分布式鎖,確保同一時間只有一個請求可以操作緩存和數據庫,從而避免數據不一致的問題?。?(這篇博客有講:如何通過redis實現分布式鎖)
缺點:增加了系統的復雜度和性能開銷;分布式事務的實現和維護難度較大。
7.消息隊列重試
- 在代碼里,更新MySQL數據,同步刪除緩存,如果刪除失敗,則發送一個消息隊列的刪除緩存的消息
- 再由一個消費者監聽對應的消息,將緩存數據刪除
- 如果刪除失敗,觸發重試機制,重試刪除。如果重試超過一定次數,則需要記錄異常且告警。
缺點:該方法對代碼入侵性比較強,且引入了消息隊列,提升了系統的復雜性,提高了服務的風險性。