快取穿透防護方案設計

b1uesk9發表於2024-11-06

在電商或服務平臺中,快取的使用是提高系統效能和響應速度的關鍵。然而,快取穿透是一個常見的效能瓶頸問題,尤其是在查詢不存在的資料時,系統會直接訪問資料庫,這不僅影響效能,還可能造成資料庫負擔過重。為了有效解決這個問題,我們提出了一種結合 布隆過濾器空值快取分散式鎖 的快取穿透防護方案。以下是該方案的工作流程。

工作流程

1. 使用者請求優惠券模板資訊

使用者首先發起對優惠券模板資訊的請求。該請求包括一個優惠券模板ID,系統需要根據該ID返回相應的優惠券資訊。

2. 快取查詢:Redis快取

系統首先會在 Redis快取 中查詢是否已經快取了相關的優惠券資訊。Redis 是一個高效的快取系統,通常可以極大地提高查詢速度。如果快取中存在相應的模板資訊,系統直接返回給使用者,查詢過程結束。

3. 快取未命中:布隆過濾器的使用

如果 Redis 快取中沒有找到對應的優惠券模板資訊,系統會進一步透過 布隆過濾器 檢查該模板ID是否有效。布隆過濾器是一種空間效率極高的資料結構,用來快速判斷某個元素是否在集合中。

  • 如果布隆過濾器中沒有該模板ID,說明該優惠券模板ID不合法或已經失效,系統直接返回給使用者 “失敗:無效的優惠券模板ID”
  • 如果布隆過濾器中存在該模板ID,表示該優惠券模板ID可能有效,系統會繼續查詢資料庫。

4. 空值快取:防止重複查詢

在布隆過濾器判斷模板ID有效的情況下,系統繼續檢查 Redis 快取中是否存在空值快取。空值快取是指對於某些查詢,資料庫返回了“空”結果(例如優惠券模板ID不存在於資料庫中),為了避免重複查詢資料庫,這類空結果會被快取一段時間。

  • 如果 Redis 快取中存在空值,系統會直接返回 “失敗:無效的優惠券模板ID”,避免重複的資料庫查詢。
  • 如果 Redis 快取中沒有空值,系統繼續進行資料庫查詢操作。

5. 分散式鎖:保證資料一致性

為了防止多個請求同時查詢資料庫,造成資料庫壓力過大,或者多個執行緒同時執行相同查詢操作,系統使用了 分散式鎖 來確保在同一時間只有一個請求會訪問資料庫查詢資料。

  • 如果分散式鎖可用,系統獲取鎖,並進行以下步驟:

    1. 查詢資料庫獲取優惠券模板資訊。
    2. 如果資料庫返回資料,系統將資料快取到 Redis 中,減少後續請求對資料庫的訪問。
    3. 如果資料庫返回空資料,系統在 Redis 中快取空結果,並設定短時間過期,防止短時間內重複查詢。
    4. 最後釋放分散式鎖。
  • 如果分散式鎖不可用,表示其他請求正在進行相同的資料庫查詢操作,系統會等待鎖釋放或返回錯誤資訊。

6. 返回結果:快取資料或資料庫資料

  • 如果 Redis 快取中有資料,系統直接返回快取的資料給使用者。
  • 如果快取中沒有資料且查詢成功,系統將資料庫中的資料返回給使用者,並快取該資料以提高後續查詢的效率。
  • 如果查詢失敗(例如模板ID無效或資料庫無資料),系統返回錯誤資訊。

流程圖

image

程式碼實現

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());
}

相關文章