Skip to content

优惠券查询

这个优惠券查询是面向 C 端 的优惠券查询任务,因此不能类似于后管系统直接查询数据库。为了保证查询性能和系统稳定性,我们采用了多级缓存 + 布隆过滤器 + 分布式锁的架构设计。


1. 整体架构

查询请求通过 findCouponTemplate(requestParam) 方法入口,依次经过以下五层处理:

第一层:Redis Hash 缓存

  • 缓存 Keycoupon:template:{couponTemplateId}
  • 命中 → 直接返回缓存数据
  • 未命中 → 进入 loadAndCacheCouponTemplate() 方法

第二层:布隆过滤器(防缓存穿透)

  • 检查方法couponTemplateQueryBloomFilter.contains(couponTemplateId)
  • 不存在 → 抛出异常:"优惠券模板不存在或已过期"
  • 存在 → 继续执行下一层

第三层:空值缓存检查(防缓存穿透)

  • 缓存 Keycoupon:template:is_null:{couponTemplateId}
  • 存在空值标记 → 抛出异常:"优惠券模板不存在或已过期"
  • 不存在 → 继续执行下一层

第四层:分布式锁 + 双重检查

  • 锁 Keylock:coupon:template:{couponTemplateId}
  • 获取锁失败 → 返回繁忙提示:"系统繁忙,请稍后重试"
  • 获取锁成功 → 执行双重检查:
    1. 再次检查 Redis 缓存
    2. 再次检查空值缓存
    3. 若均未命中,查询数据库

第五层:数据库查询

  • 查询方法queryActiveCouponTemplate()
  • 查询到数据 → 使用 Lua 脚本原子写入缓存 → 返回数据
  • 未查询到数据 → 设置空值缓存(TTL 5 分钟)→ 抛出异常

2. 核心设计详解

2.1 Redis Hash 缓存

采用 Hash 结构存储优惠券模板数据,相比 String 结构的优势:

  • 可以单独读取/修改某个字段,减少网络传输
  • 更节省内存(共享 key 元数据)
java
// 缓存 Key 格式
String cacheKey = String.format(EngineRedisConstant.COUPON_TEMPLATE_KEY, couponTemplateId);

// 读取缓存
Map<Object, Object> cachedData = stringRedisTemplate.opsForHash().entries(cacheKey);

2.2 布隆过滤器 (Bloom Filter)

用于快速判断优惠券模板是否存在,防止恶意请求穿透到数据库。

特点

  • 存在误判:布隆过滤器说"存在",实际可能不存在(误判率可控)
  • 不存在精确:布隆过滤器说"不存在",则一定不存在
java
boolean contains = couponTemplateQueryBloomFilter.contains(requestParam.getCouponTemplateId());
if (!contains) {
    throw new ClientException("优惠券模板不存在或已过期");
}

2.3 空值缓存 (Null Cache)

解决布隆过滤器误判的问题。当数据库确实不存在某条记录时,缓存一个空标记,避免后续请求继续穿透。

java
// 检查空值缓存
String nullCacheKey = String.format(EngineRedisConstant.COUPON_TEMPLATE_IS_NULL_KEY, couponTemplateId);
if (StringUtils.hasText(stringRedisTemplate.opsForValue().get(nullCacheKey))) {
    throw new ClientException("优惠券模板不存在或已过期");
}

// 设置空值缓存(数据库查询为空时)
stringRedisTemplate.opsForValue().set(nullCacheKey, "1", 5, TimeUnit.MINUTES);

设计要点

  • TTL 设置为 5 分钟,避免长期占用缓存空间
  • 如果后续新增了该模板,5 分钟后自然失效,不影响正常查询

2.4 分布式锁 + 双重检查

解决缓存击穿问题:当大量请求同时查询一个不在缓存中的热点数据时,可能导致数据库瞬间压力过大。

java
String lockKey = String.format(EngineRedisConstant.LOCK_COUPON_TEMPLATE_KEY, couponTemplateId);
RLock lock = redissonClient.getLock(lockKey);

// 尝试获取锁,最多等待 3 秒
boolean acquired = lock.tryLock(3, TimeUnit.SECONDS);
if (!acquired) {
    throw new ClientException("系统繁忙,请稍后重试");
}

try {
    // 双重检查:再次查询缓存
    Map<Object, Object> cachedData = stringRedisTemplate.opsForHash().entries(cacheKey);
    if (!cachedData.isEmpty()) {
        return cachedData;  // 其他线程已经加载完成
    }
    
    // 查询数据库并缓存...
} finally {
    lock.unlock();
}

双重检查的意义

  • 第一次检查(锁外):快速返回,减少锁竞争
  • 第二次检查(锁内):确保只有一个线程查询数据库

2.5 Lua 脚本原子缓存

使用 Lua 脚本保证 HMSETEXPIREAT 的原子性,避免缓存写入成功但过期时间设置失败的问题。

java
String luaScript = "redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) " +
                   "redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])";

过期时间设计

  • 缓存过期时间 = 优惠券活动的 validEndTime
  • 活动结束后缓存自动失效,无需额外清理

3. 异常处理

场景处理方式用户提示
布隆过滤器判断不存在直接拒绝"优惠券模板不存在或已过期"
命中空值缓存直接拒绝"优惠券模板不存在或已过期"
获取分布式锁失败重试或拒绝"系统繁忙,请稍后重试"
数据库查询为空设置空值缓存并拒绝"优惠券模板不存在或已过期"

4. 性能分析

指标说明
缓存命中O(1) 时间复杂度,直接返回
布隆过滤器O(k) 时间复杂度,k 为哈希函数个数,通常 < 10
数据库查询仅在缓存未命中且通过所有检查后才执行
锁粒度couponTemplateId 加锁,不影响其他模板的查询

5. 关键代码入口

java
@Override
public CouponTemplateQueryRespDTO findCouponTemplate(CouponTemplateQueryReqDTO requestParam) {
    String cacheKey = String.format(EngineRedisConstant.COUPON_TEMPLATE_KEY, requestParam.getCouponTemplateId());
    Map<Object, Object> cachedData = stringRedisTemplate.opsForHash().entries(cacheKey);

    if (cachedData.isEmpty()) {
        cachedData = loadAndCacheCouponTemplate(requestParam, cacheKey);
    }

    return BeanUtil.mapToBean(cachedData, CouponTemplateQueryRespDTO.class, false, CopyOptions.create());
}