缓存穿透防护方案设计

在电商或服务平台中,缓存的使用是提高系统性能和响应速度的关键。然而,缓存穿透是一个常见的性能瓶颈问题,尤其是在查询不存在的数据时,系统会直接访问数据库,这不仅影响性能,还可能造成数据库负担过重。为了有效解决这个问题,我们提出了一种结合 布隆过滤器空值缓存分布式锁 的缓存穿透防护方案。以下是该方案的工作流程。

工作流程

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无效或数据库无数据),系统返回错误信息。

流程图

缓存穿透防护方案设计

代码实现

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

发表评论

相关文章