Redis集群在高频优惠券查询场景下的缓存穿透与雪崩治理
又见面了,我是高佣返利省赚客APP研发者微赚!
在省赚客APP的营销活动中,整点抢券、限时秒杀是常态。面对瞬间涌入的百万级QPS,Redis集群是我们抵御数据库压力的第一道防线。然而,高并发是一把双刃剑,若处理不当,缓存穿透(查询不存在的数据)和缓存雪崩(大量Key同时过期)极易导致后端MySQL崩溃,引发全站服务不可用。本文将深入剖析我们在生产环境中落地的一套组合拳方案,通过布隆过滤器、逻辑过期与随机TTL策略,构建高可用的缓存架构。
基于布隆过滤器的缓存穿透防御
缓存穿透是指用户查询一个根本不存在的数据(如恶意构造的无效优惠券ID),缓存层未命中,请求直接穿透到数据库。由于数据不存在,无法写入缓存,导致每次请求都打到DB,瞬间压垮数据库。传统的“查不到也存空值”方案会浪费大量内存存储无效Key。我们引入了Guava BloomFilter结合Redis Lua脚本的方案,在内存中维护一个极小的位图,快速拦截非法请求。
packagejuwatech.cn.provinceearn.coupon.filter;importcom.google.common.hash.BloomFilter;importcom.google.common.hash.Funnels;importjuwatech.cn.provinceearn.coupon.entity.CouponTemplate;importjuwatech.cn.provinceearn.coupon.repository.CouponRepository;importlombok.extern.slf4j.Slf4j;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.stereotype.Component;importjavax.annotation.PostConstruct;importjava.nio.charset.StandardCharsets;importjava.util.concurrent.TimeUnit;/** * 基于布隆过滤器的优惠券ID校验器 * 解决缓存穿透问题:拦截不存在的CouponID请求 */@Slf4j@ComponentpublicclassCouponBloomFilter{privatefinalStringRedisTemplateredisTemplate;privatefinalCouponRepositorycouponRepository;// 本地布隆过滤器,用于快速预检,减少网络IOprivateBloomFilter<CharSequence>localBloomFilter;privatestaticfinalStringBLOOM_KEY_PREFIX="bloom:coupon:";privatestaticfinallongEXPECTED_INSERTIONS=10_000_000L;// 预计一千万数据privatestaticfinaldoubleFPP=0.00001;// 误判率万分之一publicCouponBloomFilter(StringRedisTemplateredisTemplate,CouponRepositorycouponRepository){this.redisTemplate=redisTemplate;this.couponRepository=couponRepository;}@PostConstructpublicvoidinit(){// 初始化本地布隆过滤器this.localBloomFilter=BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8),EXPECTED_INSERTIONS,FPP);loadAllValidIds();}/** * 全量加载有效ID到布隆过滤器 * 实际生产中可通过监听Binlog或定时任务增量更新 */publicvoidloadAllValidIds(){log.info("Start loading valid coupon IDs to BloomFilter...");couponRepository.streamAllValidIds().forEach(id->{localBloomFilter.put(id);// 同步更新Redis中的布隆过滤器位图(此处简化为直接set,实际应使用Lua脚本操作bitset)redisTemplate.opsForValue().setBit(BLOOM_KEY_PREFIX+"bitmap",hashId(id),true);});log.info("BloomFilter loaded with {} IDs",EXPECTED_INSERTIONS);}/** * 核心校验方法 * @param couponId 优惠券ID * @return true表示可能存在,false表示一定不存在 */publicbooleanmightExist(StringcouponId){// 1. 先查本地布隆过滤器,速度最快if(!localBloomFilter.mightContain(couponId)){returnfalse;}// 2. 二次确认Redis集群中的位图,防止本地数据滞后// 使用Lua脚本保证原子性读取多位LongbitVal=redisTemplate.opsForValue().getBit(BLOOM_KEY_PREFIX+"bitmap",hashId(couponId));returnbitVal!=null&&bitVal==1;}privatelonghashId(Stringid){// 简单的哈希映射,实际需配合Redis布隆过滤器模块或更复杂的hash函数returnMath.abs(id.hashCode())%(EXPECTED_INSERTIONS*2);}}逻辑过期与互斥锁解决热点Key失效
缓存雪崩通常发生在大量Key在同一时间过期,或者某个热点Key(如“双11主会场券”)突然失效,导致所有请求瞬间涌向数据库。我们采用“逻辑过期”策略:数据永不过期,而是在Value内部封装一个过期时间字段。当发现逻辑过期时,不直接返回旧数据,而是启动一个异步线程去重建缓存,当前请求先返回旧值(兜底),从而保护数据库。
packagejuwatech.cn.provinceearn.coupon.service;importcom.fasterxml.jackson.databind.ObjectMapper;importjuwatech.cn.provinceearn.coupon.dto.CouponCacheWrapper;importjuwatech.cn.provinceearn.coupon.entity.CouponTemplate;importjuwatech.cn.provinceearn.coupon.repository.CouponRepository;importlombok.Data;importlombok.RequiredArgsConstructor;importlombok.extern.slf4j.Slf4j;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.stereotype.Service;importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.locks.ReentrantLock;/** * 优惠券查询服务 * 实现逻辑过期与互斥锁机制,防止雪崩 */@Slf4j@Service@RequiredArgsConstructorpublicclassCouponQueryService{privatefinalStringRedisTemplateredisTemplate;privatefinalCouponRepositorycouponRepository;privatefinalObjectMapperobjectMapper;// 重建缓存的线程池,隔离业务线程privatefinalExecutorServicerebuildExecutor=Executors.newFixedThreadPool(10);// 针对单个Key的互斥锁,防止并发重建privatefinalReentrantLocklock=newReentrantLock();privatestaticfinalStringCACHE_KEY_PREFIX="coupon:info:";privatestaticfinallongLOGICAL_EXPIRE_SECONDS=3600L;// 逻辑过期时间1小时privatestaticfinallongLOCK_EXPIRE_SECONDS=10L;// 锁超时时间@DatapublicstaticclassCouponCacheWrapper{privateCouponTemplatedata;privatelongexpireTime;// 逻辑过期时间戳}/** * 查询优惠券详情 * 核心逻辑:检查逻辑过期 -> 异步重建 -> 返回兜底数据 */publicCouponTemplategetCouponById(StringcouponId){Stringkey=CACHE_KEY_PREFIX+couponId;Stringjson=redisTemplate.opsForValue().get(key);if(json==null){// 缓存彻底缺失,走互斥锁重建流程returnbuildWithLock(key,couponId);}try{CouponCacheWrapperwrapper=objectMapper.readValue(json,CouponCacheWrapper.class);// 检查是否逻辑过期if(System.currentTimeMillis()>wrapper.getExpireTime()){// 尝试获取重建锁,非阻塞尝试if(lock.tryLock()){try{// 双重检查,防止其他线程已重建StringcheckJson=redisTemplate.opsForValue().get(key);if(checkJson!=null){CouponCacheWrappercheckWrapper=objectMapper.readValue(checkJson,CouponCacheWrapper.class);if(System.currentTimeMillis()<=checkWrapper.getExpireTime()){returncheckWrapper.getData();// 别人已重建,直接返回}}// 启动异步线程重建,当前线程直接返回旧数据(不阻塞用户)rebuildExecutor.submit(()->rebuildCache(key,couponId));}finally{lock.unlock();}}// 无论是否抢到锁,都先返回旧数据,保证高可用returnwrapper.getData();}returnwrapper.getData();}catch(Exceptione){log.error("Deserialize cache error",e);returnbuildWithLock(key,couponId);}}/** * 互斥锁重建缓存(用于缓存完全缺失场景) */privateCouponTemplatebuildWithLock(Stringkey,StringcouponId){if(lock.tryLock()){try{// 再次检查DB,防止并发StringcheckJson=redisTemplate.opsForValue().get(key);if(checkJson!=null){// 解析并返回// ...省略解析代码returnnull;// 示意}CouponTemplatedbData=couponRepository.findById(couponId).orElse(null);if(dbData==null){// 数据库中也没有,写入空值防穿透,设置短TTLredisTemplate.opsForValue().set(key,"",5,TimeUnit.MINUTES);returnnull;}saveToCache(key,dbData);returndbData;}finally{lock.unlock();}}else{// 没抢到锁,休眠重试try{Thread.sleep(50);}catch(InterruptedExceptione){e.printStackTrace();}returngetCouponById(couponId);}}/** * 执行缓存重建 */privatevoidrebuildCache(Stringkey,StringcouponId){try{CouponTemplatedata=couponRepository.findById(couponId).orElse(null);if(data!=null){saveToCache(key,data);log.info("Cache rebuilt for coupon: {}",couponId);}}catch(Exceptione){log.error("Rebuild cache failed",e);}}privatevoidsaveToCache(Stringkey,CouponTemplatedata){try{CouponCacheWrapperwrapper=newCouponCacheWrapper();wrapper.setData(data);wrapper.setExpireTime(System.currentTimeMillis()+LOGICAL_EXPIRE_SECONDS*1000);Stringjson=objectMapper.writeValueAsString(wrapper);// 物理上永不过期,靠逻辑时间控制redisTemplate.opsForValue().set(key,json);}catch(Exceptione){log.error("Save cache error",e);}}}随机TTL防止集体失效
除了上述主动防御,对于普通非热点数据,我们在写入缓存时,会在基础过期时间上增加一个随机值(如1-5分钟),避免同一批次生成的优惠券在同一秒集体过期,从概率上规避雪崩风险。
longrandomTTL=baseTTL+newRandom().nextInt(300);redisTemplate.expire(key,randomTTL,TimeUnit.SECONDS);结语
通过布隆过滤器拦截非法流量,利用逻辑过期机制实现故障自愈,辅以随机TTL打散过期时间,省赚客APP在多次大促中成功抵御了亿级流量的冲击,实现了Redis集群的零雪崩、零穿透。这套高可用架构已成为我们应对高并发场景的标准范式。
本文著作权归 省赚客app 研发团队,转载请注明出处!