功能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對應的快取重新設定。