功能02-商鋪查詢緩存02 知識補充 (1)緩存穿透 https://blog.csdn.net/qq_45637260/article/details/125866738 緩存穿透(cache penetration)是指用戶訪問的數據既不在緩存當中,也不在資料庫中。出於容錯的考慮,如果從底層數據 ...
功能02-商鋪查詢緩存02
知識補充
(1)緩存穿透
緩存穿透(cache penetration)是指用戶訪問的數據既不在緩存當中,也不在資料庫中。出於容錯的考慮,如果從底層資料庫查詢不到數據,則不寫入緩存。這就導致每次請求都會到底層資料庫進行查詢,緩存也失去了意義。當高併發或有人利用不存在的Key頻繁攻擊時,資料庫的壓力驟增,甚至崩潰,這就是緩存穿透問題。
簡單地說,緩存穿透是指用戶請求的數據在緩存和資料庫中都不存在,則每次請求都會打到資料庫中,給資料庫帶來巨大壓力。
常見的兩種解決方案
(1)緩存空對象:是指在持久層沒有命中的情況下,對key進行set (key,null)。
緩存空對象會有兩個問題:
-
value為null 不代表不占用記憶體空間,空值做了緩存,意味著緩存層中存了更多的鍵,需要更多的記憶體空間,比較有效的方法是針對這類數據設置一個較短的過期時間,讓其自動剔除。
-
緩存層和存儲層的數據會有一段時間視窗的不一致,可能會對業務有一定影響。例如過期時間設置為5分鐘,如果此時存儲層添加了這個數據,那此段時間就會出現緩存層和存儲層數據的不一致,此時可以利用消息系統或者其他方式清除掉緩存層中的空對象。
(2)布隆過濾器:
在訪問緩存層和存儲層之前,將存在的key用布隆過濾器提前保存起來,做第一層攔截,當收到一個對key請求時,先用布隆過濾器驗證是key否存在,如果存在再進入緩存層、存儲層。
可以使用bitmap做布隆過濾器。這種方法適用於數據命中不高、數據相對固定、實時性低的應用場景,代碼維護較為複雜,但是緩存空間占用少。
布隆過濾器實際上是一個很長的二進位向量和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的演算法,缺點是有一定的誤識別率和刪除困難。
布隆過濾器攔截的演算法描述:
初始狀態時,BloomFilter是一個長度為m的位數組,每一位都置為0。添加元素x時,x使用k個hash函數得到k個hash值,對m取餘,對應的bit位設置為1。
判斷y是否屬於這個集合,對y使用k個哈希函數得到k個哈希值,對m取餘,所有對應的位置都是1,則認為y屬於該集合(哈希衝突,可能存在誤判),否則就認為y不屬於該集合。可以通過增加哈希函數和增加二進位位數組的長度來降低錯報率
兩種方案的比較:
緩存穿透的方案 | 使用場景 | 維護成本 |
---|---|---|
緩存空對象 | 1.數據命中率不高 2.數據頻繁變化實時性高 | 1.代碼維護簡單 2.需要過多的緩存空間 3.數據不一致 |
布隆過濾器 | 1.數據命中不高 2.數據相對固定實時性低 | 1.代碼維護複雜 2.緩存空間占用少 |
緩存穿透的解決方案還有:
(2)緩存雪崩
緩存雪崩
在使用緩存時,通常會對緩存設置過期時間,一方面目的是保持緩存與資料庫數據的一致性,另一方面是減少冷緩存占用過多的記憶體空間。但當緩存中大量熱點緩存採用了相同的實效時間,就會導致緩存在某一個時刻同時實效,請求全部轉發到資料庫,從而導致資料庫壓力驟增,甚至宕機。從而形成一系列的連鎖反應,造成系統崩潰等情況,這就是緩存雪崩(Cache Avalanche)。
簡單地說,緩存雪崩是指在同一時間段大量的熱點key同時失效,或者Redis服務宕機,導致大量請求到達資料庫,給資料庫帶來巨大壓力。
解決方案
- 給不同的key的TTL添加隨機值(比如隨機1-5分鐘),讓key均勻地失效
- 利用redis集群提高服務的可用性(提高高可用性)
- 給緩存業務添加熔斷、降級、限流策略
- 給業務添加多級緩存
(3)緩存擊穿
緩存擊穿
如果有一個熱點key,在不停的扛著大併發,在這個key失效的瞬間,持續的大併發請求就會擊破緩存,直接請求到資料庫,好像蠻力擊穿一樣。這種情況就是緩存擊穿(Cache Breakdown)。
緩存擊穿問題也叫做熱點key問題,簡單來說,就是一個被高併發訪問並且緩存重建業務較複雜的key突然失效了,無數的請求訪問在瞬間給資料庫帶來巨大的衝擊。
從定義上可以看出,緩存擊穿和緩存雪崩很類似,只不過是緩存擊穿是一個熱點key失效,而緩存雪崩是大量熱點key失效。因此,可以將緩存擊穿看作是緩存雪崩的一個子集。
解決方案
方案一:使用互斥鎖(Mutex Key),只讓一個線程構建緩存,其他線程等待構建緩存執行完畢,重新從緩存中獲取數據。單機通過synchronized或lock來處理,分散式環境採用分散式鎖。
方案二:邏輯過期。熱點數據不設置過期時間,只在value中設置邏輯上的過期時間。後臺非同步更新緩存,適用於不嚴格要求緩存一致性的場景。
兩種方案的對比:
3.功能02-商鋪查詢緩存
3.4查詢商鋪id的緩存穿透問題
3.4.3需求分析
解決查詢商鋪查詢可能存在的緩存穿透問題:當訪問不存在的店鋪時,請求會直接打到資料庫上,並且redis緩存永遠不會生效。
這裡使用緩存空對象的方式來解決。
3.4.4代碼實現
(1)修改ShopServiceImpl.java的queryById方法
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.從redis中查詢商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷緩存是否命中
if (StrUtil.isNotBlank(shopJson)) {
//2.1若命中,直接返回商鋪信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判斷命中的是否是redis的空值
if (shopJson != null) {
return Result.fail("店鋪不存在!");
}
//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 Result.fail("店鋪不存在!");
}
//2.2.2存在,則將商鋪數據寫入redis中
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
(2)測試,訪問一個緩存和資料庫都不存在的數據:
可以看到redis已經緩存了一個空值
之後再訪問該數據,只要redis的空值對沒有過期,就不會訪問到資料庫,從而起到保護資料庫的作用。
3.5查詢商鋪id的緩存擊穿問題
當查詢店鋪id時,可能會出現該店鋪id對應的緩存失效,從而大量請求發送到資料庫的情況,這裡使用兩種方案分別解決該問題。
3.5.1基於互斥鎖方案解決
3.5.1.1需求分析
修改根據id查詢商鋪的業務,基於互斥鎖方式來解決緩存擊穿問題。
如下,當出現緩存擊穿問題,首先需要判斷當前的線程是否能夠獲取鎖:
- 若可以,則進行緩存重建(將資料庫數據重新寫入緩存中),然後釋放鎖。
- 如果不能,則線程等待一段時間,然後再判斷緩存是否能命中。
- 如果未命中,則重覆獲取鎖的流程,直到緩存命中,或者獲得鎖,重建緩存。
根據redis的setnx命令,當setnx設置某個key之後,如果該key存在,則其他線程無法設置該key。
我們可以根據這個特性,作為一個lock的邏輯標誌,當一個線程setnx某個key後,代表獲取了“鎖”。當刪除這個key時,代表釋放“鎖”,這樣其他線程就可以重新獲取“鎖”。此外,可以對該key設置一個有效期,防止刪除key失敗,產生“死鎖”。
3.5.1.2代碼實現
(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對應的緩存重新設置。