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

空值缓存

分布式锁
的缓存穿透防护方案。以下是该方案的工作流程。

工作流程

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

标签: none

添加新评论