第二部分:緩存擊穿——熱點key過期引發的“DB瞬間高壓”
緩存擊穿的本質是“某個熱點key(高并發訪問)突然過期”,導致大量請求在同一時間穿透緩存,集中沖擊DB,形成“瞬間高壓”。
案例3:電商秒殺的“庫存超賣”驚魂
故障現場
某電商平臺“618”秒殺活動中,一款限量1000臺的手機采用“Redis緩存+MySQL”架構:
- 緩存key:
seckill:stock:1001
(存儲庫存數量),過期時間1小時; - 流程:查詢緩存→未命中則查DB→扣減庫存→更新緩存。
- 故障:活動開始1小時后,緩存key恰好過期,此時2000+用戶同時刷新頁面,緩存未命中,所有請求直達MySQL查詢庫存。MySQL因瞬間高并發(2000QPS)出現鎖等待,庫存更新延遲,最終超賣50臺。
根因解剖
- 熱點key(
seckill:stock:1001
)過期瞬間,2000+并發請求穿透至MySQL; - MySQL查詢庫存時加行鎖(
SELECT stock FROM seckill WHERE item_id=1001 FOR UPDATE
),并發請求排隊等待,導致庫存更新延遲; - 前端未做防重放處理,用戶多次刷新加劇并發。
三重防御方案落地
方案1:熱點數據“邏輯永不過期”
核心邏輯:緩存不設置物理過期時間,而是在value中嵌入“邏輯過期時間”。當邏輯過期時,不直接刪除緩存,而是通過后臺線程異步更新,當前請求仍返回舊數據。
優勢:徹底避免過期瞬間的并發穿透。
實戰代碼:
@Service
public class SeckillStockService {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate SeckillMapper seckillMapper;// 線程池:處理緩存異步更新private final ExecutorService updatePool = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS,new LinkedBlockingQueue<>(100),new ThreadPoolExecutor.CallerRunsPolicy());// 緩存數據模型(含邏輯過期時間)@Datastatic class StockCache {private Integer stock; // 庫存數量private long expireTime; // 邏輯過期時間(毫秒)}/*** 查詢秒殺庫存(邏輯永不過期)*/public Integer getStock(Long itemId) {String cacheKey = "seckill:stock:" + itemId;// 1. 查詢緩存String cacheVal = redisTemplate.opsForValue().get(cacheKey);if (cacheVal == null) {// 2. 緩存未命中(首次加載):加鎖查詢DB并初始化return loadStockWithLock(itemId, cacheKey);}// 3. 解析緩存數據StockCache cache = JSON.parseObject(cacheVal, StockCache.class);// 4. 邏輯未過期:直接返回if (System.currentTimeMillis() < cache.getExpireTime()) {return cache.getStock();}// 5. 邏輯已過期:異步更新緩存,當前請求返回舊數據updatePool.submit(() -> refreshStockCache(itemId, cacheKey));return cache.getStock();}// 加鎖加載庫存(防止緩存擊穿)private Integer loadStockWithLock(Long itemId, String cacheKey) {// 使用Redisson分布式鎖RLock lock = redissonClient.getLock("lock:seckill:stock:" + itemId);try {// 最多等待100ms,持有鎖5秒if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {// 雙重檢查:防止重復加載String cacheVal = redisTemplate.opsForValue().get(cacheKey);if (cacheVal != null) {return JSON.parseObject(cacheVal, StockCache.class).getStock();}// 查詢DB并初始化緩存(邏輯過期1小時)Integer stock = seckillMapper.selectStock(itemId);StockCache cache = new StockCache();cache.setStock(stock);cache.setExpireTime(System.currentTimeMillis() + 3600 * 1000);redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));return stock;} else {// 獲取鎖失敗:返回DB查詢結果(兜底)return seckillMapper.selectStock(itemId);}} finally {if (lock.isHeldByCurrentThread()) {lock.unlock();}}}// 刷新緩存(異步執行)private void refreshStockCache(Long itemId, String cacheKey) {RLock lock = redissonClient.getLock("lock:seckill:stock:" + itemId);try {// 加鎖防止并發更新if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {Integer newStock = seckillMapper.selectStock(itemId);StockCache cache = new StockCache();cache.setStock(newStock);cache.setExpireTime(System.currentTimeMillis() + 3600 * 1000);redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));}} finally {if (lock.isHeldByCurrentThread()) {lock.unlock();}}}
}
時序圖:
正常請求(未過期):
[用戶] → 查緩存 → 命中(未過期)→ 返回結果過期請求(異步更新):
[用戶] → 查緩存 → 命中(已過期)→ 返回舊數據↓異步線程更新緩存(加鎖)
實戰效果:緩存過期時無請求穿透至DB,MySQL查詢量穩定在50QPS以內,超賣問題徹底解決。
方案2:分布式鎖“串行化”查詢
核心邏輯:熱點key過期時,通過分布式鎖保證只有一個線程能查詢DB并更新緩存,其他線程等待重試。
適用場景:數據實時性要求高,無法接受舊數據。
實戰代碼(Redisson實現):
@Service
public class HotItemService {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate RedissonClient redissonClient;@Autowiredprivate ItemMapper itemMapper;/*** 查詢熱點商品詳情(分布式鎖防擊穿)*/public ItemDTO getHotItem(Long itemId) {String cacheKey = "item:hot:" + itemId;// 1. 查詢緩存String cacheVal = redisTemplate.opsForValue().get(cacheKey);if (cacheVal != null) {return JSON.parseObject(cacheVal, ItemDTO.class);}// 2. 緩存未命中:加分布式鎖RLock lock = redissonClient.getLock("lock:item:hot:" + itemId);try {// 最多等待500ms,持有鎖3秒if (lock.tryLock(500, 3000, TimeUnit.MILLISECONDS)) {// 雙重檢查:防止鎖等待期間已更新緩存cacheVal = redisTemplate.opsForValue().get(cacheKey);if (cacheVal != null) {return JSON.parseObject(cacheVal, ItemDTO.class);}// 3. 查詢DB并更新緩存(設置過期時間30分鐘)ItemDTO item = itemMapper.selectById(itemId);redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(item), 30, TimeUnit.MINUTES);return item;} else {// 4. 獲取鎖失敗:重試(最多3次)for (int i = 0; i < 3; i++) {Thread.sleep(50); // 短暫等待cacheVal = redisTemplate.opsForValue().get(cacheKey);if (cacheVal != null) {return JSON.parseObject(cacheVal, ItemDTO.class);}}// 重試失敗:返回DB結果(兜底)return itemMapper.selectById(itemId);}} finally {if (lock.isHeldByCurrentThread()) {lock.unlock();}}}
}
實戰效果:熱點key過期時,僅1個線程查詢DB,其他線程從緩存獲取,MySQL峰值QPS從2000降至5,接口響應時間從500ms降至50ms。
方案3:熔斷降級(極端情況保護)
核心邏輯:當DB壓力過大時,通過熔斷組件(如Resilience4j)臨時返回緩存舊值或默認值,避免DB被壓垮。
實戰代碼(Resilience4j配置):
@Configuration
public class CircuitBreakerConfig {@Beanpublic CircuitBreakerRegistry circuitBreakerRegistry() {CircuitBreakerConfig config = CircuitBreakerConfig.custom().failureRateThreshold(50) // 失敗率超50%觸發熔斷.waitDurationInOpenState(Duration.ofSeconds(10)) // 熔斷10秒.permittedNumberOfCallsInHalfOpenState(5) // 半開狀態允許5次調用.slidingWindowSize(100) // 滑動窗口大小100.build();return CircuitBreakerRegistry.of(config);}
}@Service
public class ItemService {@Autowiredprivate CircuitBreakerRegistry circuitBreakerRegistry;@Autowiredprivate ItemMapper itemMapper;@Autowiredprivate StringRedisTemplate redisTemplate;/*** 帶熔斷的DB查詢(兜底方案)*/public ItemDTO queryFromDBWithFallback(Long itemId) {CircuitBreaker breaker = circuitBreakerRegistry.circuitBreaker("itemDBQuery");// 包裝DB查詢方法,配置熔斷降級return Try.ofSupplier(CircuitBreaker.decorateSupplier(breaker, () -> itemMapper.selectById(itemId))).recover(Exception.class, e -> {log.warn("DB查詢熔斷,使用緩存舊值,itemId={}", itemId, e);// 熔斷時返回緩存舊值(即使過期)String oldVal = redisTemplate.opsForValue().get("item:hot:" + itemId);return oldVal != null ? JSON.parseObject(oldVal, ItemDTO.class) : buildDefaultItem(itemId);}).get();}// 構建默認商品(極端降級)private ItemDTO buildDefaultItem(Long itemId) {ItemDTO defaultItem = new ItemDTO();defaultItem.setId(itemId);defaultItem.setName("商品信息加載中");return defaultItem;}
}
實戰效果:DB壓力過大時自動熔斷,返回緩存舊值,接口成功率保持99.9%,無服務雪崩。
擊穿防御總結
方案 | 適用場景 | 優點 | 缺點 | 實施成本 |
---|---|---|---|---|
邏輯永不過期 | 實時性要求不高 | 無并發穿透,性能好 | 可能返回舊數據 | 中 |
分布式鎖 | 實時性要求高 | 數據一致,實現簡單 | 鎖競爭可能導致延遲 | 中 |
熔斷降級 | 極端流量保護 | 兜底保障,防止DB雪崩 | 影響用戶體驗 | 低 |