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