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