Redis 緩衝問題詳解
緩衝穿透
緩衝穿透是指用戶端請求的資料在緩衝和資料庫中都不存在,因此緩衝永遠不會生效,這些請求都會直接打到資料庫。
如果惡意使用者利用無數線程並發訪問不存在的資料,這些請求都會到達資料庫,很可能會導致資料庫崩潰。
解決方案
緩衝Null 物件
思路:當使用者請求一個 id 時,Redis 和資料庫都不存在該資料,我們直接將 id 對應的空值緩衝到 Redis,這樣下次使用者再重複請求這個 id 時,Redis 就能命中(命中空值),就不會去查資料庫了。
優點:實現簡單,易於維護
缺點:
額外的記憶體消耗(可通過添加 TTL 解決)
- 可能引起短時不一致(控制 TTL 時間可在一定程度上緩解):緩衝了空值後,我們剛好在資料庫中設定了該值,使用者查詢到的是空值,但資料庫中實際存在該資料,這就造成了不一致(可通過插入資料時自動覆蓋之前的空值資料來解決)
布隆過濾器
在用戶端和 Redis 之間增加一層布隆過濾器。使用者訪問時,先由布隆過濾器判斷資料是否存在,不存在則直接拒絕;存在則走正常流程。
布隆過濾器如何判斷資料是否存在?
布隆過濾器可以簡單理解為一個位元組數組,儲存二進位位。判斷資料庫中資料是否存在時,不是直接將資料存入布隆過濾器,而是通過雜湊演算法計算雜湊值,再將這些雜湊值轉換為二進位位存入布隆過濾器。判斷資料是否存在時,只需判斷對應位置是 0 還是 1(這種判斷是機率性的,不是 100% 準確,所以判斷不存在時不一定真的不存在,仍有穿透風險)
優點:記憶體佔用少,無冗餘 Key(二進位儲存)
缺點:
實現複雜
存在誤判可能(不一定準確)
緩衝Null 物件 Java 實現
/**
* Cache penetration
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Does not exist, query the database according to the id
Shop shop = getById(id);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. return
return shop;
}
緩衝雪崩
緩衝雪崩是指大量緩衝 Key 同時失效或 Redis 服務同時宕機,導致大量請求到達資料庫,帶來巨大壓力。
解決方案
為不同 Key 的 TTL 添加隨機值(解決同時失效問題):例如緩衝預熱時,需要將資料庫中的資料分批提前置入緩衝。由於資料是同時匯入的,這些資料的 TTL 值相同,可能導致某一時刻資料同時到期,從而引發雪崩。為解決此問題,可以在匯入時為 TTL 加上隨機數(例如 TTL 為 30 ± 1~5),使這些 Key 的到期時間分散在一段時間內,而不是同時失效,從而避免雪崩發生
使用 Redis 叢集提高服務可用性(解決 Redis 宕機):藉助 Redis 哨兵機制,當一台機器宕機時,哨兵可以自動選擇一台機器替代,主從同步資料以保證 Redis 高可用
為緩衝業務添加降級和限流策略:如快速失敗、拒絕服務,防止請求湧入資料庫
為業務添加多級緩衝:瀏覽器可添加緩衝(通常是靜態資源),反向 Proxy伺服器 Nginx 可添加緩衝,Nginx 緩衝未命中再請求 Redis,Redis 緩衝未命中到達 JVM,JVM 內部也可建立本機快取,最後才到達資料庫
緩衝擊穿
緩衝擊穿問題,也稱熱 Key 問題,是指一個被高並發訪問的 Key 且緩衝重建業務較複雜時突然失效。無數請求的訪問將在瞬間對資料庫產生巨大衝擊。
緩衝重建:Redis 中的緩衝到期後失效,需要重新從資料庫查詢並寫入 Redis。從資料庫查詢和構建資料的過程可能較複雜,需要多表關聯查詢等,最後將結果緩衝。此業務可能耗時較長(數十甚至數百毫秒)。在此期間 Redis 中無緩衝,傳入的請求將直接存取資料庫。
解決方案
互斥鎖
當線程請求發現緩衝未命中時,在查詢資料庫前執行加鎖操作,寫入緩衝後釋放鎖。這樣其他線程在未命中時查詢資料庫也會擷取互斥鎖。擷取失敗後睡眠一段時間再重試。
顯然,其他線程只有在寫入緩衝後才能擷取資料。雖然能保證一致性,但效能較差,且可能導致死結。
Java 實現
/**
* Get the lock
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* release lock
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* Mutex
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Implement cache rebuild
// 4.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 Determine whether the acquisition is successful
if (!isLock) {
// 4.3 Fail, sleep and try again
Thread.sleep(50);
// recurse
return queryWithMutex(id);
}
// 4.4 Success, query the database according to id
shop = getById(id);
// simulate rebuild delay
Thread.sleep(200);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. Release the mutex
unlock(lockKey);
}
// 8. return
return shop;
}
用 jmeter 測試一下,發送 1000 個請求,可以看到所有請求都通過了,資料庫只查詢了一次
邏輯到期
顧名思義,不是真正到期,可以看作永不到期。在 Redis 中快取資料時不設定 TTL,在儲存資料時增加一個到期時間欄位(不是 TTL,基於目前時間 + 到期時間,邏輯維護的時間),這樣任何線程查詢都能命中,只需在邏輯上判斷是否已到期。
如下圖所示,如果線程 1 查詢快取時發現邏輯時間已到期,需要重建緩衝,然後擷取互斥鎖,開啟獨立線程進行緩衝重建(而不是自行執行緩衝重建操作)。緩衝重建完成後釋放鎖,線程 1 直接返回到期資料。當其他線程也未命中時,擷取互斥鎖失敗也會直接返回到期資料。雖然保證了效能,但無法保證一致性。
Java 實現
/**
* Cache warm-up
*
* @param id
* @param expireSeconds logical expiration time
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. Query store data
Shop shop = getById(id);
Thread.sleep(200);
// 2. Encapsulate logic expiration time
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Write to redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* Logical expiration
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isBlank(shopJson)) {
// 3. Missed, return directly
return null;
}
// 4. Hit, you need to deserialize json to object first
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. Determine whether it has expired
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 If it has not expired, return the store information directly
return shop;
}
// 5.2 has expired and needs to be rebuilt
// 6. Cache rebuild
// 6.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 Determine whether the lock is acquired successfully
if (isLock) {
// 6.3 Success, open an independent thread to achieve cache reconstruction
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// rebuild cache
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// release the lock
unlock(lockKey);
}
});
}
// 6.4 Return expired store information
return shop;
}
緩衝穿透是指用戶端請求的資料在緩衝和資料庫中都不存在,因此緩衝永遠不會生效,這些請求都會直接打到資料庫。
如果惡意使用者利用無數線程並發訪問不存在的資料,這些請求都會到達資料庫,很可能會導致資料庫崩潰。
解決方案
緩衝Null 物件
思路:當使用者請求一個 id 時,Redis 和資料庫都不存在該資料,我們直接將 id 對應的空值緩衝到 Redis,這樣下次使用者再重複請求這個 id 時,Redis 就能命中(命中空值),就不會去查資料庫了。
優點:實現簡單,易於維護
缺點:
額外的記憶體消耗(可通過添加 TTL 解決)
- 可能引起短時不一致(控制 TTL 時間可在一定程度上緩解):緩衝了空值後,我們剛好在資料庫中設定了該值,使用者查詢到的是空值,但資料庫中實際存在該資料,這就造成了不一致(可通過插入資料時自動覆蓋之前的空值資料來解決)
布隆過濾器
在用戶端和 Redis 之間增加一層布隆過濾器。使用者訪問時,先由布隆過濾器判斷資料是否存在,不存在則直接拒絕;存在則走正常流程。
布隆過濾器如何判斷資料是否存在?
布隆過濾器可以簡單理解為一個位元組數組,儲存二進位位。判斷資料庫中資料是否存在時,不是直接將資料存入布隆過濾器,而是通過雜湊演算法計算雜湊值,再將這些雜湊值轉換為二進位位存入布隆過濾器。判斷資料是否存在時,只需判斷對應位置是 0 還是 1(這種判斷是機率性的,不是 100% 準確,所以判斷不存在時不一定真的不存在,仍有穿透風險)
優點:記憶體佔用少,無冗餘 Key(二進位儲存)
缺點:
實現複雜
存在誤判可能(不一定準確)
緩衝Null 物件 Java 實現
/**
* Cache penetration
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Does not exist, query the database according to the id
Shop shop = getById(id);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. return
return shop;
}
緩衝雪崩
緩衝雪崩是指大量緩衝 Key 同時失效或 Redis 服務同時宕機,導致大量請求到達資料庫,帶來巨大壓力。
解決方案
為不同 Key 的 TTL 添加隨機值(解決同時失效問題):例如緩衝預熱時,需要將資料庫中的資料分批提前置入緩衝。由於資料是同時匯入的,這些資料的 TTL 值相同,可能導致某一時刻資料同時到期,從而引發雪崩。為解決此問題,可以在匯入時為 TTL 加上隨機數(例如 TTL 為 30 ± 1~5),使這些 Key 的到期時間分散在一段時間內,而不是同時失效,從而避免雪崩發生
使用 Redis 叢集提高服務可用性(解決 Redis 宕機):藉助 Redis 哨兵機制,當一台機器宕機時,哨兵可以自動選擇一台機器替代,主從同步資料以保證 Redis 高可用
為緩衝業務添加降級和限流策略:如快速失敗、拒絕服務,防止請求湧入資料庫
為業務添加多級緩衝:瀏覽器可添加緩衝(通常是靜態資源),反向 Proxy伺服器 Nginx 可添加緩衝,Nginx 緩衝未命中再請求 Redis,Redis 緩衝未命中到達 JVM,JVM 內部也可建立本機快取,最後才到達資料庫
緩衝擊穿
緩衝擊穿問題,也稱熱 Key 問題,是指一個被高並發訪問的 Key 且緩衝重建業務較複雜時突然失效。無數請求的訪問將在瞬間對資料庫產生巨大衝擊。
緩衝重建:Redis 中的緩衝到期後失效,需要重新從資料庫查詢並寫入 Redis。從資料庫查詢和構建資料的過程可能較複雜,需要多表關聯查詢等,最後將結果緩衝。此業務可能耗時較長(數十甚至數百毫秒)。在此期間 Redis 中無緩衝,傳入的請求將直接存取資料庫。
解決方案
互斥鎖
當線程請求發現緩衝未命中時,在查詢資料庫前執行加鎖操作,寫入緩衝後釋放鎖。這樣其他線程在未命中時查詢資料庫也會擷取互斥鎖。擷取失敗後睡眠一段時間再重試。
顯然,其他線程只有在寫入緩衝後才能擷取資料。雖然能保證一致性,但效能較差,且可能導致死結。
Java 實現
/**
* Get the lock
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* release lock
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* Mutex
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Implement cache rebuild
// 4.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 Determine whether the acquisition is successful
if (!isLock) {
// 4.3 Fail, sleep and try again
Thread.sleep(50);
// recurse
return queryWithMutex(id);
}
// 4.4 Success, query the database according to id
shop = getById(id);
// simulate rebuild delay
Thread.sleep(200);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. Release the mutex
unlock(lockKey);
}
// 8. return
return shop;
}
用 jmeter 測試一下,發送 1000 個請求,可以看到所有請求都通過了,資料庫只查詢了一次
邏輯到期
顧名思義,不是真正到期,可以看作永不到期。在 Redis 中快取資料時不設定 TTL,在儲存資料時增加一個到期時間欄位(不是 TTL,基於目前時間 + 到期時間,邏輯維護的時間),這樣任何線程查詢都能命中,只需在邏輯上判斷是否已到期。
如下圖所示,如果線程 1 查詢快取時發現邏輯時間已到期,需要重建緩衝,然後擷取互斥鎖,開啟獨立線程進行緩衝重建(而不是自行執行緩衝重建操作)。緩衝重建完成後釋放鎖,線程 1 直接返回到期資料。當其他線程也未命中時,擷取互斥鎖失敗也會直接返回到期資料。雖然保證了效能,但無法保證一致性。
Java 實現
/**
* Cache warm-up
*
* @param id
* @param expireSeconds logical expiration time
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. Query store data
Shop shop = getById(id);
Thread.sleep(200);
// 2. Encapsulate logic expiration time
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Write to redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* Logical expiration
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isBlank(shopJson)) {
// 3. Missed, return directly
return null;
}
// 4. Hit, you need to deserialize json to object first
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. Determine whether it has expired
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 If it has not expired, return the store information directly
return shop;
}
// 5.2 has expired and needs to be rebuilt
// 6. Cache rebuild
// 6.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 Determine whether the lock is acquired successfully
if (isLock) {
// 6.3 Success, open an independent thread to achieve cache reconstruction
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// rebuild cache
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// release the lock
unlock(lockKey);
}
});
}
// 6.4 Return expired store information
return shop;
}
