黑马点评商户缓存实战:穿透、击穿、雪崩与工具封装

📅 2026/7/11 8:30:34 👁️ 阅读次数 📝 编程学习
黑马点评商户缓存实战:穿透、击穿、雪崩与工具封装

黑马点评商户缓存实战:穿透、击穿、雪崩与工具封装

读多写少的场景下缓存几乎是必选项。黑马点评里的店铺信息就是典型的读多写少,谁进 App 都要查商户。这篇文章我把项目里商户查询相关的缓存代码拆开看,包括更新策略、穿透、击穿、雪崩,以及最后封装出来的CacheClient

1. 整体思路:Cache-Aside

项目里采用的是最常见的Cache-Aside模式:

  • 读:先查缓存,命中直接返回;没命中再查数据库,然后回写缓存。
  • 写:先更新数据库,再删除缓存。

这套模式简单直接,缺点是对一致性要求特别高的场景需要额外处理。但对店铺详情这种可以容忍秒级不一致的业务,已经够用了。

2. 缓存更新策略

ShopServiceImpl.update是缓存更新的入口:

@Override@TransactionalpublicResultupdate(Shopshop){Longid=shop.getId();if(id==null){returnResult.fail("店铺ID不存");}// 1. 更新数据库updateById(shop);// 2. 删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEY+id);returnResult.ok();}

思路是先更新数据库,再删除缓存。如果反过来,先删缓存再更 DB,在更新完成前另一个读请求可能把旧数据重新写回缓存,导致缓存和数据库不一致。先更 DB 再删,虽然仍有短暂不一致窗口,但概率小很多,也足够日常业务使用。

那能不能直接更新缓存而不是删除?可以,但会有并发覆盖的问题。比如两个线程同时更新同一个字段,先写的缓存可能被后写但内容较旧的线程覆盖。删除缓存相当于下次读取时重新从 DB 拉最新值,简单可靠。

店铺类型ShopTypeServiceImpl也是类似的 Cache-Aside,只是它缓存的是一个列表:

@OverridepublicResultqueryTypeList(){Stringkey=CACHE_SHOP_TYPE_KEY;StringshopTypes=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(shopTypes)){List<ShopType>shopTypeList=JSONUtil.toList(shopTypes,ShopType.class);returnResult.ok(shopTypeList);}List<ShopType>shoplist=query().list();if(CollectionUtil.isEmpty(shoplist)){returnResult.fail("没有数据");}stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(shoplist));stringRedisTemplate.expire(key,CACHE_SHOP_TYPE_TTL,TimeUnit.MINUTES);returnResult.ok(shoplist);}

3. 缓存穿透

缓存穿透指的是查询一个缓存和数据库里都没有的数据,导致每次请求都打到 DB。如果被人恶意用大量不存在的 id 刷接口,DB 会被打挂。

项目里用CacheClient.queryWithPassThrough来解决:

public<R,ID>RqueryWithPassThrough(StringkeyPrefix,IDid,Class<R>type,Function<ID,R>dbFallback,Longtime,TimeUnitunit){Stringkey=keyPrefix+id;Stringjson=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){returnJSONUtil.toBean(json,type);}// 命中空值:说明之前已经查过 DB 且不存在if(json!=null){returnnull;}Rr=dbFallback.apply(id);if(r==null){// DB 也不存在,缓存空值 "",TTL 2 分钟stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);returnnull;}this.set(key,r,time,unit);returnr;}

关键点:

  • 命中空字符串""直接返回 null,不会再去查 DB。
  • 非空字符串反序列化后返回。
  • 数据库没有就缓存空值,TTL 只有 2 分钟,避免空值长期占用缓存。

这个方案简单有效,但有一个前提:大量不存在的 id 空间不能太大,否则空值也会占不少内存。如果 id 范围无限,布隆过滤器会是更好的选择,不过项目里没有引入。

4. 缓存击穿

缓存击穿是热点 key 过期的那一瞬间,大量并发请求同时发现缓存里没有,然后一起去查数据库。对商户详情页这种可能出现的热门店铺来说,风险真实存在。

项目里提供了两种解决思路,目前实际调用的是逻辑过期

@OverridepublicResultqueryById(Longid){Shopshop=cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,this::getById,20L,TimeUnit.SECONDS);if(shop==null){returnResult.fail("店铺不存在");}returnResult.ok(shop);}

方案 A:互斥锁

项目里CacheClienttryLock/unlock方法,靠 Redis 的setIfAbsent实现:

privatebooleantryLock(Stringkey){Booleanflag=stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);returnBooleanUtil.isTrue(flag);}privatevoidunlock(Stringkey){stringRedisTemplate.delete(key);}

思路是:缓存未命中时,所有线程抢锁,只有一个线程能查 DB 并回写缓存,其他线程等待或重试。ShopServiceImpl里有一段被注释掉的queryWithMutex就是这种写法。

互斥锁简单,但缺点是用户要等待,如果重建慢,响应会变差。

方案 B:逻辑过期(当前采用)

逻辑过期的核心思想是:缓存里的 key 永远不会被 Redis 物理删除,而是在 value 里额外保存一个过期时间。即使过期了,也继续返回旧数据,同时另起一个线程异步重建。

RedisData就是用来包装逻辑过期时间的:

@DatapublicclassRedisData{privateLocalDateTimeexpireTime;privateObjectdata;}

CacheClient.queryWithLogicalExpire的逻辑:

public<R,ID>RqueryWithLogicalExpire(StringkeyPrefix,IDid,Class<R>type,Function<ID,R>dbFallback,Longtime,TimeUnitunit){Stringkey=keyPrefix+id;StringshopJSON=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isBlank(shopJSON)){returnnull;}RedisDataredisData=JSONUtil.toBean(shopJSON,RedisData.class);Rr=JSONUtil.toBean((JSONObject)redisData.getData(),type);LocalDateTimeexpireTime=redisData.getExpireTime();if(expireTime.isAfter(LocalDateTime.now())){returnr;// 未过期,直接返回}// 已过期,异步重建StringlockKey=LOCK_SHOP_KEY+id;booleanisLock=tryLock(lockKey);if(isLock){CACHE_REBUILD_EXECUTOR.submit(()->{try{Rr1=dbFallback.apply(id);this.setWithLogicalExpire(key,r1,time,unit);}catch(Exceptione){thrownewRuntimeException(e);}finally{unlock(lockKey);}});}returnr;// 返回过期数据,用户无感知}

setWithLogicalExpire写入 Redis 时并不设置 Redis 的 TTL:

publicvoidsetWithLogicalExpire(Stringkey,Objectvalue,Longtime,TimeUnitunit){RedisDataredisData=newRedisData();redisData.setData(value);redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));}

注意:它只把过期时间存在 value 里,没有expire命令。所以cache:shop:{id}在 Redis 里是长期存在的,除非被内存淘汰。

这种做法的好处是:

  • 热点 key 不会因为物理过期而瞬间失效。
  • 重建期间用户仍然能拿到旧数据,响应不会阻塞。
  • 互斥锁只用于控制重建线程的数量,而不是阻塞读请求。

风险是可能返回旧数据,以及长期占用 Redis 内存。对店铺详情这种读多写少、允许短暂不一致的场景很合适。

5. 缓存雪崩

缓存雪崩是大量 key 同时过期,或者 Redis 直接宕机,导致所有请求涌向 DB。和缓存击穿不同,击穿是一个 key,雪崩是一片 key。

项目里的应对:

  • queryWithLogicalExpire的 key 没有物理 TTL,不存在过期瞬间,自然规避了雪崩。
  • 如果用queryWithPassThrough的缓存,TTL 是固定的 30 分钟。批量导入数据时如果同时写入,确实可能同时失效。可以改成基础 TTL + 随机偏移,比如30 + random(1, 10)分钟。
  • 另外就是 Redis 本身的高可用,主从、哨兵、集群,避免单点故障。

代码里雪崩并没有被显式处理(没有随机 TTL),但逻辑过期方案本身解决了商户详情这部分的雪崩风险。

6. 缓存工具封装

如果每个 Service 都自己写一套「查缓存 → 查 DB → 写缓存」的逻辑,会重复很多代码。项目里把通用逻辑抽到了CacheClient

CacheClient提供的能力:

  • set(key, value, time, unit):普通缓存写入。
  • setWithLogicalExpire(key, value, time, unit):带逻辑过期时间写入。
  • queryWithPassThrough(...):解决穿透。
  • queryWithLogicalExpire(...):解决击穿。

通过泛型和Function<ID, R> dbFallback回调,可以复用到任何实体上:

Shopshop=cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,this::getById,20L,TimeUnit.SECONDS);

调用的 Service 只需要关心业务查询this::getById,缓存的通用流程全在CacheClient里。

另外,SimpleRedisLock也做了独立封装,用 UUID + 线程 ID 作为锁的标识,并用 Lua 脚本保证释放锁的原子性:

publicclassSimpleRedisLockimplementsILock{privateStringname;privateStringRedisTemplatestringRedisTemplate;privatestaticfinalStringKEY_PREFIX="lock";privatestaticfinalStringID_PREFIX=UUID.randomUUID().toString(true)+"-";privatestaticfinalDefaultRedisScript<Long>UNLOCK_SCRIPT;static{UNLOCK_SCRIPT=newDefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(newClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}@OverridepublicbooleantryLock(longtimeoutSec){StringthreadId=ID_PREFIX+Thread.currentThread().getId();Booleansuccess=stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,threadId,timeoutSec,TimeUnit.SECONDS);returnBoolean.TRUE.equals(success);}@Overridepublicvoidunlock(){stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX+name),ID_PREFIX+Thread.currentThread().getId());}}

用 Lua 脚本是为了避免“判断锁是不是自己的”和“删除锁”之间被其他线程打断,导致误删。CacheClient里的unlock是简单delete,只作为重建锁使用;真正需要安全释放的场景建议用SimpleRedisLock

7. 踩坑总结

  1. Cache-Aside 更新一定是先 DB 后删缓存。反过来的话,并发读容易把旧值重新写回缓存。
  2. 缓存空值要设短 TTL。否则空值长期占内存,而且业务数据一旦真的新增,缓存里还是空。
  3. 逻辑过期不设置 Redis TTL。这意味着缓存不会自动过期,要预留内存淘汰策略,且能接受短期旧数据。
  4. DoubleCheck 建议加上queryWithLogicalExpire拿到锁后,最好再查一次 Redis 是否已过期,避免多个线程重复重建。
  5. 互斥锁需要超时。防止获取锁的线程挂掉后导致死锁。
  6. JSON 序列化用 Hutool 的 JSONUtil。注意RedisData里的dataObject,需要二次转换为具体类型。

8. 总结

黑马点评的商户缓存覆盖了日常最经典的几类问题:

  • Cache-Aside 更新策略:先更 DB 再删缓存。
  • 缓存穿透:缓存空值 + 短 TTL。
  • 缓存击穿:互斥锁或逻辑过期,项目里用的是逻辑过期。
  • 缓存雪崩:逻辑过期无物理 TTL,规避了批量过期风险。
  • 工具封装:CacheClient把通用逻辑抽出来,SimpleRedisLock提供安全的分布式锁。

作为还在学习路上的大学生,这些都是我踩坑踩出来的经验,分享出来希望能帮到同样在学 Java 的小伙伴~如果有写得不对的地方,欢迎大佬们指正!

你们在学习的时候有遇到过什么有意思的坑吗?评论区聊聊呀👇

🎓 我是小小放舟,一个正在努力打怪升级的后端学习者

🌊 个人主页:小小放舟的 CSDN

✨ 点赞收藏不迷路,我们一起放舟技术海~