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

发布时间:2026/7/11 8:18:54

黑马点评商户缓存实战:穿透、击穿、雪崩与工具封装 黑马点评商户缓存实战穿透、击穿、雪崩与工具封装读多写少的场景下缓存几乎是必选项。黑马点评里的店铺信息就是典型的读多写少谁进 App 都要查商户。这篇文章我把项目里商户查询相关的缓存代码拆开看包括更新策略、穿透、击穿、雪崩以及最后封装出来的CacheClient。1. 整体思路Cache-Aside项目里采用的是最常见的Cache-Aside模式读先查缓存命中直接返回没命中再查数据库然后回写缓存。写先更新数据库再删除缓存。这套模式简单直接缺点是对一致性要求特别高的场景需要额外处理。但对店铺详情这种可以容忍秒级不一致的业务已经够用了。2. 缓存更新策略ShopServiceImpl.update是缓存更新的入口OverrideTransactionalpublicResultupdate(Shopshop){Longidshop.getId();if(idnull){returnResult.fail(店铺ID不存);}// 1. 更新数据库updateById(shop);// 2. 删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEYid);returnResult.ok();}思路是先更新数据库再删除缓存。如果反过来先删缓存再更 DB在更新完成前另一个读请求可能把旧数据重新写回缓存导致缓存和数据库不一致。先更 DB 再删虽然仍有短暂不一致窗口但概率小很多也足够日常业务使用。那能不能直接更新缓存而不是删除可以但会有并发覆盖的问题。比如两个线程同时更新同一个字段先写的缓存可能被后写但内容较旧的线程覆盖。删除缓存相当于下次读取时重新从 DB 拉最新值简单可靠。店铺类型ShopTypeServiceImpl也是类似的 Cache-Aside只是它缓存的是一个列表OverridepublicResultqueryTypeList(){StringkeyCACHE_SHOP_TYPE_KEY;StringshopTypesstringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(shopTypes)){ListShopTypeshopTypeListJSONUtil.toList(shopTypes,ShopType.class);returnResult.ok(shopTypeList);}ListShopTypeshoplistquery().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来解决publicR,IDRqueryWithPassThrough(StringkeyPrefix,IDid,ClassRtype,FunctionID,RdbFallback,Longtime,TimeUnitunit){StringkeykeyPrefixid;StringjsonstringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){returnJSONUtil.toBean(json,type);}// 命中空值说明之前已经查过 DB 且不存在if(json!null){returnnull;}RrdbFallback.apply(id);if(rnull){// 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){ShopshopcacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,this::getById,20L,TimeUnit.SECONDS);if(shopnull){returnResult.fail(店铺不存在);}returnResult.ok(shop);}方案 A互斥锁项目里CacheClient有tryLock/unlock方法靠 Redis 的setIfAbsent实现privatebooleantryLock(Stringkey){BooleanflagstringRedisTemplate.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的逻辑publicR,IDRqueryWithLogicalExpire(StringkeyPrefix,IDid,ClassRtype,FunctionID,RdbFallback,Longtime,TimeUnitunit){StringkeykeyPrefixid;StringshopJSONstringRedisTemplate.opsForValue().get(key);if(StrUtil.isBlank(shopJSON)){returnnull;}RedisDataredisDataJSONUtil.toBean(shopJSON,RedisData.class);RrJSONUtil.toBean((JSONObject)redisData.getData(),type);LocalDateTimeexpireTimeredisData.getExpireTime();if(expireTime.isAfter(LocalDateTime.now())){returnr;// 未过期直接返回}// 已过期异步重建StringlockKeyLOCK_SHOP_KEYid;booleanisLocktryLock(lockKey);if(isLock){CACHE_REBUILD_EXECUTOR.submit(()-{try{Rr1dbFallback.apply(id);this.setWithLogicalExpire(key,r1,time,unit);}catch(Exceptione){thrownewRuntimeException(e);}finally{unlock(lockKey);}});}returnr;// 返回过期数据用户无感知}setWithLogicalExpire写入 Redis 时并不设置 Redis 的 TTLpublicvoidsetWithLogicalExpire(Stringkey,Objectvalue,Longtime,TimeUnitunit){RedisDataredisDatanewRedisData();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(...)解决击穿。通过泛型和FunctionID, R dbFallback回调可以复用到任何实体上ShopshopcacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,this::getById,20L,TimeUnit.SECONDS);调用的 Service 只需要关心业务查询this::getById缓存的通用流程全在CacheClient里。另外SimpleRedisLock也做了独立封装用 UUID 线程 ID 作为锁的标识并用 Lua 脚本保证释放锁的原子性publicclassSimpleRedisLockimplementsILock{privateStringname;privateStringRedisTemplatestringRedisTemplate;privatestaticfinalStringKEY_PREFIXlock;privatestaticfinalStringID_PREFIXUUID.randomUUID().toString(true)-;privatestaticfinalDefaultRedisScriptLongUNLOCK_SCRIPT;static{UNLOCK_SCRIPTnewDefaultRedisScript();UNLOCK_SCRIPT.setLocation(newClassPathResource(unlock.lua));UNLOCK_SCRIPT.setResultType(Long.class);}OverridepublicbooleantryLock(longtimeoutSec){StringthreadIdID_PREFIXThread.currentThread().getId();BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,threadId,timeoutSec,TimeUnit.SECONDS);returnBoolean.TRUE.equals(success);}Overridepublicvoidunlock(){stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIXname),ID_PREFIXThread.currentThread().getId());}}用 Lua 脚本是为了避免“判断锁是不是自己的”和“删除锁”之间被其他线程打断导致误删。CacheClient里的unlock是简单delete只作为重建锁使用真正需要安全释放的场景建议用SimpleRedisLock。7. 踩坑总结Cache-Aside 更新一定是先 DB 后删缓存。反过来的话并发读容易把旧值重新写回缓存。缓存空值要设短 TTL。否则空值长期占内存而且业务数据一旦真的新增缓存里还是空。逻辑过期不设置 Redis TTL。这意味着缓存不会自动过期要预留内存淘汰策略且能接受短期旧数据。DoubleCheck 建议加上。queryWithLogicalExpire拿到锁后最好再查一次 Redis 是否已过期避免多个线程重复重建。互斥锁需要超时。防止获取锁的线程挂掉后导致死锁。JSON 序列化用 Hutool 的 JSONUtil。注意RedisData里的data是Object需要二次转换为具体类型。8. 总结黑马点评的商户缓存覆盖了日常最经典的几类问题Cache-Aside 更新策略先更 DB 再删缓存。缓存穿透缓存空值 短 TTL。缓存击穿互斥锁或逻辑过期项目里用的是逻辑过期。缓存雪崩逻辑过期无物理 TTL规避了批量过期风险。工具封装CacheClient把通用逻辑抽出来SimpleRedisLock提供安全的分布式锁。作为还在学习路上的大学生这些都是我踩坑踩出来的经验分享出来希望能帮到同样在学 Java 的小伙伴如果有写得不对的地方欢迎大佬们指正你们在学习的时候有遇到过什么有意思的坑吗评论区聊聊呀 我是小小放舟一个正在努力打怪升级的后端学习者 个人主页小小放舟的 CSDN✨ 点赞收藏不迷路我们一起放舟技术海

相关新闻