快取穿透(cache penetration)是指使用者存取的資料既不在快取當中,也不在資料庫中。出於容錯的考慮,如果從底層資料庫查詢不到資料,則不寫入快取。這就導致每次請求都會到底層資料庫進行查詢,快取也失去了意義。當高並行或有人利用不存在的Key頻繁攻擊時,資料庫的壓力驟增,甚至崩潰,這就是快取穿透問題。
簡單地說,快取穿透是指使用者請求的資料在快取和資料庫中都不存在,則每次請求都會打到資料庫中,給資料庫帶來巨大壓力。
之後再存取該資料,只要redis的空值對沒有過期,就不會存取到資料庫,從而起到保護資料庫的作用。
當查詢店鋪id時,可能會出現該店鋪id對應的快取失效,從而大量請求傳送到資料庫的情況,這裡使用兩種方案分別解決該問題。
修改根據id查詢商鋪的業務,基於互斥鎖方式來解決快取擊穿問題。
如下,當出現快取擊穿問題,首先需要判斷當前的執行緒是否能夠獲取鎖:
根據redis的setnx命令,當setnx設定某個key之後,如果該key存在,則其他執行緒無法設定該key。
我們可以根據這個特性,作為一個lock的邏輯標誌,當一個執行緒setnx某個key後,代表獲取了「鎖」。當刪除這個key時,代表釋放「鎖」,這樣其他執行緒就可以重新獲取「鎖」。此外,可以對該key設定一個有效期,防止刪除key失敗,產生「死鎖」。
(1)修改 ShopServiceImpl.java
package com.hmdp.service.impl;
import ...
/**
* 服務實現類
*
* @author 李
* @version 1.0
*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop>
implements IShopService {
@Resource
StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店鋪不存在!");
}
return Result.ok(shop);
}
//快取穿透(儲存空物件)+快取擊穿解決(互斥鎖解決)
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//從redis中查詢商鋪快取
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判斷快取是否命中
if (StrUtil.isNotBlank(shopJson)) {
//命中,直接返回商鋪資訊
return JSONUtil.toBean(shopJson, Shop.class);
}
//判斷命中的是否是redis的空值(快取擊穿解決)
if (shopJson != null) {
return null;
}
//未命中,嘗試獲取互斥鎖
String lockKey = "lock:shop:" + id;
boolean isLock = false;
Shop shop = null;
try {
//獲取互斥鎖
isLock = tryLock(lockKey);
//判斷是否獲取成功
if (!isLock) {//失敗
//等待並重試
Thread.sleep(50);
//直到快取命中,或者獲取到鎖
return queryWithMutex(id);
}
//獲取鎖成功,開始重建快取
//根據id查詢資料庫,判斷商鋪是否存在資料庫中
shop = getById(id);
//模擬重建快取的延遲-----------
Thread.sleep(200);
if (shop == null) {
//不存在,防止快取穿透,將空值存入redis,TTL設定為2min
stringRedisTemplate.opsForValue().set(key, "",
CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回錯誤資訊
return null;
}
//存在,則將商鋪資料寫入redis中
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//釋放互斥鎖
unLock(lockKey);
}
//返回從快取或資料庫中查到的資料
return shop;
}
//快取穿透方案
// public Shop queryWithPassThrough(Long id) {
// String key = CACHE_SHOP_KEY + id;
// //1.從redis中查詢商鋪快取
// String shopJson = stringRedisTemplate.opsForValue().get(key);
// //2.判斷快取是否命中
// if (StrUtil.isNotBlank(shopJson)) {
// //2.1若命中,直接返回商鋪資訊
// return JSONUtil.toBean(shopJson, Shop.class);
// }
// //判斷命中的是否是redis的空值
// if (shopJson != null) {
// return null;
// }
// //2.2未命中,根據id查詢資料庫,判斷商鋪是否存在資料庫中
// Shop shop = getById(id);
// if (shop == null) {
// //2.2.1不存在,防止快取穿透,將空值存入redis,TTL設定為2min
// stringRedisTemplate.opsForValue().set(key, "",
// CACHE_NULL_TTL, TimeUnit.MINUTES);
// //返回錯誤資訊
// return null;
// }
// //2.2.2存在,則將商鋪資料寫入redis中
// stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
// CACHE_SHOP_TTL, TimeUnit.MINUTES);
// return shop;
// }
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店鋪id不能為空");
}
//1.更新資料庫
updateById(shop);
//2.刪除redis快取
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
}
(2)使用jemeter模擬高並行的情況:
5秒發起1000個請求執行緒:
模擬http請求:
全部請求成功,獲取到資料:
在伺服器的控制檯中可以看到:對於資料庫的請求只觸發了一次,證明在高並行的場景下,只有一個執行緒對資料庫發起請求,並對redis對應的快取重新設定。