Redis-緩存并發
- 引言:緩存,高性能架構的基石與并發挑戰
- 一、 緩存擊穿:熱點 Key 失效引發的“單點風暴”
- 1.1 什么是緩存擊穿?
- 1.2 緩存擊穿的風險
- 1.3 緩存擊穿的解決方案
- 1.3.1 互斥鎖(Mutex Lock)/ 分布式鎖 (Distributed Lock) - 推薦
- 1.3.2 邏輯過期(Logical Expiration)/ 熱點數據永不過期
- 1.3.3 對比與選擇
- 二、 緩存雪崩:大面積失效引發的“系統性災難”
- 2.1 什么是緩存雪崩?
- 2.2 緩存雪崩的風險
- 2.3 緩存雪崩的解決方案
- 2.3.1 預防 Key 同時過期
- 2.3.2 保證緩存服務高可用
- 2.3.3 容災措施:限流與降級
- 2.3.4 多級緩存
- 2.3.5 對比與選擇
- 三、 緩存穿透:查詢不存在數據的“持續騷擾”
- 3.1 什么是緩存穿透?
- 3.2 緩存穿透的風險
- 3.3 緩存穿透的解決方案
- 3.3.1 緩存空值 (Cache Null Values) - 常用
- 3.3.2 布隆過濾器 (Bloom Filter) - 推薦
- 3.3.3 接口層校驗 (Parameter Validation)
- 3.3.4 對比與選擇
- 四、 總結與最佳實踐
引言:緩存,高性能架構的基石與并發挑戰
在現代分布式系統中,緩存是提升系統性能、降低后端負載不可或缺的關鍵組件。
通過將熱點數據存儲在訪問速度更快的介質(如內存)中,緩存能夠顯著減少對后端數據庫或其他慢速服務的訪問,從而提高應用的響應速度和吞吐量。
Redis 以其高性能、豐富的數據結構和良好的生態,成為了目前最主流的緩存解決方案之一。
然而,引入緩存并非一勞永逸。在高并發場景下,緩存系統自身也可能面臨嚴峻的挑戰,其中最典型的就是緩存擊穿、緩存雪崩和緩存穿透這三大并發問題。這些問題一旦發生,輕則導致系統性能下降、響應變慢,重則可能引發后端數據庫過載甚至整個系統崩潰。
文章旨在介紹分析這三種常見的 Redis 緩存并發問題:
- 緩存擊穿 (Cache Breakdown/Penetration):單個熱點 Key 過期,高并發請求直擊數據庫。
- 緩存雪崩 (Cache Avalanche):大量 Key 同時過期或 Redis 服務宕機,海量請求涌向數據庫。
- 緩存穿透 (Cache Penetration):查詢不存在的數據,請求繞過緩存,頻繁訪問數據庫。
一、 緩存擊穿:熱點 Key 失效引發的“單點風暴”
1.1 什么是緩存擊穿?
緩存擊穿,簡單來說,是指某個訪問極其頻繁的熱點 Key,在它失效的瞬間,恰好有大量的并發請求訪問這個 Key。由于緩存已過期(或被剔除),這些并發請求無法命中緩存,便會“擊穿”緩存層,同時涌向后端的數據庫或其他數據源,導致數據庫壓力瞬間劇增,甚至可能被打垮。
想象一下某個電商平臺的爆款商品詳情頁,這個商品 ID 就是一個典型的熱點 Key。平時成千上萬的用戶請求都由 Redis 緩存扛著,毫秒級響應。但如果這個商品 Key 的緩存在某個精確的時間點過期了,而此時恰好有大量用戶(比如秒殺活動開始時)同時刷新頁面請求該商品信息,那么這些請求就會在極短的時間內全部打到數據庫上,形成一次猛烈的“單點沖擊”。
關鍵特征:
- 單一熱點 Key: 問題集中在某個特定的、訪問量遠超其他 Key 的數據上。
- 高并發訪問: 在 Key 失效的瞬間,有大量的線程/請求同時訪問該 Key。
- 緩存瞬間失效: Key 恰好在此時過期或因其他原因(如 LRU 淘汰)被刪除。
1.2 緩存擊穿的風險
緩存擊穿雖然看似只影響一個 Key,但其帶來的風險不容小覷:
- 數據庫瞬時超載: 這是最直接也是最嚴重的風險。熱點 Key 的訪問量通常非常大,失效瞬間涌入數據庫的請求量可能是平時的數十倍甚至數百倍,遠超數據庫的處理能力上限,導致數據庫 CPU、IO、連接數等資源迅速耗盡。
- 接口響應時間劇增: 請求從訪問高速緩存(毫秒級)轉為訪問數據庫(可能數十或數百毫秒,甚至更長),用戶能明顯感知到卡頓或加載緩慢,影響用戶體驗。
- 系統雪崩風險(連鎖反應): 數據庫作為許多服務的核心依賴,其壓力過大或響應緩慢,會拖慢依賴它的所有服務。這可能導致請求超時、線程阻塞、資源耗盡等問題在系統中蔓延,最終引發更大范圍的服務不可用,甚至整個系統雪崩。
- 數據不一致(若處理不當): 如果沒有合適的并發控制,多個請求同時查詢數據庫并回寫緩存,可能由于讀取和寫入的時間差導致緩存中存儲了舊的數據。
舉例說明:
- 微博熱搜榜首: 某個明星八卦突然登上熱搜第一,對應的資訊 Key 成為熱點。若緩存失效,大量用戶的點擊和刷新請求會同時打到數據庫。
- 電商秒殺活動: 秒殺商品的庫存信息 Key。活動開始瞬間,大量用戶請求查詢庫存,若緩存失效,數據庫壓力陡增。
- 首頁推薦內容: 門戶網站或 App 首頁某個固定推薦位的內容 Key。
1.3 緩存擊穿的解決方案
解決緩存擊穿的核心思路是:避免大量請求在同一時間點直接請求數據庫加載同一個數據。 主要有以下幾種常用方法:
1.3.1 互斥鎖(Mutex Lock)/ 分布式鎖 (Distributed Lock) - 推薦
這是最經典也是最常用的解決方案。其核心思想是:當緩存失效時,只允許一個請求去查詢數據庫并重建緩存,其他請求則等待該請求完成或直接返回(取決于業務策略)。
基本流程:
- 請求線程訪問緩存。
- 如果緩存命中,直接返回數據。
- 如果緩存未命中:
a. 嘗試獲取該 Key 對應的互斥鎖。
b. 獲取鎖成功的線程:
i. 再次檢查緩存(Double Check Locking,防止在等待鎖期間已有其他線程重建了緩存)。
ii. 如果緩存仍然不存在,則查詢數據庫。
iii. 將查詢結果寫入緩存(設置合理的過期時間)。
iv. 釋放鎖。
v. 返回數據。
c. 獲取鎖失敗的線程:
i. 可以選擇短暫休眠后重試(自旋等待),或者直接返回空值/默認值/提示信息(取決于業務容忍度),避免所有線程都阻塞在鎖上。
實現方式:
- 單機環境: 可以使用 JVM 提供的鎖機制,如
synchronized
關鍵字或java.util.concurrent.locks.Lock
(例如ReentrantLock
)。 - 分布式環境: 必須使用分布式鎖,因為應用通常是集群部署,JVM 鎖無法跨進程生效。常用的分布式鎖實現有:
- 基于 Redis 實現:
SETNX
+ Lua 腳本(保證原子性),或使用 Redisson 等成熟的客戶端庫。 - 基于 ZooKeeper 實現: 利用其臨時有序節點。
- 基于數據庫實現: 利用數據庫的唯一約束或行鎖(性能相對較低)。
- 基于 Redis 實現:
推薦使用 Redisson 實現分布式鎖:
Redisson 提供了易于使用的分布式鎖接口,并處理了鎖的可重入、自動續期(看門狗機制)、釋放等復雜問題。
Java + Redisson 示例代碼:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import java.util.concurrent.TimeUnit;@Service
public class ProductServiceWithMutex {@Autowiredprivate StringRedisTemplate stringRedisTemplate; // 用于操作 Redis 緩存@Autowiredprivate RedissonClient redissonClient; // Redisson 客戶端,需要配置 Beanprivate static final String CACHE_KEY_PREFIX = "product:";private static final String LOCK_KEY_PREFIX = "lock:product:";private static final long CACHE_TTL = 30; // 緩存過期時間,單位:分鐘private static final long LOCK_WAIT_TIME = 1; // 獲取鎖的等待時間,單位:秒private static final long LOCK_LEASE_TIME = 10; // 鎖的持有時間(Redisson 默認有看門狗機制自動續期)/*** 查詢商品信息,使用互斥鎖解決緩存擊穿* @param productId 商品 ID* @return 商品信息,如果不存在則返回 null*/public String getProductInfo(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 從緩存獲取數據String productInfo = stringRedisTemplate.opsForValue().get(cacheKey);// 2. 緩存命中,直接返回if (productInfo != null) {// 可以考慮在這里重置一下緩存有效期(如果需要的話,即緩存續期)// stringRedisTemplate.expire(cacheKey, CACHE_TTL, TimeUnit.MINUTES);System.out.println("緩存命中,直接返回: " + productInfo);return productInfo;}// --- 緩存未命中 ---// 3. 準備獲取分布式鎖String lockKey = LOCK_KEY_PREFIX + productId;RLock lock = redissonClient.getLock(lockKey);try {// 4. 嘗試獲取鎖// tryLock(waitTime, leaseTime, unit)// waitTime: 獲取鎖的最大等待時間。如果在等待時間內獲取到鎖,則返回 true;否則返回 false。// leaseTime: 鎖的持有時間。超過這個時間鎖會自動釋放。如果設置為 -1,則使用 Redisson 的看門狗機制,默認 30 秒,并且會自動續期。// 為避免死鎖和保證鎖最終釋放,通常建議設置一個合理的 leaseTime,或者依賴看門狗。// 這里我們設置一個較短的等待時間,如果獲取不到鎖就放棄,避免過多線程阻塞。// 同時設置一個 leaseTime,即使看門狗失效(例如服務宕機),鎖最終也會釋放。boolean isLocked = lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS);// 5. 判斷是否獲取鎖成功if (isLocked) {System.out.println("線程 " + Thread.currentThread().getId() + " 獲取鎖成功,準備查詢數據庫...");// 6. 獲取鎖成功 - Double Check Locking (再次檢查緩存)// 防止在等待鎖的過程中,已有其他線程重建了緩存productInfo = stringRedisTemplate.opsForValue().get(cacheKey);if (productInfo != null) {System.out.println("獲取鎖后發現緩存已存在,直接返回: " + productInfo);return productInfo;}// 7. 緩存確實不存在,查詢數據庫System.out.println("線程 " + Thread.currentThread().getId() + " 查詢數據庫獲取商品信息...");productInfo = queryProductFromDB(productId); // 模擬數據庫查詢// 8. 數據庫查詢結果處理if (productInfo != null) {// 數據庫中有數據,寫入緩存System.out.println("線程 " + Thread.currentThread().getId() + " 將數據寫入緩存: " + productInfo);stringRedisTemplate.opsForValue().set(cacheKey, productInfo, CACHE_TTL, TimeUnit.MINUTES);} else {// 數據庫中也沒有數據(防止緩存穿透,后面會講),可以緩存一個特殊空值// 注意:緩存空值的時間不宜過長System.out.println("線程 " + Thread.currentThread().getId() + " 數據庫無此商品,緩存空值");stringRedisTemplate.opsForValue().set(cacheKey, "", 5, TimeUnit.MINUTES); // 緩存空字符串,過期時間短一些}// 9. 返回查詢結果(可能是真實數據或空值標記)return productInfo; // 注意:如果緩存了空值,這里返回的可能是空字符串""} else {// 10. 獲取鎖失敗 - 其他線程正在重建緩存System.out.println("線程 " + Thread.currentThread().getId() + " 獲取鎖失敗,休眠后重試...");// 可以選擇短暫休眠后重試,再次調用 getProductInfo 方法// 或者直接返回提示信息或默認值,避免長時間等待TimeUnit.MILLISECONDS.sleep(100); // 休眠 100 毫秒return getProductInfo(productId); // 遞歸調用重試(注意控制重試次數,防止死循環)// 或者 return "系統繁忙,請稍后重試";}} catch (InterruptedException e) {Thread.currentThread().interrupt(); // 恢復中斷狀態System.err.println("線程 " + Thread.currentThread().getId() + " 在等待鎖或休眠時被中斷");return "系統錯誤,請稍后重試";} finally {// 11. 釋放鎖 - 必須在 finally 塊中執行,確保鎖一定會被釋放// 需要判斷當前線程是否持有鎖if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();System.out.println("線程 " + Thread.currentThread().getId() + " 釋放鎖");}}}/*** 模擬從數據庫查詢商品信息* @param productId 商品 ID* @return 商品信息字符串,如果不存在則返回 null*/private String queryProductFromDB(Long productId) {// 實際應用中,這里會調用 DAO 層或 Mapper 層訪問數據庫System.out.println("--- 模擬數據庫查詢 productId: " + productId + " ---");try {// 模擬數據庫查詢耗時TimeUnit.MILLISECONDS.sleep(200);} catch (InterruptedException e) {Thread.currentThread().interrupt();}// 模擬數據庫中存在該商品if (productId != null && productId > 0 && productId < 1000) {return "{\"id\":" + productId + ", \"name\":\"模擬商品" + productId + "\", \"price\":99.9}";} else {// 模擬數據庫中不存在該商品return null;}}
}
代碼解釋與注意事項:
- RedissonClient 配置: 需要在 Spring Boot 配置中正確初始化
RedissonClient
Bean,連接到你的 Redis 服務器。 - 鎖 Key 設計: 鎖 Key (
LOCK_KEY_PREFIX + productId
) 應該與緩存 Key 相關聯,確保對同一個資源的訪問使用同一個鎖。 tryLock()
參數:waitTime
:設置一個較短的等待時間,避免大量線程因等待鎖而阻塞。如果獲取失敗,可以選擇快速失敗或短暫休眠后重試。leaseTime
:鎖的持有時間。Redisson 默認的看門狗機制(leaseTime
為 -1 或不設置時)會在鎖未釋放前自動續期(默認每lockWatchdogTimeout / 3
時間續期一次,lockWatchdogTimeout
默認 30 秒)。這可以防止業務邏輯執行時間過長導致鎖提前釋放。但如果服務宕機,看門狗也會失效,所以設置一個合理的leaseTime
作為兜底是推薦的,確保鎖最終能被釋放。
- Double Check Locking (DCL): 在獲取鎖成功后,必須再次檢查緩存。因為在線程等待鎖的期間,可能已經有其他線程獲取了鎖、查詢了數據庫、重建了緩存并釋放了鎖。DCL 可以避免不必要的數據庫查詢。
- 釋放鎖: 必須在
finally
塊中釋放鎖,確保即使發生異常,鎖也能被正確釋放,防止死鎖。同時,需要使用isLocked()
和isHeldByCurrentThread()
判斷鎖的狀態和歸屬,避免釋放不屬于自己的鎖或未加鎖成功的鎖。 - 獲取鎖失敗的處理: 可以選擇:
- 自旋重試: 休眠一小段時間后再次嘗試獲取數據(如示例中的遞歸調用)。需要設置最大重試次數或超時時間,防止無限重試。
- 快速失敗: 直接返回錯誤信息或默認值,將壓力快速反饋給調用方。
- 緩存空值: 如果數據庫查詢結果為空,建議緩存一個特殊的空值(如空字符串 “” 或特定標記),并設置一個較短的過期時間。這可以有效防止緩存穿透(后續會詳細講解)。
優點:
- 強一致性: 能有效保證只有一個線程更新緩存,避免并發更新導致的數據不一致。
- 簡單有效: 思路清晰,實現相對直接(尤其使用 Redisson)。
缺點:
- 性能開銷: 引入了鎖機制,獲取和釋放鎖會帶來一定的性能開銷。在高并發下,鎖的爭搶可能成為瓶頸。
- 線程阻塞: 獲取鎖失敗的線程需要等待或重試,增加了請求的響應時間。
- 死鎖風險: 如果鎖使用不當(如忘記釋放鎖),可能導致死鎖。
1.3.2 邏輯過期(Logical Expiration)/ 熱點數據永不過期
另一種思路是不給熱點 Key 設置物理過期時間 (TTL),或者設置一個非常長的過期時間,而是在緩存值中包含一個邏輯過期時間字段。
基本流程:
- 請求線程訪問緩存。
- 如果緩存命中:
a. 檢查緩存值中的邏輯過期時間是否已到。
b. 未過期: 直接返回數據。
c. 已過期:
i. 嘗試獲取互斥鎖(同樣需要鎖來保證只有一個線程執行異步重建)。
ii. 獲取鎖成功: 開啟一個新的線程或使用線程池,異步去查詢數據庫并更新緩存(更新數據和新的邏輯過期時間)。
iii. 無論是否獲取到鎖: 立即返回當前緩存中的舊數據。
iv. 獲取鎖成功的線程在異步更新完緩存后釋放鎖。 - 如果緩存未命中(例如首次訪問或緩存被意外刪除):
a. 走類似互斥鎖方案的邏輯:獲取鎖 -> 查詢數據庫 -> 寫入緩存(包含邏輯過期時間)-> 釋放鎖 -> 返回數據。或者,可以先寫入一個臨時的、表示正在加載的標記值,然后異步加載,后續請求根據標記值等待或返回舊數據(如果適用)。
實現要點:
-
緩存結構: 緩存的值不再是簡單的業務數據,而是一個包含業務數據和邏輯過期時間戳的對象或 JSON 字符串。
{"data": { ... }, // 真實的業務數據"expireTime": 1678886400000 // 邏輯過期時間戳 (e.g., System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)) }
-
異步重建: 當邏輯過期時,需要啟動異步任務來更新緩存,而不是阻塞當前請求。可以使用
@Async
注解、CompletableFuture
、線程池等。 -
并發控制: 異步重建的過程仍然需要互斥鎖,防止多個請求發現邏輯過期后同時去執行重建任務。
-
數據預熱: 對于核心熱點數據,可以在系統啟動時或低峰期提前加載到緩存中,并設置好邏輯過期時間。
Java 偽代碼示例 (結合邏輯過期與互斥鎖):
import com.fasterxml.jackson.databind.ObjectMapper; // Jackson for JSON
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;@Service
public class ProductServiceWithLogicalExpire {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate RedissonClient redissonClient;private ObjectMapper objectMapper = new ObjectMapper(); // 用于序列化/反序列化private static final String CACHE_KEY_PREFIX = "product:logical:";private static final String LOCK_KEY_PREFIX = "lock:product:logical:";private static final long LOGICAL_TTL_SECONDS = 30 * 60; // 邏輯過期時間:30分鐘private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); // 用于異步重建緩存的線程池// 內部類,用于封裝緩存數據和邏輯過期時間private static class RedisData<T> {private T data;private LocalDateTime expireTime; // 邏輯過期時間// 構造函數、Getter、Setter 省略...public RedisData(T data, LocalDateTime expireTime) {this.data = data;this.expireTime = expireTime;}public T getData() { return data; }public LocalDateTime getExpireTime() { return expireTime; }// Jackson 需要無參構造函數public RedisData() {}}/*** 查詢商品信息,使用邏輯過期解決緩存擊穿* @param productId 商品 ID* @return 商品信息,可能返回舊數據*/public String getProductInfoLogical(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 從緩存獲取數據 (JSON 字符串)String json = stringRedisTemplate.opsForValue().get(cacheKey);// 2. 緩存未命中 (可能是首次訪問,或緩存被意外刪除)if (json == null) {// 這里可以返回 null,或者觸發一次同步加載 (類似互斥鎖方案)// 為簡化,我們假設數據會通過預熱或其他方式寫入,這里直接返回 null// 實際應用中可能需要處理這種情況,例如,嘗試獲取鎖并同步加載System.out.println("緩存未命中 (邏輯過期場景,可能需要預熱或特殊處理)");// 可以嘗試獲取鎖并同步加載一次// return loadAndCacheProduct(productId); // 類似互斥鎖方案的加載邏輯return null;}// 3. 緩存命中,反序列化 JSONRedisData<String> redisData;try {redisData = objectMapper.readValue(json, objectMapper.getTypeFactory().constructParametricType(RedisData.class, String.class));} catch (Exception e) {System.err.println("反序列化緩存數據失敗: " + e.getMessage());// 可以選擇刪除錯誤格式的緩存,然后讓后續請求重新加載stringRedisTemplate.delete(cacheKey);return null; // 或者拋出異常}String productInfo = redisData.getData();LocalDateTime expireTime = redisData.getExpireTime();// 4. 判斷邏輯時間是否過期if (expireTime.isAfter(LocalDateTime.now())) {// 4.1 邏輯時間未過期,直接返回緩存數據System.out.println("邏輯時間未過期,直接返回緩存數據: " + productInfo);return productInfo;}// --- 5. 邏輯時間已過期,需要重建緩存 ---System.out.println("邏輯時間已過期,嘗試異步重建緩存...");String lockKey = LOCK_KEY_PREFIX + productId;RLock lock = redissonClient.getLock(lockKey);try {// 6. 嘗試獲取鎖 (waitTime=0,不等待,獲取不到就算了,讓其他線程去重建)boolean isLocked = lock.tryLock(0, 10, TimeUnit.SECONDS); // leaseTime 保證任務執行完前鎖不釋放if (isLocked) {System.out.println("線程 " + Thread.currentThread().getId() + " 獲取鎖成功,開啟異步任務重建緩存...");// 7. 獲取鎖成功,開啟異步線程重建緩存CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 查詢數據庫String freshProductInfo = queryProductFromDB(productId);// 計算新的邏輯過期時間LocalDateTime newExpireTime = LocalDateTime.now().plusSeconds(LOGICAL_TTL_SECONDS);// 創建新的 RedisDataRedisData<String> newRedisData = new RedisData<>(freshProductInfo, newExpireTime);// 寫入緩存 (沒有設置 TTL,永不過期)stringRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(newRedisData));System.out.println("異步任務:緩存重建完成");} catch (Exception e) {System.err.println("異步重建緩存失敗: " + e.getMessage());// 可以加入重試機制或日志記錄} finally {// 確保異步任務結束后釋放鎖if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();System.out.println("異步任務:釋放鎖");}}});}// 8. 無論是否獲取到鎖,都直接返回舊的緩存數據System.out.println("返回舊的緩存數據: " + productInfo);return productInfo;} catch (Exception e) {System.err.println("處理邏輯過期時發生錯誤: " + e.getMessage());// 發生異常時,仍然可以嘗試返回舊數據,保證可用性return productInfo;}// 注意:這里的 finally 不需要釋放鎖,因為鎖要么被異步任務持有,要么沒獲取到。// 如果 tryLock 失敗,鎖根本沒被當前線程持有。// 如果 tryLock 成功,鎖的釋放邏輯在異步任務中。}// 預熱數據:在系統啟動或低峰期調用,將數據加載到緩存public void warmUpProductCache(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;String lockKey = LOCK_KEY_PREFIX + productId;RLock lock = redissonClient.getLock(lockKey);try {// 加鎖防止并發預熱boolean isLocked = lock.tryLock(1, 10, TimeUnit.SECONDS);if(isLocked){System.out.println("預熱數據: 開始加載 productId=" + productId);String productInfo = queryProductFromDB(productId);if(productInfo != null){LocalDateTime expireTime = LocalDateTime.now().plusSeconds(LOGICAL_TTL_SECONDS);RedisData<String> redisData = new RedisData<>(productInfo, expireTime);stringRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(redisData));System.out.println("預熱數據: 加載完成 productId=" + productId);}}} catch (Exception e) {System.err.println("預熱數據失敗: " + e.getMessage());} finally {if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();}}}// 模擬數據庫查詢的方法 (同上一個例子)private String queryProductFromDB(Long productId) {System.out.println("--- 模擬數據庫查詢 productId: " + productId + " ---");try {TimeUnit.MILLISECONDS.sleep(200);} catch (InterruptedException e) {Thread.currentThread().interrupt();}if (productId != null && productId > 0 && productId < 1000) {return "{\"id\":" + productId + ", \"name\":\"模擬商品" + productId + "\", \"price\":99.9}";} else {return null;}}
}
代碼解釋與注意事項:
RedisData
類: 用于封裝實際數據和邏輯過期時間。你需要根據實際業務數據的類型調整泛型T
。- 序列化: 需要使用 Jackson 或 Gson 等庫將
RedisData
對象序列化為 JSON 字符串存入 Redis,取出時再反序列化。 - 異步執行器: 使用
ExecutorService
(線程池) 來執行緩存重建任務,避免阻塞當前請求線程。線程池的大小需要根據系統負載合理配置。 - 獲取鎖 (
tryLock(0, ...)
): 當發現邏輯過期時,嘗試獲取鎖設置為不等待 (waitTime=0
)。如果獲取失敗,說明有其他線程正在重建,當前線程直接返回舊數據即可,無需等待。 - 返回舊數據: 即使邏輯過期,也立即返回緩存中的舊數據。這保證了接口的低延遲,但犧牲了一定的數據實時性。是否接受舊數據取決于業務需求。
- 緩存未命中處理: 示例中簡化了緩存未命中的情況。實際應用中,如果緩存為空(如首次加載),可能需要一個同步加載的邏輯(類似互斥鎖方案),或者在預熱階段確保數據已加載。
- 鎖的釋放: 異步重建任務完成后,必須在異步任務內部釋放鎖。
優點:
- 高可用性: 通過返回舊數據,即使在緩存重建期間,服務也能持續提供響應,避免了互斥鎖方案中線程等待導致的部分請求延遲增加。
- 低延遲: 大部分請求(邏輯未過期或獲取鎖失敗)都能直接從緩存獲取數據(即使是舊數據),響應速度快。
缺點:
- 數據不一致: 在緩存重建完成之前,返回的是舊數據,存在一定時間窗口的數據不一致。業務需要能容忍這種短暫的不一致。
- 實現復雜度高: 需要引入邏輯過期時間字段、異步處理、額外的鎖機制,代碼復雜度相對較高。
- 額外內存開銷: 緩存值需要額外存儲邏輯過期時間,占用更多內存。
- 依賴預熱: 對于必須有數據才能提供服務的場景,依賴于數據的預熱。
1.3.3 對比與選擇
特性 | 互斥鎖/分布式鎖 | 邏輯過期/永不過期 |
---|---|---|
核心思想 | 加鎖排隊,只允許一個線程加載數據 | 返回舊數據,異步后臺更新 |
數據一致性 | 強一致性(理論上) | 最終一致性(存在短暫不一致) |
系統可用性 | 稍低(部分線程需等待) | 高(優先保證服務可用) |
響應延遲 | 可能增加(等待鎖) | 低(大部分請求直接返回) |
實現復雜度 | 中等 (使用 Redisson 較簡單) | 較高 (涉及異步、時間戳、額外鎖) |
內存開銷 | 正常 | 略高 (存儲邏輯時間) |
適用場景 | 對數據一致性要求高的場景 | 對可用性和性能要求極高,能容忍短暫數據不一致的場景 |
選擇建議:
- 如果業務對數據一致性要求非常高,不能容忍返回舊數據,互斥鎖/分布式鎖是更合適的選擇。
- 如果業務對接口性能和可用性要求極高,能夠接受短時間的數據不一致(例如,商品詳情頁展示舊幾分鐘的價格通常可以接受),邏輯過期方案是更好的選擇。
- 在實踐中,可以結合使用。例如,對極少數核心熱點數據使用邏輯過期,對其他普通熱點數據使用互斥鎖。
二、 緩存雪崩:大面積失效引發的“系統性災難”
2.1 什么是緩存雪崩?
緩存雪崩 是指在短時間內,大量緩存 Key 同時失效(例如,設置了相同的固定過期時間),或者 Redis 緩存服務本身發生宕機或不可用,導致海量的請求在無法命中緩存的情況下,直接沖擊到后端的數據庫或其他數據源,如同雪崩一般,瞬間壓垮后端服務。
與緩存擊穿針對“單個”熱點 Key 不同,緩存雪崩影響的是“大面積”的緩存 Key。
主要誘因:
- 同一時間大面積 Key 過期:
- 固定 TTL: 給大量的 Key 設置了完全相同的過期時間(例如,
expire key 3600
),導致它們在未來的某個時間點同時失效。這在批量導入數據、定時任務刷新緩存等場景下容易發生。 - 應用重啟: 應用重啟可能導致內存中的緩存(如 Guava Cache, Caffeine)全部丟失,如果此時有大量請求涌入,也會沖擊后端。
- 固定 TTL: 給大量的 Key 設置了完全相同的過期時間(例如,
- Redis 服務宕機或故障:
- 單點故障: 如果 Redis 是單節點部署,一旦該節點宕機,所有緩存訪問都會失敗。
- 集群故障: Redis 集群(如 Sentinel 或 Cluster 模式)發生主從切換、網絡分區或其他故障,導致部分或全部緩存節點在短時間內不可用。
2.2 緩存雪崩的風險
緩存雪崩的后果通常比緩存擊穿更嚴重,因為它影響范圍更廣,可能導致系統性問題:
- 數據庫徹底崩潰: 雪崩帶來的請求量可能是平時的幾倍甚至幾十倍,數據庫往往難以承受如此巨大的瞬時壓力,導致連接耗盡、CPU 飆升、響應超時,最終宕機。
- 系統性癱瘓: 數據庫作為核心依賴,其崩潰會迅速傳導到上游服務,引發連鎖反應,導致整個應用集群或相關微服務大面積不可用。
- 資源耗盡: 不僅僅是數據庫,應用服務器的線程池、連接池等資源也可能被大量等待數據庫響應的請求耗盡。
- 恢復時間長: 由于影響范圍廣,涉及多個服務和數據庫,從雪崩中恢復通常需要較長時間,需要重啟服務、預熱數據等。
- 數據丟失風險(極端情況): 如果系統設計不當,數據庫崩潰可能導致事務未提交、消息丟失等問題。
舉例說明:
- 定時任務刷新全量配置: 每天凌晨 4 點定時任務刷新系統中所有配置項的緩存,并設置了 24 小時過期。那么第二天凌晨 4 點,所有配置緩存將同時失效。
- Redis 主節點宕機: 部署了 Redis 主從模式,但沒有哨兵自動切換或切換失敗,主節點宕機導致所有寫操作失敗,讀操作也可能失敗(取決于配置)。
- 云服務商 Redis 故障: 使用的云 Redis 服務發生區域性故障,導致大量應用的緩存不可用。
2.3 緩存雪崩的解決方案
解決緩存雪崩需要從預防和容災兩個層面入手。核心思路是:避免 Key 同時過期、保證緩存服務高可用、在緩存失效時進行限流和降級。
2.3.1 預防 Key 同時過期
-
過期時間加隨機值(推薦): 這是最簡單有效的防止因 TTL 相同導致雪崩的方法。在設置緩存過期時間時,不再使用固定的 TTL,而是在一個基礎 TTL 上增加一個隨機的時間范圍。
公式:
expireTime = baseTTL + random(range)
例如,基礎過期時間是 1 小時,可以增加一個 0 到 10 分鐘的隨機值。這樣,即使是同一批寫入的緩存,它們的過期時間也會分散開,避免在同一時刻集中失效。
Java 示例 (使用
StringRedisTemplate
)import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;import java.util.Random; import java.util.concurrent.TimeUnit;@Component public class CacheUtils {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private static final long BASE_TTL_MINUTES = 60; // 基礎過期時間:60分鐘private static final int RANDOM_RANGE_MINUTES = 10; // 隨機范圍:0-10分鐘private static final Random random = new Random();/*** 設置緩存,并添加隨機過期時間* @param key 緩存 Key* @param value 緩存 Value*/public void setCacheWithRandomTtl(String key, String value) {// 計算隨機增加的秒數long randomSeconds = random.nextInt(RANDOM_RANGE_MINUTES * 60);// 計算最終的過期時間(秒)long finalTtlSeconds = TimeUnit.MINUTES.toSeconds(BASE_TTL_MINUTES) + randomSeconds;System.out.println("設置緩存 Key: " + key + ", 基礎 TTL: " + BASE_TTL_MINUTES + " 分鐘, 隨機增加: "+ TimeUnit.SECONDS.toMinutes(randomSeconds) + " 分鐘 "+ (randomSeconds % 60) + " 秒, 最終 TTL: " + finalTtlSeconds + " 秒");stringRedisTemplate.opsForValue().set(key, value, finalTtlSeconds, TimeUnit.SECONDS);}/*** 設置緩存,使用基礎 TTL 和固定的隨機因子(適合按 Key 哈希分散)* @param key 緩存 Key* @param value 緩存 Value*/public void setCacheWithFixedRandomFactor(String key, String value) {// 使用 key 的哈希值來確定一個固定的隨機偏移量,確保同一個 key 的偏移量是穩定的int hashFactor = Math.abs(key.hashCode()) % (RANDOM_RANGE_MINUTES * 60); // 0 到 range-1 的秒數long finalTtlSeconds = TimeUnit.MINUTES.toSeconds(BASE_TTL_MINUTES) + hashFactor;System.out.println("設置緩存 Key: " + key + ", 基礎 TTL: " + BASE_TTL_MINUTES + " 分鐘, 固定偏移: "+ TimeUnit.SECONDS.toMinutes(hashFactor) + " 分鐘 "+ (hashFactor % 60) + " 秒, 最終 TTL: " + finalTtlSeconds + " 秒");stringRedisTemplate.opsForValue().set(key, value, finalTtlSeconds, TimeUnit.SECONDS);} }
注意:
random.nextInt(upperBound)
生成的是[0, upperBound)
范圍內的隨機整數。- 隨機范圍
range
需要根據業務場景和基礎 TTL 合理設置。范圍太小效果不明顯,范圍太大可能導致緩存命中率略微下降。 - 第二種方法
setCacheWithFixedRandomFactor
使用 Key 的哈希值計算偏移,可以保證同一個 Key 每次寫入時的過期時間點相對固定(只要基礎 TTL 不變),有助于緩存預熱和管理,但也可能因為哈希碰撞導致少量 Key 依然集中過期,是一種折中方案。
-
永不過期(用于邏輯過期方案): 對于核心數據,可以采用上一節提到的邏輯過期方案,不設置物理 TTL,從根本上避免因過期導致的雪崩。但這需要業務能接受返回舊數據。
2.3.2 保證緩存服務高可用
預防 Redis 服務宕機或故障導致的雪崩,關鍵在于構建高可用的 Redis 集群。
- Redis Sentinel (哨兵模式):
- 原理: 通過引入一個或多個 Sentinel 進程來監控 Redis 主從節點的狀態。當主節點故障時,Sentinel 會自動進行故障轉移(Failover),選舉一個新的從節點提升為新的主節點,并通知客戶端切換連接。
- 優點: 實現了主從切換自動化,提高了 Redis 的可用性。
- 缺點: 每個 Sentinel 節點都需要維護所有主從節點的狀態信息,配置相對復雜。寫操作仍然只能在主節點進行,寫性能受限于單機。故障切換過程中可能有短暫的服務中斷。
- Redis Cluster (集群模式):
- 原理: 采用去中心化的分片架構。數據被分散存儲在多個節點上(通過哈希槽 Slot),每個節點負責一部分 Slot。節點間通過 Gossip 協議進行通信和狀態同步。每個主節點可以有自己的從節點用于故障轉移。
- 優點: 提供了水平擴展能力(增加節點可以提升容量和吞吐量),天然支持高可用(部分節點故障不影響整個集群),去中心化設計。
- 缺點: 實現更復雜,對客戶端有要求(需要支持 Cluster 協議),不支持部分 Redis 命令(如涉及多個 Key 的原子操作可能受限)。
- 多副本與跨機架/跨可用區部署: 無論是 Sentinel 還是 Cluster,都應該配置多個副本(主從),并將這些副本部署在不同的物理機架(IDC 環境)或不同的可用區(云環境),以防止單點物理故障(如機架掉電、可用區網絡故障)導致整個緩存服務不可用。
選擇建議:
- 對于需要高可用且數據量和并發量不是特別巨大的場景,Redis Sentinel 是一個成熟且相對簡單的選擇。
- 對于需要高可用、高并發、并且需要水平擴展能力的大規模緩存場景,Redis Cluster 是更優的選擇。
- 無論哪種模式,多副本和跨區域部署都是必不可少的。
2.3.3 容災措施:限流與降級
即使做了上述預防措施,也不能完全保證緩存雪崩絕對不會發生(例如,極端網絡故障、程序 Bug 導致緩存被意外清空)。因此,還需要有事后的容災手段,即在緩存失效、大量請求涌向后端時,能夠限制流量并犧牲部分非核心功能,保護核心服務和數據庫不被壓垮。
-
后端服務限流 (Rate Limiting):
- 目的: 限制單位時間內能夠訪問數據庫或其他后端服務的請求數量,超過閾值的請求直接拒絕或排隊等待,防止后端過載。
- 實現方式:
- Guava RateLimiter: Java 單機限流庫,簡單易用,提供令牌桶和平滑突發限流算法。
- Sentinel: 分布式流量控制、熔斷降級框架(阿里巴巴開源)。功能強大,支持多種限流策略(QPS、線程數)、熔斷降級、熱點參數限流等,提供可視化控制臺。
- Hystrix: Netflix 開源的容錯庫,提供線程隔離/信號量隔離、熔斷、降級等功能(目前已進入維護狀態,推薦使用 Sentinel 或 Resilience4j)。
- Nginx/Gateway 層限流: 在網關層面對接口進行統一限流。
- 關鍵: 限流閾值需要根據后端服務的實際處理能力進行壓測和設定。
Java + Sentinel 示例 (簡單 QPS 限流):
- 需要引入 Sentinel 依賴,并進行配置。
- 在需要保護的方法上添加
@SentinelResource
注解,并配置流控規則。
import com.alibaba.csp.sentinel.annotation.SentinelResource; import com.alibaba.csp.sentinel.slots.block.BlockException; import org.springframework.stereotype.Service;@Service public class DatabaseService {// 定義資源名,用于 Sentinel 控制臺配置規則private static final String DB_QUERY_RESOURCE = "queryDatabaseResource";// 模擬數據庫查詢方法,使用 Sentinel 進行限流保護// value: 資源名// blockHandler: 指定流控降級(被阻止)時調用的方法 (方法簽名需匹配)// fallback: 指定發生異常時調用的方法 (方法簽名需匹配)@SentinelResource(value = DB_QUERY_RESOURCE,blockHandler = "handleBlock",fallback = "handleFallback")public String queryFromDBWithSentinel(Long id) {System.out.println("--- 嘗試查詢數據庫 ID: " + id + " ---");// 模擬數據庫操作可能拋出異常if (id != null && id < 0) {throw new IllegalArgumentException("ID 不能為負數");}// 模擬數據庫查詢耗時try {Thread.sleep(50); // 模擬耗時} catch (InterruptedException e) {Thread.currentThread().interrupt();}return "DB_Result_For_" + id;}// 流控降級處理方法 (BlockException)// 注意:方法必須是 public,返回值和參數列表要與原方法一致,// 并且額外多一個 BlockException 參數。可以是靜態方法。public String handleBlock(Long id, BlockException ex) {System.err.println("觸發限流!資源名: " + ex.getRule().getResource()+ ", 規則: " + ex.getRuleLimitApp()+ ", 請求 ID: " + id);// 可以返回默認值、友好提示或 nullreturn "系統繁忙,請稍后重試 (限流)";}// 異常降級處理方法 (Throwable)// 注意:方法必須是 public,返回值和參數列表要與原方法一致,// 并且額外多一個 Throwable 參數。可以是靜態方法。public String handleFallback(Long id, Throwable ex) {System.err.println("查詢數據庫時發生異常!請求 ID: " + id + ", 異常: " + ex.getMessage());// 可以返回默認值、友好提示或 nullreturn "系統錯誤,請稍后重試 (異常降級)";}// --- Sentinel 規則配置 (實際應通過配置中心或 Dashboard 配置) ---// 這里僅作演示,在應用啟動時配置規則 (需要引入 sentinel-datasource-extension)// 或者通過 Sentinel Dashboard 動態配置static {// initFlowRules(); // 在實際項目中通過配置加載}/*private static void initFlowRules(){List<FlowRule> rules = new ArrayList<>();FlowRule rule = new FlowRule();rule.setResource(DB_QUERY_RESOURCE); // 針對哪個資源rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 限流閾值類型:QPSrule.setCount(10); // 設置 QPS 閾值為 10rules.add(rule);FlowRuleManager.loadRules(rules);System.out.println("Sentinel 流控規則加載完成: " + DB_QUERY_RESOURCE + " QPS=10");}*/ }
-
服務降級 (Degradation):
- 目的: 當系統負載過高或依賴的服務出現問題時,暫時屏蔽或簡化非核心功能,釋放資源,保證核心功能的穩定運行。
- 實現方式:
- 開關降級: 通過配置中心(如 Nacos, Apollo)設置開關,手動或自動觸發降級。
- 熔斷降級 (Circuit Breaking): 當某個依賴服務的錯誤率、慢調用比例超過閾值時,熔斷器會打開,后續一段時間內所有對該服務的調用都會直接失敗(執行降級邏輯),不再請求該服務,避免資源浪費和雪崩效應。一段時間后,熔斷器會進入半開狀態,嘗試放行少量請求,如果成功則關閉熔斷器恢復正常,如果失敗則繼續保持打開狀態。Sentinel 和 Hystrix 都提供了熔斷降級功能。
- 降級策略:
- 返回默認值/Mock 數據: 例如,商品推薦服務降級時,返回固定的默認推薦列表。
- 返回空值/錯誤提示: 例如,用戶積分查詢服務降級時,返回空或提示“積分服務暫不可用”。
- 執行簡化邏輯: 例如,復雜的計算服務降級時,執行一個簡化的、資源消耗較低的計算邏輯。
- 關鍵: 需要提前梳理業務的核心和非核心功能,并為非核心功能設計好降級預案。
-
請求隊列/異步化: 對于非實時性要求高的操作,可以考慮將請求放入消息隊列(如 Kafka, RabbitMQ),由后端服務異步消費處理,削峰填谷,避免瞬時流量直接沖擊數據庫。
2.3.4 多級緩存
構建多級緩存體系也是應對緩存雪崩和提升性能的有效手段。
- 客戶端緩存 (Local Cache): 在應用服務器內存中緩存數據(如 Guava Cache, Caffeine)。訪問速度最快,但容量有限,且存在數據一致性問題(需要合適的失效策略)。可以緩存一些變化頻率低、體積小的數據。
- 分布式緩存 (Remote Cache): Redis 等。容量和并發能力遠超本地緩存,是主要的緩存層。
- Nginx + Lua 緩存: 在網關層使用 OpenResty (Nginx + Lua) 實現緩存,可以攔截部分請求,減輕后端壓力。
當 Redis 雪崩時,如果本地緩存仍然有效,可以頂住一部分流量。多級緩存可以層層過濾請求,降低最終到達數據庫的壓力。
2.3.5 對比與選擇
方案 | 核心作用 | 優點 | 缺點 | 適用階段 |
---|---|---|---|---|
過期時間加隨機值 | 預防 Key 同時過期 | 簡單有效,易實現 | 可能略微降低緩存命中率 | 預防 |
高可用緩存集群 | 預防 Redis 服務宕機 | 提高緩存服務自身健壯性 | 配置部署相對復雜,有成本 | 預防 |
服務限流 | 事后保護后端 | 防止后端過載,強制限制流量 | 可能拒絕部分正常請求,需合理設置閾值 | 容災 |
服務降級/熔斷 | 事后保護核心功能 | 犧牲非核心保核心,提高系統韌性 | 需要梳理業務,設計降級預案 | 容災 |
多級緩存 | 提升性能,分攤壓力 | 提高命中率,減輕后端壓力,增加一層防護 | 增加了系統復雜度,數據一致性更難保證 | 預防 & 容災 |
請求隊列/異步化 | 削峰填谷,解耦 | 提高系統吞吐,平滑流量 | 增加了延遲,改變了交互模式,引入MQ復雜性 | 架構優化 |
選擇建議:
緩存雪崩的防治是一個體系化的工程,通常需要組合使用多種策略:
- 必須做:
- 過期時間加隨機值: 成本最低,效果最直接的預防措施。
- 高可用緩存集群 (Sentinel/Cluster): 保障緩存服務自身穩定性的基石。
- 強烈推薦:
- 服務限流: 對訪問數據庫或其他核心依賴的操作進行限流,是最后的保護屏障。
- 服務降級/熔斷: 提前規劃,確保極端情況下核心業務可用。
- 可選優化:
- 多級緩存: 根據業務場景和性能需求決定是否引入本地緩存或其他層級緩存。
- 請求隊列/異步化: 適用于可以接受異步處理的場景。
三、 緩存穿透:查詢不存在數據的“持續騷擾”
3.1 什么是緩存穿透?
緩存穿透 是指客戶端持續發起對一個緩存和數據庫中都不存在的數據的查詢請求。由于緩存中沒有命中(因為數據根本不存在),請求每次都會“穿透”緩存層,直接打到后端的數據庫。如果這類請求量很大,也會給數據庫帶來巨大的壓力,甚至影響正常服務。
這就像有人故意或無意地,不停地按一個不存在的門鈴,每次都得讓房主(數據庫)親自去開門確認,徒勞無功。
關鍵特征:
- 查詢不存在的數據: 請求的 Key 在緩存和數據庫中都找不到對應的值。
- 繞過緩存: 每次請求都無法命中緩存。
- 直擊數據庫: 每次請求都落到數據庫或其他后端存儲上。
常見場景:
- 惡意攻擊: 攻擊者利用漏洞或猜測,構造大量不存在的 ID 或參數,持續發起查詢請求,意圖拖垮數據庫。例如,不斷請求
product_id=-1
,user_id=random_string
等無效 ID。 - 程序 Bug: 代碼邏輯錯誤,導致生成或傳入了非法的參數去查詢數據。
- 業務規則變化: 之前存在的數據被刪除了,但前端或其他系統仍然在請求這些已刪除的數據。
3.2 緩存穿透的風險
緩存穿透的風險與雪崩和擊穿有所不同,它通常不是瞬時的爆發,而是持續性的壓力:
- 數據庫持續承壓: 大量無效查詢不斷消耗數據庫的連接、CPU 和 IO 資源,導致正常查詢性能下降。
- 資源浪費: 系統花費大量資源處理這些無效請求。
- 難以察覺: 單個穿透請求看起來可能并不異常,只有當總量達到一定規模時,才會顯現出對數據庫的壓力,因此可能在造成影響前難以被發現。
- 安全風險: 如果是惡意攻擊,可能被用作一種低成本的 DoS (Denial of Service) 或 DDoS (Distributed Denial of Service) 攻擊手段。
3.3 緩存穿透的解決方案
解決緩存穿透的核心思路是:識別并攔截這些對不存在數據的無效查詢,阻止它們到達數據庫。
3.3.1 緩存空值 (Cache Null Values) - 常用
這是最簡單直接的方法。當數據庫查詢一個 Key 返回為空(即數據不存在)時,仍然將這個“空結果”或一個特殊的占位符緩存起來,但設置一個較短的過期時間。
基本流程:
- 請求線程訪問緩存。
- 如果緩存命中:
a. 檢查命中的值是否是預定義的“空值標記”。
b. 如果是空值標記,直接返回null
或告知調用方數據不存在。
c. 如果是正常數據,直接返回。 - 如果緩存未命中:
a. 查詢數據庫。
b. 如果數據庫查詢有結果,將結果寫入緩存(設置正常過期時間)。
c. 如果數據庫查詢無結果 (null),將一個空值標記(如空字符串""
、特定 JSON{"isNull":true}
或null
本身,取決于 Redis 客戶端和序列化方式)寫入緩存,并設置一個較短的 TTL(例如 1-5 分鐘)。 - 返回查詢結果(可能是真實數據或
null
)。
Java + Redis 示例 (使用 StringRedisTemplate
緩存空字符串):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; // Spring Framework StringUtilsimport java.util.concurrent.TimeUnit;@Service
public class ProductServiceWithNullCache {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private static final String CACHE_KEY_PREFIX = "product:nullcache:";private static final long CACHE_TTL_MINUTES = 30; // 正常數據緩存時間private static final long NULL_CACHE_TTL_MINUTES = 5; // 空值緩存時間private static final String NULL_VALUE_MARKER = ""; // 使用空字符串作為空值標記/*** 查詢商品信息,使用緩存空值解決緩存穿透* @param productId 商品 ID* @return 商品信息,如果不存在則返回 null*/public String getProductInfoWithNullCache(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 從緩存獲取數據String productInfo = stringRedisTemplate.opsForValue().get(cacheKey);// 2. 緩存命中if (productInfo != null) {// 2.1 判斷是否是空值標記if (NULL_VALUE_MARKER.equals(productInfo)) {System.out.println("緩存命中空值標記,返回 null");return null; // 數據不存在}// 2.2 是正常數據,直接返回System.out.println("緩存命中,直接返回: " + productInfo);return productInfo;}// --- 3. 緩存未命中 ---System.out.println("緩存未命中,查詢數據庫...");// 4. 查詢數據庫String dbResult = queryProductFromDB(productId); // 模擬數據庫查詢// 5. 處理數據庫結果并寫入緩存if (dbResult != null) {// 5.1 數據庫有數據,寫入緩存 (正常 TTL)System.out.println("數據庫查詢到數據,寫入緩存: " + dbResult);stringRedisTemplate.opsForValue().set(cacheKey, dbResult, CACHE_TTL_MINUTES, TimeUnit.MINUTES);return dbResult;} else {// 5.2 數據庫無數據,寫入空值標記 (短 TTL)System.out.println("數據庫無此數據,寫入空值標記到緩存");stringRedisTemplate.opsForValue().set(cacheKey, NULL_VALUE_MARKER, NULL_CACHE_TTL_MINUTES, TimeUnit.MINUTES);return null; // 數據不存在}}// 模擬數據庫查詢的方法 (同上一個例子)private String queryProductFromDB(Long productId) {System.out.println("--- 模擬數據庫查詢 productId: " + productId + " ---");try {TimeUnit.MILLISECONDS.sleep(50); // 模擬DB查詢耗時} catch (InterruptedException e) {Thread.currentThread().interrupt();}if (productId != null && productId > 0 && productId < 1000) {return "{\"id\":" + productId + ", \"name\":\"真實商品" + productId + "\", \"price\":199.9}";} else {return null; // 模擬數據庫不存在}}
}
代碼解釋與注意事項:
- 空值標記: 選擇一個不會與正常業務數據沖突的值作為空值標記。空字符串
""
是常見的選擇,但如果業務數據本身可能就是空字符串,則需要選擇其他標記,如一個特定的 JSON 串{"isNull": true}
或一個特殊的字符串"$NULL$"
。 - 短 TTL: 緩存空值的過期時間必須設置得比較短(如幾分鐘)。原因:
- 防止存儲過多的無效 Key 占用 Redis 內存。
- 如果之后數據庫中真的插入了這個 Key 對應的數據,較短的 TTL 可以讓緩存盡快失效,以便后續請求能獲取到最新的真實數據。
- 一致性問題: 在空值緩存的 TTL 時間內,如果數據庫中新增了對應的數據,客戶端仍然會獲取到
null
。這是一個短暫的數據不一致,通常可以接受。如果對一致性要求極高,可能需要配合其他機制(如數據庫變更時主動刪除緩存)。
優點:
- 簡單易懂: 實現邏輯清晰,容易理解和部署。
- 效果顯著: 能有效阻止對同一個不存在 Key 的重復數據庫查詢。
缺點:
- 額外的緩存開銷: 需要存儲空值 Key,占用了 Redis 的內存空間。如果惡意攻擊者持續請求大量不同的不存在 Key,可能會消耗大量緩存空間。
- 短暫的數據不一致: 如上所述,在空值 TTL 內無法感知到數據庫的新增數據。
3.3.2 布隆過濾器 (Bloom Filter) - 推薦
布隆過濾器是一種空間效率極高的概率型數據結構,用于判斷一個元素是否可能存在于一個集合中。它可以在使用極少內存的情況下,快速判斷一個 Key 是否一定不存在。
核心特點:
- 空間效率高: 比哈希表等傳統結構節省大量空間。
- 查詢速度快: 判斷時間復雜度為 O(k),k 為哈希函數個數,通常是常數。
- 存在誤判率 (False Positive): 它可能將一個不存在的元素誤判為存在(但概率可控)。
- 絕不漏判 (No False Negative): 它絕不會將一個存在的元素誤判為不存在。
工作原理簡述:
- 初始化: 一個長度為 m 的位數組(所有位初始化為 0)和 k 個獨立的哈希函數。
- 添加元素: 當要添加一個元素時,用 k 個哈希函數分別計算該元素的哈希值,得到 k 個在位數組中的下標位置,并將這些位置的位都置為 1。
- 查詢元素: 當要查詢一個元素是否存在時,同樣用 k 個哈希函數計算出 k 個下標位置。檢查這 k 個位置:
- 如果任意一個位置的位是 0,則該元素一定不存在。
- 如果所有位置的位都是 1,則該元素可能存在(有可能是之前添加的其他元素恰好把這些位都置為 1 了,這就是誤判)。
如何用于防止緩存穿透:
- 預加載: 將數據庫中所有可能被查詢的 Key (例如,所有商品 ID、用戶 ID) 提前加載到布隆過濾器中。
- 請求過濾: 當一個查詢請求到來時:
a. 先用布隆過濾器判斷該 Key 是否存在。
b. 如果布隆過濾器判斷一定不存在,則直接返回null
或錯誤信息,不再查詢緩存和數據庫。
c. 如果布隆過濾器判斷可能存在,則繼續執行后續的緩存查詢和數據庫查詢邏輯(允許少量誤判的請求穿透到緩存層)。
實現方式:
- Guava BloomFilter: Google Guava 庫提供了 Java 實現的布隆過濾器,適用于單機內存。
- Redis (配合 Redisson 或 Lua 腳本):
- Redisson
RBloomFilter
: Redisson 客戶端提供了開箱即用的分布式布隆過濾器實現RBloomFilter
,底層利用 Redis 的 Bitmap 數據結構。這是在分布式環境中最推薦的方式。 - 自定義 Lua + Bitmap: 可以自己編寫 Lua 腳本,利用 Redis 的
SETBIT
和GETBIT
命令操作 Bitmap 來實現布隆過濾器邏輯。
- Redisson
Java + Redisson RBloomFilter
示例:
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import javax.annotation.PostConstruct; // 用于初始化布隆過濾器
import java.util.concurrent.TimeUnit;
import java.util.stream.LongStream; // 用于生成模擬數據@Service
public class ProductServiceWithBloomFilter {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate RedissonClient redissonClient;private RBloomFilter<Long> productBloomFilter; // Redisson 布隆過濾器實例private static final String CACHE_KEY_PREFIX = "product:bloom:";private static final String BLOOM_FILTER_NAME = "product_ids_bloom_filter";private static final long EXPECTED_INSERTIONS = 10000; // 預期插入的元素數量 (例如,預計的商品總數)private static final double FALSE_POSITIVE_PROBABILITY = 0.01; // 期望的誤判率 (例如 1%)/*** 初始化布隆過濾器,加載全量數據* 實際應用中,應該在系統啟動時或通過定時任務加載*/@PostConstruct // 在 Bean 初始化后執行public void initBloomFilter() {System.out.println("開始初始化商品 ID 布隆過濾器...");productBloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);// tryInit(expectedInsertions, falseProbability)// expectedInsertions: 預期放入的元素數量// falseProbability: 可接受的最大誤判率// Redisson 會根據這兩個參數自動計算最優的位數組長度和哈希函數個數productBloomFilter.tryInit(EXPECTED_INSERTIONS, FALSE_POSITIVE_PROBABILITY);// --- 模擬從數據庫加載全量商品 ID 并添加到布隆過濾器 ---// 實際應用中,這里應該是查詢數據庫獲取所有有效的商品 IDSystem.out.println("模擬加載商品 ID 到布隆過濾器...");LongStream.rangeClosed(1, 1000) // 假設數據庫有 1000 個商品 ID.forEach(id -> {productBloomFilter.add(id);if (id % 100 == 0) {System.out.print("."); // 打印進度}});System.out.println("\n布隆過濾器初始化完成。");System.out.println("布隆過濾器大小 (估計): " + productBloomFilter.getSize());System.out.println("布隆過濾器哈希函數數量 (估計): " + productBloomFilter.getHashIterations());}/*** 查詢商品信息,使用布隆過濾器解決緩存穿透* @param productId 商品 ID* @return 商品信息,如果不存在則返回 null*/public String getProductInfoWithBloomFilter(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// --- 1. 使用布隆過濾器進行前置判斷 ---if (!productBloomFilter.contains(productId)) {// 如果布隆過濾器判斷該 ID 一定不存在System.out.println("布隆過濾器攔截:商品 ID " + productId + " 不存在,直接返回 null");return null; // 直接返回,不查詢緩存和數據庫}// --- 布隆過濾器認為 ID 可能存在,繼續后續流程 ---System.out.println("布隆過濾器認為商品 ID " + productId + " 可能存在,繼續查詢緩存...");// 2. 從緩存獲取數據String productInfo = stringRedisTemplate.opsForValue().get(cacheKey);// 3. 緩存命中if (productInfo != null) {// 注意:即使布隆過濾器通過了,緩存中也可能存的是空值標記(如果結合了緩存空值策略)// 這里我們假設沒有結合緩存空值,或者之前的例子已經處理了空值標記的判斷System.out.println("緩存命中,直接返回: " + productInfo);return productInfo;}// --- 4. 緩存未命中 ---System.out.println("緩存未命中,查詢數據庫...");// 5. 查詢數據庫String dbResult = queryProductFromDB(productId); // 模擬數據庫查詢// 6. 處理數據庫結果并寫入緩存if (dbResult != null) {// 數據庫有數據,寫入緩存 (正常 TTL)System.out.println("數據庫查詢到數據,寫入緩存: " + dbResult);stringRedisTemplate.opsForValue().set(cacheKey, dbResult, 30, TimeUnit.MINUTES); // 正常緩存時間return dbResult;} else {// 數據庫無數據// 走到這里,說明發生了布隆過濾器的誤判 (False Positive)// 或者是在布隆過濾器初始化之后,數據庫刪除了該 IDSystem.err.println("布隆過濾器誤判或數據已刪除:數據庫未找到商品 ID " + productId);// 可以選擇緩存空值來防止后續對該誤判 Key 的數據庫查詢// stringRedisTemplate.opsForValue().set(cacheKey, "", 5, TimeUnit.MINUTES);return null; // 數據不存在}}// 模擬數據庫查詢的方法 (同上)private String queryProductFromDB(Long productId) {System.out.println("--- 模擬數據庫查詢 productId: " + productId + " ---");try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {Thread.currentThread().interrupt();}// 注意:為了測試布隆過濾器效果,這里的判斷條件要和初始化時一致if (productId != null && productId >= 1 && productId <= 1000) { // 假設只有 1 到 1000 是有效 IDreturn "{\"id\":" + productId + ", \"name\":\"真實商品" + productId + "\", \"price\":199.9}";} else {return null;}}
}
代碼解釋與注意事項:
RBloomFilter<Long>
: Redisson 提供了泛型接口,這里指定元素類型為Long
(商品 ID)。tryInit()
: 初始化布隆過濾器。expectedInsertions
(預期元素數量) 和falseProbability
(誤判率) 是最重要的參數。Redisson 會根據它們計算出最優的位數組大小 (m) 和哈希函數個數 (k)。expectedInsertions
預估要準: 如果實際插入數量遠超預期,誤判率會急劇上升。通常需要預留一些余量。falseProbability
選擇要合理: 誤判率越低,需要的內存空間越大,計算開銷也可能略高。通常設置在 0.01 (1%) 到 0.001 (0.1%) 之間。
add()
: 將元素添加到布隆過濾器。contains()
: 判斷元素是否存在。返回false
表示一定不存在,返回true
表示可能存在。- 初始化時機: 布隆過濾器的初始化(加載全量 Key)通常在系統啟動時完成。如果 Key 集合會變化(新增商品),需要有機制(如定時任務、MQ 監聽數據庫變更)來定期重建或增量更新布隆過濾器。標準布隆過濾器不支持刪除元素,如果需要刪除,可以考慮使用計數布隆過濾器 (Counting Bloom Filter) 或定期完全重建。
- 分布式環境: Redisson 的
RBloomFilter
是分布式的,多個應用實例共享同一個位于 Redis 中的布隆過濾器。 - 誤判處理: 即使布隆過濾器判斷 Key 可能存在,數據庫查詢后仍然可能返回
null
(因為誤判或數據被刪除)。此時可以選擇緩存空值(短 TTL)來進一步防止對該誤判 Key 的重復數據庫查詢。
優點:
- 內存效率極高: 相比緩存空值,占用內存極少,可以處理海量 Key。
- 效率高: 查詢速度快,能攔截掉絕大部分無效查詢,顯著降低數據庫壓力。
- 實現相對簡單 (使用 Redisson): Redisson 封裝了底層細節。
缺點:
- 存在誤判率: 無法 100% 攔截所有穿透請求,總有一小部分(概率由
falseProbability
控制)會漏過布隆過濾器到達緩存層甚至數據庫層。 - 不支持刪除(標準實現): 標準布隆過濾器無法安全地刪除元素。刪除操作可能影響其他元素的判斷。需要定期重建或使用變種(如 Counting Bloom Filter,但空間效率會降低)。
- 需要預加載/更新: 需要將全量或變化的 Key 同步到過濾器中,增加了維護成本。
3.3.3 接口層校驗 (Parameter Validation)
在緩存和數據庫查詢之前,對請求參數進行合法性校驗,也是一種有效的輔助手段。
- 基本類型校驗: 例如,用戶 ID 必須是正整數,商品 ID 必須符合某種格式。
- 取值范圍校驗: 例如,訂單號長度必須是 N 位,狀態值只能是幾個枚舉值之一。
- 業務規則校驗: 例如,根據用戶權限判斷其是否能查詢某些數據。
通過嚴格的參數校驗,可以在入口處就攔截掉大量明顯不合法的請求,減輕后續處理邏輯的壓力。這通常在 Controller 層或 Service 層入口完成,可以使用 Java 的 Bean Validation (JSR 303/380) 注解(如 @NotNull
, @Min
, @Pattern
等)或手動編寫校驗邏輯。
優點:
- 提前攔截: 在請求處理早期就過濾掉非法請求。
- 邏輯清晰: 校驗規則明確。
缺點:
- 無法覆蓋所有場景: 只能校驗參數本身的格式和范圍,無法判斷一個格式合法但實際不存在的 ID(例如,一個符合 ID 格式但數據庫里沒有的
productId=999999
)。 - 不能完全替代其他方案: 通常作為第一道防線,需要結合緩存空值或布隆過濾器使用。
3.3.4 對比與選擇
方案 | 核心作用 | 優點 | 缺點 | 適用階段 |
---|---|---|---|---|
緩存空值 | 緩存不存在的結果 | 實現簡單,效果直接 | 占用額外緩存空間,存在短暫數據不一致,對大量不同 Key 攻擊效果差 | 緩存層 |
布隆過濾器 | 概率性判斷 Key 是否存在 | 空間效率極高,速度快,攔截大部分無效請求 | 存在誤判率,標準實現不支持刪除,需要維護 Key 集合 | 前置過濾 |
接口層校驗 | 校驗參數合法性 | 提前攔截明顯非法請求,邏輯清晰 | 無法判斷邏輯上存在但實際不存在的 Key | 入口層 |
選擇建議:
緩存穿透的解決方案也常常是組合拳:
- 接口層校驗: 作為基礎防線,攔截明顯無效的請求參數。
- 布隆過濾器: 強烈推薦作為核心解決方案,尤其是當 Key 集合相對穩定且數量較大時。它可以高效地過濾掉絕大多數不存在的 Key 的查詢。
- 緩存空值: 可以作為布隆過濾器的補充。對于通過了布隆過濾器(可能是誤判)但數據庫查詢確實為空的 Key,緩存一個短暫的空值,可以防止后續對該誤判 Key 的重復數據庫訪問。也可以在不方便使用布隆過濾器(如 Key 集合變化頻繁且難以維護)的場景下單獨使用,但要注意內存占用和一致性問題。
四、 總結與最佳實踐
核心區別回顧:
- 緩存擊穿: 單個熱點 Key 過期,大量并發請求打到 DB。
- 緩存雪崩: 大量 Key 同時過期 或 緩存服務宕機,海量請求打到 DB。
- 緩存穿透: 查詢不存在的數據,請求繞過緩存,持續打到 DB。
解決方案思維導圖(簡化):
緩存并發問題
├── 緩存擊穿 (單個熱點 Key)
│ ├── 互斥鎖/分布式鎖 (推薦,強一致)
│ └── 邏輯過期 (高可用,最終一致)
├── 緩存雪崩 (大量 Key / 服務宕機)
│ ├── 預防 Key 同時過期
│ │ └── 過期時間加隨機值 (推薦)
│ ├── 保證緩存高可用
│ │ └── Redis Sentinel / Cluster (推薦)
│ └── 容災措施
│ ├── 服務限流 (推薦)
│ ├── 服務降級/熔斷 (推薦)
│ └── 多級緩存 / 異步化 (可選)
└── 緩存穿透 (查詢不存在數據)├── 布隆過濾器 (推薦,高效過濾)├── 緩存空值 (常用,簡單直接)└── 接口層校驗 (基礎防線)