在電商或服務平臺中,快取的使用是提高系統效能和響應速度的關鍵。然而,快取穿透是一個常見的效能瓶頸問題,尤其是在查詢不存在的資料時,系統會直接訪問資料庫,這不僅影響效能,還可能造成資料庫負擔過重。為了有效解決這個問題,我們提出了一種結合 布隆過濾器、空值快取 和 分散式鎖 的快取穿透防護方案。以下是該方案的工作流程。
工作流程
1. 使用者請求優惠券模板資訊
使用者首先發起對優惠券模板資訊的請求。該請求包括一個優惠券模板ID,系統需要根據該ID返回相應的優惠券資訊。
2. 快取查詢:Redis快取
系統首先會在 Redis快取 中查詢是否已經快取了相關的優惠券資訊。Redis 是一個高效的快取系統,通常可以極大地提高查詢速度。如果快取中存在相應的模板資訊,系統直接返回給使用者,查詢過程結束。
3. 快取未命中:布隆過濾器的使用
如果 Redis 快取中沒有找到對應的優惠券模板資訊,系統會進一步透過 布隆過濾器 檢查該模板ID是否有效。布隆過濾器是一種空間效率極高的資料結構,用來快速判斷某個元素是否在集合中。
- 如果布隆過濾器中沒有該模板ID,說明該優惠券模板ID不合法或已經失效,系統直接返回給使用者 “失敗:無效的優惠券模板ID”。
- 如果布隆過濾器中存在該模板ID,表示該優惠券模板ID可能有效,系統會繼續查詢資料庫。
4. 空值快取:防止重複查詢
在布隆過濾器判斷模板ID有效的情況下,系統繼續檢查 Redis 快取中是否存在空值快取。空值快取是指對於某些查詢,資料庫返回了“空”結果(例如優惠券模板ID不存在於資料庫中),為了避免重複查詢資料庫,這類空結果會被快取一段時間。
- 如果 Redis 快取中存在空值,系統會直接返回 “失敗:無效的優惠券模板ID”,避免重複的資料庫查詢。
- 如果 Redis 快取中沒有空值,系統繼續進行資料庫查詢操作。
5. 分散式鎖:保證資料一致性
為了防止多個請求同時查詢資料庫,造成資料庫壓力過大,或者多個執行緒同時執行相同查詢操作,系統使用了 分散式鎖 來確保在同一時間只有一個請求會訪問資料庫查詢資料。
-
如果分散式鎖可用,系統獲取鎖,並進行以下步驟:
- 查詢資料庫獲取優惠券模板資訊。
- 如果資料庫返回資料,系統將資料快取到 Redis 中,減少後續請求對資料庫的訪問。
- 如果資料庫返回空資料,系統在 Redis 中快取空結果,並設定短時間過期,防止短時間內重複查詢。
- 最後釋放分散式鎖。
-
如果分散式鎖不可用,表示其他請求正在進行相同的資料庫查詢操作,系統會等待鎖釋放或返回錯誤資訊。
6. 返回結果:快取資料或資料庫資料
- 如果 Redis 快取中有資料,系統直接返回快取的資料給使用者。
- 如果快取中沒有資料且查詢成功,系統將資料庫中的資料返回給使用者,並快取該資料以提高後續查詢的效率。
- 如果查詢失敗(例如模板ID無效或資料庫無資料),系統返回錯誤資訊。
流程圖
程式碼實現
public CouponTemplateQueryRespDTO getCouponTemplate(CouponTemplateQueryReqDTO requestParam) {
// 查詢 Redis 快取中是否存在優惠券模板資訊
String cacheKey = String.format(RedisConstants.COUPON_TEMPLATE_KEY, requestParam.getTemplateId());
Map<Object, Object> cacheMap = stringRedisTemplate.opsForHash().entries(cacheKey);
// 如果快取存在直接返回,否則透過布隆過濾器、空值快取以及分散式鎖查詢資料庫
if (MapUtil.isEmpty(cacheMap)) {
// 判斷布隆過濾器是否存在指定模板 ID,不存在則返回錯誤
if (!bloomFilter.contains(requestParam.getTemplateId())) {
throw new ClientException("Coupon template does not exist");
}
// 查詢 Redis 快取中是否存在空值資訊,如果存在則直接返回
String nullCacheKey = String.format(RedisConstants.COUPON_TEMPLATE_NULL_KEY, requestParam.getTemplateId());
Boolean isNullCached = stringRedisTemplate.hasKey(nullCacheKey);
if (isNullCached) {
throw new ClientException("Coupon template does not exist");
}
// 獲取分散式鎖
RLock lock = redissonClient.getLock(String.format(RedisConstants.LOCK_COUPON_TEMPLATE_KEY, requestParam.getTemplateId()));
lock.lock();
try {
// 雙重檢查空值快取
isNullCached = stringRedisTemplate.hasKey(nullCacheKey);
if (isNullCached) {
throw new ClientException("Coupon template does not exist");
}
// 使用雙重檢查鎖避免併發查詢資料庫
cacheMap = stringRedisTemplate.opsForHash().entries(cacheKey);
if (MapUtil.isEmpty(cacheMap)) {
LambdaQueryWrapper<CouponTemplate> queryWrapper = Wrappers.lambdaQuery(CouponTemplate.class)
.eq(CouponTemplate::getShopId, Long.parseLong(requestParam.getShopId()))
.eq(CouponTemplate::getId, Long.parseLong(requestParam.getTemplateId()))
.eq(CouponTemplate::getStatus, TemplateStatusEnum.ACTIVE.getStatus());
CouponTemplate couponTemplate = couponTemplateMapper.selectOne(queryWrapper);
// 如果模板不存在或已過期,設定空值快取並丟擲異常
if (couponTemplate == null) {
stringRedisTemplate.opsForValue().set(nullCacheKey, "", 30, TimeUnit.MINUTES);
throw new ClientException("Coupon template does not exist or has expired");
}
// 將資料庫記錄序列化並存入 Redis 快取
CouponTemplateQueryRespDTO responseDTO = BeanUtil.toBean(couponTemplate, CouponTemplateQueryRespDTO.class);
Map<String, Object> responseMap = BeanUtil.beanToMap(responseDTO, false, true);
Map<String, String> cacheData = responseMap.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue() != null ? entry.getValue().toString() : ""
));
// 使用 Lua 指令碼將資料存入 Redis 並設定過期時間
String luaScript = "redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) " +
"redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])";
List<String> keys = Collections.singletonList(cacheKey);
List<String> args = new ArrayList<>(cacheData.size() * 2 + 1);
cacheData.forEach((key, value) -> {
args.add(key);
args.add(value);
});
// 設定優惠券活動的過期時間
args.add(String.valueOf(couponTemplate.getEndTime().getTime() / 1000));
// 執行 Lua 指令碼
stringRedisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
keys,
args.toArray()
);
cacheMap = cacheData.entrySet()
.stream()
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
} finally {
lock.unlock();
}
}
// 返回從快取中獲取的資料
return BeanUtil.mapToBean(cacheMap, CouponTemplateQueryRespDTO.class, false, CopyOptions.create());
}