一、什么是緩存
緩存(Cache)是數據交換的緩沖區,是存儲數據的臨時地方,一般讀寫性能較高。
(一)緩存的作用
- 降低后端負載:減少對數據庫等后端存儲的直接訪問壓力。
- 提高讀寫效率,降低響應時間:利用緩存的高性能,快速響應數據請求。
(二)緩存的成本
- 數據一致性成本:緩存與后端數據可能存在不一致,維護一致性需要額外處理。
- 代碼維護成本:引入緩存后,代碼邏輯會更復雜,增加維護難度。
- 運維成本:緩存系統的部署、監控、擴容等都需要投入運維資源。
二、緩存更新策略
策略 | 說明 | 一致性 | 維護成本 |
---|---|---|---|
內存淘汰 | 利用Redis的內存淘汰機制,內存不足時自動淘汰部分數據,下次查詢時更新緩存 | 差 | 無 |
超時剔除 | 給緩存數據添加TTL時間,到期后自動刪除緩存,下次查詢時更新緩存 | 一般 | 低 |
主動更新 | 編寫業務邏輯,在修改數據庫的同時更新緩存 | 好 | 高 |
業務場景
- 低一致性需求:使用內存淘汰機制,例如店鋪類型的查詢緩存。
- 高一致性需求:主動更新,并以超時剔除作為兜底方案,例如店鋪詳情查詢的緩存。
代碼實現:主動更新策略
@Override
@Transactional
public Result updateShop(Shop shop) {Long id = shop.getId();if (id == null) {return Result.fail("商鋪ID不能為空");}// 1.先更新數據庫updateById(shop);// 2.再刪除緩存(保證數據一致性)stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());return Result.ok();
}
三、緩存更新策略的最佳實踐方案
(一)低一致性需求
使用Redis自帶的內存淘汰機制。
(二)高一致性需求
主動更新,并以超時剔除作為兜底方案。
- 讀操作:
- 緩存命中則直接返回。
- 緩存未命中則查詢數據庫,并寫入緩存,設定超時時間。
- 寫操作:
- 先寫數據庫,然后再刪除緩存。
- 要確保數據庫與緩存操作的原子性。
代碼實現:基礎緩存讀寫邏輯
private Result queryShopById1(Long id) {// 1.查詢RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.緩存命中直接返回if (StrUtil.isNotBlank(cacheShop)) {Shop shop = JSONUtil.toBean(cacheShop, Shop.class);return Result.ok(shop);}// 3.緩存未命中查詢數據庫Shop shop = getById(id);if (shop == null) {return Result.fail("商鋪不存在!");}// 4.數據庫查詢結果寫入Redis,設置超時時間stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}
四、緩存問題及解決方案
(一)緩存穿透
問題定義
客戶端請求的數據在緩存中和數據庫中都不存在,這樣緩存永遠不會生效,這些請求都會打到數據庫。
問題場景
- 惡意攻擊:黑客故意構造大量不存在的ID(如負數、超范圍的隨機數)發起請求,試圖耗盡數據庫資源
- 業務誤操作:前端表單未做校驗,用戶輸入了無效ID導致大量無效查詢
- 數據已刪除:商品已下架或用戶已注銷,但仍有請求訪問這些已不存在的數據
- 爬蟲抓取:爬蟲程序遍歷不存在的URL路徑,導致大量無效查詢
解決方案
1. 緩存空對象
原理:當數據庫查詢結果為空時,仍然將這個空結果緩存起來(可以是空字符串、null或特定標識),并設置較短的過期時間,避免同一無效請求反復穿透到數據庫。
實現代碼:
private Result queryShopById2(Long id) {// 1.查詢RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.緩存命中(包括空值)if (StrUtil.isNotBlank(cacheShop)) {// 2.1 空值判斷if (cacheShop.isEmpty()) {return Result.fail("商鋪不存在!");}// 2.2 正常數據返回Shop shop = JSONUtil.toBean(cacheShop, Shop.class);return Result.ok(shop);}// 3.數據庫查詢Shop shop = getById(id);if (shop == null) {// 3.1 數據庫不存在則緩存空值(短期有效)stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("商鋪不存在!");}// 4.正常數據寫入緩存stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}
優缺點:
- 優點:實現簡單,維護方便,能有效攔截重復的無效請求
- 缺點:
- 占用額外內存空間存儲空值
- 可能造成短期數據不一致(如剛刪除的數據又被創建)
- 對于海量不同的無效ID,仍可能消耗大量緩存空間
2. 布隆過濾器
原理:在緩存之前增加一層布隆過濾器,預先將數據庫中所有存在的Key存入布隆過濾器。當請求進來時,先通過布隆過濾器判斷Key是否可能存在:
- 若不存在,則直接返回,無需訪問緩存和數據庫
- 若可能存在,再走正常的緩存+數據庫查詢流程
實現思路:
- 系統初始化時,將數據庫中所有有效ID加載到布隆過濾器
- 接收請求時,先通過布隆過濾器驗證ID有效性
- 對布隆過濾器判斷不存在的ID,直接返回錯誤
優缺點:
- 優點:內存占用少(相比緩存空對象),處理海量無效ID效率高
- 缺點:
- 實現復雜,需要維護布隆過濾器
- 存在誤判可能(不能準確判斷元素是否存在)
- 需要定期更新布隆過濾器數據,以反映數據庫變化
(二)緩存雪崩
問題定義
在同一時段大量的緩存key同時失效或者Redis服務宕機,導致大量請求到達數據庫,帶來巨大壓力,甚至引起數據庫宕機。
問題場景
- 集中過期:系統上線時為一批熱點數據設置了相同的過期時間(如24小時),導致24小時后這批數據同時失效
- Redis宕機:Redis服務器因硬件故障、網絡問題或內存溢出等原因突然宕機,整個緩存層失效
- 批量更新:電商平臺在大促前批量更新商品信息,導致大量緩存被刪除
- 緩存穿透引發:大量穿透請求導致數據庫壓力過大,進而影響緩存服務的正常運行
解決方案
1. 過期時間隨機化
原理:為不同的緩存Key設置基礎過期時間的同時,增加一個隨機偏移量(如±10%),避免大量Key在同一時間點同時過期。
實現代碼:
// 設置過期時間時增加隨機值
int baseTtl = 30; // 基礎30分鐘
int random = new Random().nextInt(10); // 0-9分鐘隨機
stringRedisTemplate.opsForValue().set(key, value, baseTtl + random, TimeUnit.MINUTES
);
2. Redis集群
原理:通過部署Redis集群(主從+哨兵或Redis Cluster)提高緩存服務的可用性,避免單點故障導致整個緩存層失效。
關鍵措施:
- 主從復制:實現數據備份和讀寫分離
- 哨兵機制:自動監控和故障轉移
- 集群分片:分散數據存儲,提高整體容量和性能
3. 降級限流
原理:當緩存服務出現異常時,通過降級策略限制對數據庫的請求流量,保護數據庫不被壓垮。
實現方式:
- 使用熔斷器(如Sentinel、Hystrix)監控緩存服務狀態
- 當緩存服務異常時,返回默認數據或提示信息,而非直接查詢數據庫
- 對查詢數據庫的請求設置限流閾值,超出閾值的請求直接拒絕
4. 多級緩存
原理:構建多級緩存架構(如本地緩存+分布式緩存),即使分布式緩存失效,本地緩存仍能提供部分緩沖能力。
常見架構:
- 本地緩存(Caffeine、Guava):存在于應用進程內,速度最快
- 分布式緩存(Redis):供多個應用實例共享
- 數據庫緩存:數據庫自身的緩存機制
(三)緩存擊穿
問題定義
也叫熱點Key問題,指一個被高并發訪問的熱點數據的緩存突然失效,無數請求會在瞬間同時到達數據庫,給數據庫帶來巨大沖擊。
問題場景
- 熱門商品:電商平臺的爆款商品緩存過期,恰逢促銷活動期間,大量用戶同時訪問
- 熱點事件:突發新聞事件的相關數據緩存過期,引發大量用戶同時查詢
- 排行榜:熱門游戲排行榜數據緩存過期,大量玩家同時刷新頁面
- 秒殺活動:秒殺商品的緩存過期,大量用戶在同一時間點搶購
解決方案
1. 互斥鎖
原理:當緩存失效時,不是讓所有請求都去查詢數據庫,而是通過鎖機制保證只有一個請求能去查詢數據庫并重建緩存,其他請求則等待緩存重建完成后再從緩存獲取數據。
實現代碼:
private Result queryShopById3(Long id) {// 1.查詢RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StrUtil.isNotBlank(cacheShop)) {if (cacheShop.isEmpty()) {return Result.fail("商鋪不存在!");}return Result.ok(JSONUtil.toBean(cacheShop, Shop.class));}Shop shop = null;try {// 2.獲取互斥鎖boolean isLock = tryLock(LOCK_SHOP_KEY + id);if (!isLock) {// 2.1 獲取鎖失敗則重試(短暫等待后再次查詢)Thread.sleep(50);return queryShopById3(id);}// 3.獲取鎖成功,查詢數據庫shop = getById(id);// 模擬緩存重建耗時操作Thread.sleep(200);if (shop == null) {stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("商鋪不存在!");}// 4.寫入緩存stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 5.釋放鎖unlock(LOCK_SHOP_KEY + id);}return Result.ok(shop);
}// 獲取鎖(使用Redis的setIfAbsent實現)
private boolean tryLock(String key) {return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 100, TimeUnit.SECONDS));
}// 釋放鎖
private void unlock(String key) {stringRedisTemplate.delete(key);
}
優缺點:
- 優點:
- 保證數據一致性(每次都是最新數據)
- 無額外內存消耗
- 實現相對簡單
- 缺點:
- 鎖競爭會導致請求等待,影響接口響應性能
- 可能存在死鎖風險(需設置合理的鎖過期時間)
- 高并發下,第一個獲取鎖的請求可能成為性能瓶頸
2. 邏輯過期
原理:給緩存數據設置一個邏輯過期時間(而非Redis的實際過期時間),緩存永不過期。當查詢時發現數據已過邏輯過期時間,不直接刪除緩存,而是返回舊數據并異步更新緩存,保證后續請求能盡快獲取到新數據。
實現代碼:
// 線程池(用于異步重建緩存)
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);// 邏輯過期數據封裝類
@Data
public class RedisData {// 邏輯過期時間private LocalDateTime expireTime;// 實際存儲的數據(如Shop對象)private Object data;
}private Result queryShopById4(Long id) {// 1.查詢RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StrUtil.isBlank(cacheShop)) {return Result.fail("商鋪不存在!");}if (cacheShop.isEmpty()) {return Result.fail("商鋪不存在!");}// 2.解析緩存數據(帶邏輯過期時間)RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);JSONObject data = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(data, Shop.class);LocalDateTime expireTime = redisData.getExpireTime();// 3.判斷是否邏輯過期if (expireTime.isAfter(LocalDateTime.now())) {// 3.1 未過期直接返回return Result.ok(shop);}// 3.2 已過期,嘗試獲取鎖重建緩存boolean isLock = tryLock(LOCK_SHOP_KEY + id);if (isLock) {// 3.2.1 獲取鎖成功,異步重建緩存(不阻塞當前請求)CACHE_REBUILD_EXECUTOR.submit(() -> {try {saveShop2Redis(id, CACHE_SHOP_TTL);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {unlock(LOCK_SHOP_KEY + id);}});}// 3.2.2 無論是否獲取鎖,都返回舊數據(保證用戶體驗)return Result.ok(shop);
}// 封裝邏輯過期數據并寫入Redis
public void saveShop2Redis(long id, Long seconds) throws InterruptedException {Shop shop = getById(id);// 模擬緩存重建延時Thread.sleep(200);RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
優缺點:
- 優點:
- 無請求阻塞,性能好(始終返回數據,不等待)
- 避免大量請求同時沖擊數據庫
- 缺點:
- 數據可能短期不一致(返回舊數據)
- 需要額外存儲過期時間,占用更多內存
- 實現相對復雜,需要處理異步更新邏輯
五、緩存常量定義
public class RedisConstants {// 商鋪緩存鍵前綴public static final String CACHE_SHOP_KEY = "cache:shop:";// 商鋪緩存默認過期時間(30分鐘)public static final Long CACHE_SHOP_TTL = 30L;// 空值緩存過期時間(2分鐘,用于解決緩存穿透)public static final Long CACHE_NULL_TTL = 2L;// 商鋪緩存鎖鍵前綴(用于解決緩存擊穿)public static final String LOCK_SHOP_KEY = "lock:shop:";// 鎖過期時間(10秒)public static final Long LOCK_SHOP_TTL = 10L;
}
六、緩存方案對比
方案 | 解決問題 | 優點 | 缺點 | 適用場景 |
---|---|---|---|---|
基礎緩存 | 無 | 實現簡單 | 存在緩存穿透、擊穿問題 | 低并發、非核心業務 |
緩存空值 | 緩存穿透 | 防止數據庫壓力過大 | 占用額外緩存空間 | 無效請求相對集中的場景 |
布隆過濾 | 緩存穿透 | 內存占用少 | 有誤判、實現復雜 | 海量無效ID場景 |
互斥鎖 | 緩存擊穿 | 數據一致性好 | 可能阻塞請求 | 一致性要求高的熱點數據 |
邏輯過期 | 緩存擊穿 | 無阻塞,性能好 | 可能返回舊數據 | 高并發、一致性要求不高 |
隨機TTL | 緩存雪崩 | 實現簡單 | 只能解決集中過期問題 | 所有需要設置過期的場景 |
多級緩存 | 緩存雪崩 | 提高可用性和性能 | 實現復雜、維護成本高 | 核心業務、高可用要求 |
參考來源
本文內容基于黑馬程序員《Redis入門到實戰教程》相關章節學習整理,部分代碼示例與知識點解析參考了該課程的講解。