解決Redis緩存擊穿問題(互斥鎖、邏輯過期)
背景
緩存擊穿也叫熱點 Key 問題,就是一個被高并發(fā)訪問并且緩存重建業(yè)務較復雜的 key 突然失效了,無數(shù)的請求訪問會在瞬間給數(shù)據(jù)庫帶來巨大的沖擊

常見的解決方案有兩種:
- 1.互斥鎖
- 2.邏輯過期
互斥鎖:
本質(zhì)就是讓所有線程在緩存未命中時,需要先獲取互斥鎖才能從數(shù)據(jù)庫查詢并重建緩存,而未獲取到互斥鎖的,需要不斷循環(huán)查詢緩存、未命中就嘗試獲取互斥鎖的過程。因此這種方式可以讓所有線程返回的數(shù)據(jù)都一定是最新的,但響應速度不高

邏輯過期:
本質(zhì)就是讓熱點 key 在 redis 中永不過期,而通過過期字段來自行判斷該 key 是否過期,如果未過期,則直接返回;如果過期,則需要獲取互斥鎖,并開啟新線程來重建緩存,而原線程可以直接返回舊數(shù)據(jù);如果獲取互斥鎖失敗,就代表已有其他線程正在執(zhí)行緩存重建工作,此時直接返回舊數(shù)據(jù)即可

兩者的對比:
| 解決方案 | 優(yōu)點 | 缺點 |
|---|---|---|
| 互斥鎖 | 沒有額外的內(nèi)存消耗保證一致性實現(xiàn)簡單 | 線程需要等待,性能受影響可能有死鎖風險 |
| 邏輯過期 | 線程無需等待,性能較好 | 不保證一致性有額外內(nèi)存消耗實現(xiàn)復雜 |
代碼實現(xiàn)
前置
這里以根據(jù) id 查詢商品店鋪為案例
實體類
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主鍵
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商鋪名稱
*/
private String name;
/**
* 商鋪類型的id
*/
private Long typeId;
/**
* 商鋪圖片,多個圖片以','隔開
*/
private String images;
/**
* 商圈,例如陸家嘴
*/
private String area;
/**
* 地址
*/
private String address;
/**
* 經(jīng)度
*/
private Double x;
/**
* 維度
*/
private Double y;
/**
* 均價,取整數(shù)
*/
private Long avgPrice;
/**
* 銷量
*/
private Integer sold;
/**
* 評論數(shù)量
*/
private Integer comments;
/**
* 評分,1~5分,乘10保存,避免小數(shù)
*/
private Integer score;
/**
* 營業(yè)時間,例如 10:00-22:00
*/
private String openHours;
/**
* 創(chuàng)建時間
*/
private LocalDateTime createTime;
/**
* 更新時間
*/
private LocalDateTime updateTime;
@TableField(exist = false)
private Double distance;
}
常量類
public class RedisConstants {
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String EXPIRE_KEY = "expire";
}
工具類
public class ObjectMapUtils {
// 將對象轉(zhuǎn)為 Map
public static Map<String, String> obj2Map(Object obj) throws IllegalAccessException {
Map<String, String> result = new HashMap<>();
Class<?> clazz = obj.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
// 如果為 static 且 final 則跳過
if (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) {
continue;
}
field.setAccessible(true); // 設置為可訪問私有字段
Object fieldValue = field.get(obj);
if (fieldValue != null) {
result.put(field.getName(), field.get(obj).toString());
}
}
return result;
}
// 將 Map 轉(zhuǎn)為對象
public static Object map2Obj(Map<Object, Object> map, Class<?> clazz) throws Exception {
Object obj = clazz.getDeclaredConstructor().newInstance();
for (Map.Entry<Object, Object> entry : map.entrySet()) {
Object fieldName = entry.getKey();
Object fieldValue = entry.getValue();
Field field = clazz.getDeclaredField(fieldName.toString());
field.setAccessible(true); // 設置為可訪問私有字段
String fieldValueStr = fieldValue.toString();
// 根據(jù)字段類型進行轉(zhuǎn)換
fillField(obj, field, fieldValueStr);
}
return obj;
}
// 將 Map 轉(zhuǎn)為對象(含排除字段)
public static Object map2Obj(Map<Object, Object> map, Class<?> clazz, String... excludeFields) throws Exception {
Object obj = clazz.getDeclaredConstructor().newInstance();
for (Map.Entry<Object, Object> entry : map.entrySet()) {
Object fieldName = entry.getKey();
if(Arrays.asList(excludeFields).contains(fieldName)) {
continue;
}
Object fieldValue = entry.getValue();
Field field = clazz.getDeclaredField(fieldName.toString());
field.setAccessible(true); // 設置為可訪問私有字段
String fieldValueStr = fieldValue.toString();
// 根據(jù)字段類型進行轉(zhuǎn)換
fillField(obj, field, fieldValueStr);
}
return obj;
}
// 填充字段
private static void fillField(Object obj, Field field, String value) throws IllegalAccessException {
if (field.getType().equals(int.class) || field.getType().equals(Integer.class)) {
field.set(obj, Integer.parseInt(value));
} else if (field.getType().equals(boolean.class) || field.getType().equals(Boolean.class)) {
field.set(obj, Boolean.parseBoolean(value));
} else if (field.getType().equals(double.class) || field.getType().equals(Double.class)) {
field.set(obj, Double.parseDouble(value));
} else if (field.getType().equals(long.class) || field.getType().equals(Long.class)) {
field.set(obj, Long.parseLong(value));
} else if (field.getType().equals(String.class)) {
field.set(obj, value);
} else if(field.getType().equals(LocalDateTime.class)) {
field.set(obj, LocalDateTime.parse(value));
}
}
}
結(jié)果返回類
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
控制層
@RestController
@RequestMapping("/shop")
public class ShopController {
@Resource
public IShopService shopService;
/**
* 根據(jù)id查詢商鋪信息
* @param id 商鋪id
* @return 商鋪詳情數(shù)據(jù)
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryShopById(id);
}
}
互斥鎖方案
流程圖為:

服務層代碼:
public Result queryShopById(Long id) {
Shop shop = queryWithMutex(id);
if(shop == null) {
return Result.fail("店鋪不存在");
}
return Result.ok(shop);
}
// 互斥鎖解決緩存擊穿
public Shop queryWithMutex(Long id) {
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
boolean flag = false;
try {
do {
// 從 redis 查詢
Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
// 緩存命中
if(!entries.isEmpty()) {
try {
// 刷新有效期
redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class);
return shop;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 緩存未命中,嘗試獲取互斥鎖
flag = tryLock(id);
if(flag) { // 獲取成功,進行下一步
break;
}
// 獲取失敗,睡眠后重試
Thread.sleep(50);
} while(true); //未獲取到鎖,休眠后重試
// 查詢數(shù)據(jù)庫
Shop shop = this.getById(id);
if(shop == null) {
// 不存在,直接返回
return null;
}
// 存在,寫入 redis
try {
// 測試,延遲緩存重建過程
/*try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}*/
redisTemplate.opsForHash().putAll(shopKey, ObjectMapUtils.obj2Map(shop));
redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
return shop;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if(flag) { // 獲取了鎖需要釋放
unlock(id);
}
}
}
測試:
這里使用 JMeter 進行測試


運行結(jié)果如下:


可以看到控制臺只有一個查詢數(shù)據(jù)庫的請求,說明互斥鎖生效了
邏輯過期方案
流程圖如下:

采用邏輯過期的方式時,key 是不會過期的,而這里由于是熱點 key,我們默認其是一定存在于 redis 中的(可以做緩存預熱事先加入 redis),因此如果 redis 沒命中,就直接返回空
服務層代碼:
public Result queryShopById(Long id) {
// 邏輯過期解決緩存擊穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店鋪不存在");
}
return Result.ok(shop);
}
// 邏輯過期解決緩存擊穿
private Shop queryWithLogicalExpire(Long id) {
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
// 從 redis 查詢
Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
// 緩存未命中,返回空
if(entries.isEmpty()) {
return null;
}
try {
Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class, RedisConstants.EXPIRE_KEY);
LocalDateTime expire = LocalDateTime.parse(entries.get(RedisConstants.EXPIRE_KEY).toString());
// 判斷緩存是否過期
if(expire.isAfter(LocalDateTime.now())) {
// 未過期則直接返回
return shop;
}
// 過期需要先嘗試獲取互斥鎖
if(tryLock(id)) {
// 獲取成功
// 雙重檢驗
entries = redisTemplate.opsForHash().entries(shopKey);
shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class, RedisConstants.EXPIRE_KEY);
expire = LocalDateTime.parse(entries.get(RedisConstants.EXPIRE_KEY).toString());
if(expire.isAfter(LocalDateTime.now())) {
// 未過期則直接返回
unlock(id);
return shop;
}
// 通過線程池完成重建緩存任務
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
rebuildCache(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(id);
}
});
}
return shop;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 嘗試加鎖
private boolean tryLock(Long id) {
Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(RedisConstants.LOCK_SHOP_KEY + id,
"1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(isLocked);
}
// 解鎖
private void unlock(Long id) {
redisTemplate.delete(RedisConstants.LOCK_SHOP_KEY + id);
}
// 重建緩存
private void rebuildCache(Long id, Long expireTime) throws IllegalAccessException {
Shop shop = this.getById(id);
Map<String, String> map = ObjectMapUtils.obj2Map(shop);
// 添加邏輯過期時間
map.put(RedisConstants.EXPIRE_KEY, LocalDateTime.now().plusMinutes(expireTime).toString());
redisTemplate.opsForHash().putAll(RedisConstants.CACHE_SHOP_KEY + id, map);
}
測試:
這里先預熱,將 id 為 1 的數(shù)據(jù)加入,并且讓過期字段為過去的時間,即表示此數(shù)據(jù)已過期

然后將數(shù)據(jù)庫中對應的 name 由 “101茶餐廳” 改為 “103茶餐廳”
然后使用 JMeter 測試


測試結(jié)果:


可以看到部分結(jié)果返回的舊數(shù)據(jù),而部分結(jié)果返回的是新數(shù)據(jù)
且 redis 中的數(shù)據(jù)也已經(jīng)更新

并且,系統(tǒng)中只有一條查詢數(shù)據(jù)庫的請求

總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
Redis String 類型和 Hash 類型學習筆記與總結(jié)
這篇文章主要介紹了Redis String 類型和 Hash 類型學習筆記與總結(jié),本文分別對String 類型的一些方法和Hash 類型做了詳細介紹,需要的朋友可以參考下2015-06-06

