☰
黑马点评缓存实战:穿透、击穿、雪崩与一致性设计
2026/10/8 19:55:54 网站建设 项目流程

黑马点评这个项目,但凡认真跟过一遍的人,心里都有数:前半段CRUD不算难,真正的分水岭就在"缓存"这一章。商户查询缓存看着就是个"查Redis,没命中再查数据库"的流程,但真等你动手写代码、压测、上线,就会发现里面到处是坑——缓存穿透、击穿、雪崩怎么防,缓存和数据库的一致性怎么保证,RedisTemplate序列化为什么全是乱码,哪一步都够你喝一壶的。这篇笔记我就围绕黑马点评里的商户查询模块,把缓存从设计到落地的完整链路捋一遍,包含我的实测数据、踩坑记录和代码层面的取舍思路。适合正在跟项目、准备面试聊缓存,或者想把"会写缓存"升级成"懂缓存"的同学参考。


1. 项目里的商户查询为什么慢:从数据库压力说起

1.1 黑马点评商户模块的业务特点

黑马点评里的商户就是Shop表,核心操作是"根据id查商户详情"。这个接口在项目里看起来平平无奇,无非是select * from shop where id = ?,但如果代入真实业务场景,问题立刻就出来了。

我自己的演示环境里只有几十条Shop数据,怎么查都秒回。可一旦把数据量推到几十万、上百万条,再加上用户量上来,比如首页推荐位集中展示某些热门店铺,又赶上整点活动,每秒几千个查询同时打到数据库上,MySQL的CPU和IO就会直线飙升。实测下来,单表单机MySQL在普通配置下,简单主键查询也就支撑每秒一两千QPS左右,一旦超过这个数,响应时间从几毫秒恶化到几百毫秒甚至超时,紧接着就是连接池被打满、慢查询堆积、整个服务跟着雪崩。

那商户查询这种"读多写少"的业务,最优解法自然是加缓存。商户信息不像订单、库存那样实时性要求极高,店铺名称、地址、评分、营业时间这些字段,哪怕缓存里多放几分钟,用户也感知不到差别。这就是典型的"读多写少、一致性要求可放宽"的场景,属于缓存最理想的适用对象。

1.2 演进路径:直连DB → 本地缓存 → Redis

很多初学者第一反应是"那我用HashMap做个本地缓存行不行"。本地缓存在单机场景确实有效,甚至性能比Redis还快,因为它没有网络开销。但一旦服务部署多个实例,问题就来了:每个实例各自维护一份缓存,数据互不相同。用户在A实例查到的数据,请求打到B实例可能就是旧值甚至没有,缓存一致性没法保证。

从直连数据库到引入Redis,实际是一个三层演进思路:

  1. 直连数据库:实现最简单,但扛不住流量,数据库成为瓶颈。
  2. 进程内本地缓存:性能最好,但多实例数据不一致、重启即丢失,只适合做临时兜底。
  3. 集中式缓存Redis:所有实例共享同一份缓存,既能把压力从数据库剥离,又能保证多实例之间的数据一致性。

黑马点评这个项目之所以选择Redis作为缓存载体,本质上就是在"性能"和"一致性"之间取了均衡点。Redis单实例QPS能到10万+级别,处理商户查询这种量级绰绰有余,同时它支持丰富的数据结构、可以设置过期时间、可以持久化,这些都是本地缓存给不了的。

提示:判断一个查询该不该加缓存,就看两个条件,一是不是读多写少,二是不是对实时性要求没那么苛刻。别什么接口都套缓存,那只会给自己找麻烦。


2. 缓存设计的第一步:Redis接入与Key规范

2.1 Key怎么设计:可读性、防冲突、粒度控制

我见过不少同学学完Redis,做项目时Key随便写:"shop1"、"shop_1"、"shop:1"混着用,最后排查问题的时候想死的心都有。缓存Key必须有一套清晰、统一的规范,黑马点评里对商户缓存Key的设计是cache:shop:{id},这个思路值得借鉴。

拆解一下这个Key的三段结构:

  • cache:前缀,标识这是缓存数据,方便和业务Key区分。
  • shop:业务模块名,代表商户。
  • {id}:具体业务标识,即商户ID。

这种"前缀:模块名:唯一标识"的结构有个好处,就是可视化工具里能按前缀折叠筛选,线上排查问题一目了然。而且不同模块的Key天然隔离,不会出现误覆盖。

另外要注意的是过期时间规划。不能所有缓存都用一个固定TTL,商户详情这种基础数据,我一般设置30分钟;如果商户有评分、销量这类变化较快的字段,就缩短到5~10分钟。关键是让过期时间跟业务数据本身的"易变程度"匹配,数据变脸快,TTL就短,反之则长。

2.2 RedisTemplate的序列化:为什么缓存全是乱码

这是黑马点评里最经典的坑之一。很多人在跟着视频敲代码时,写完RedisTemplate存数据,然后用Redis Desktop Manager一看,发现存的key全是\xAC\xED\x00\x05t\x00\x06shop\x02这种乱码。这不是Redis坏了,而是默认用了JDK序列化。

RedisTemplate的默认序列化器是JdkSerializationRedisSerializer,它会把对象序列化成二进制字节,里面还带着类全限定名信息,既占用空间又完全不可读,更致命的是跨语言、跨项目无法解析。

正确做法是自定义RedisTemplate,设置key使用StringRedisSerializer,value使用GenericJackson2JsonRedisSerializer:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key使用字符串序列化器 StringRedisSerializer stringSerializer = new StringRedisSerializer(); // value使用JSON序列化器 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); return template; } }

这里还有一个细节容易被忽略:GenericJackson2JsonRedisSerializer序列化时会往JSON里塞一个@class字段,记录对象的全限定类名,用于反序列化时还原类型。这个字段一旦和Spring Boot内置的Jackson配置冲突,就可能导致反序列化失败。所以我个人在实际项目中更习惯用StringRedisTemplate+ 手动JSON转换(比如用ObjectMapper或者Hutool的JSONUtil),虽然多写一两行代码,但可控性最高,不会出现各种花式报错。

2.3 为什么同时要有空值缓存和过期时间

这一节先按下空值缓存的细节,后面讲到缓存穿透时会专门展开。这里想强调的是一个设计理念:Redis缓存不是"存了就完事",而是要围绕"缓存不命中怎么办"做防御设计。很多同学写缓存代码就是简单的"查缓存,没有就查库,再回填",三个步骤,但完全没有考虑"查库也没结果"的情况、"热点Key同时过期"的情况、"缓存服务挂了"的情况。后面的章节,全部都在解决这三个"意外情况"。


3. 缓存与数据库的一致性:更新策略该怎么选

3.1 Cache Aside:先更新数据库,再删除缓存

商户信息不是一成不变的,管理员会修改店铺状态、营业时间。那么问题来了:数据更新了,缓存怎么办?

业界最常用、也最稳妥的方案叫Cache Aside Pattern,核心就两句话:

  • 读的时候,先读缓存,读不到读数据库,然后回填缓存。
  • 写的时候,先更新数据库,然后删除缓存。

注意,是"删除缓存"而不是"更新缓存"。为什么?举个实际例子。假设一个商户的评分从4.5改到了4.8,你选择直接更新缓存里的店铺对象。但如果这个店铺还有其他模块在并发写入,比如同时有运营后台改了营业时间,两个线程各自拿着旧数据往缓存里盖,最后缓存里存的值就不知道是哪次更新的结果了。而删除缓存就不存在这个覆盖问题——下次读请求发现缓存没了,会去数据库捞最新数据重新回填。删除相比更新,既简单又安全。

把这段逻辑放到一个工具方法里,大概是这样:

public void updateShop(Shop shop) { // 1. 先更新数据库 shopMapper.updateById(shop); // 2. 再删除缓存 stringRedisTemplate.delete("cache:shop:" + shop.getId()); }

核心原则就是:以数据库为准,缓存只是速写本,DB更新完成后让缓存失效,由下一次读请求来重建。

3.2 为什么不能"先删缓存,再更新数据库"

很多同学会问:既然最终都是要删缓存,那我先删缓存再更新数据库行不行?功能上是一样能让缓存失效,但并发环境下会出现严重的脏数据问题。

想象这个时序:

  1. 线程A要更新商户数据,先删除了缓存。
  2. 线程B发起查询,缓存没命中,去数据库读到了旧数据(此刻线程A还没执行UPDATE)。
  3. 线程B把旧数据写回缓存。
  4. 线程A这才执行UPDATE,数据库变成新值,但缓存里已经躺着旧值。

结果就是缓存和数据库不一致,而且这个不一致会在TTL过期之前一直存在。反过来,先更新数据库再删缓存,虽然也不能完全避免并发问题,但概率要小得多,而且可以通过后面要讲的延迟双删进一步兜底。

这里有个隐藏知识点:为什么"先更新DB再删缓存"在大多数场景下都不会出问题?因为要让问题发生,需要"线程B在A删缓存之后、更新DB之前"这个极短的窗口期发起一次查询。这个窗口期只有几毫秒,而且必须发生在缓存Miss时。跟"先删缓存"方案比,出现脏数据的概率低了几个数量级。业务可以接受极小概率的短暂不一致时,Cache Aside就是最务实的方案。

3.3 延迟双删:把一致性残差再压一压

如果你对一致性要求更高,比如商户的价格、库存这类数据,可以用延迟双删。核心思路是:先删缓存,更新数据库,休眠一小段时间,再次删除缓存。

public void updateShopWithDelayDelete(Shop shop) { // 1. 先删缓存 stringRedisTemplate.delete("cache:shop:" + shop.getId()); // 2. 更新数据库 shopMapper.updateById(shop); // 3. 休眠500ms,等待可能出现的旧数据回填完成 Thread.sleep(500); // 4. 再次删缓存 stringRedisTemplate.delete("cache:shop:" + shop.getId()); }

第二次删除的目的,是清掉"线程A更新DB期间,线程B回填的旧缓存值"。这个延迟时间怎么定?原则是要大于一次读请求从缓存Miss到完成数据库查询并回填缓存的完整耗时。我实测一般业务中这个窗口在几十到几百毫秒之间,取500ms到1s相对安全。缺点是每次更新要阻塞几百毫秒,写频繁的业务不适合。

再往上走,就是不依赖休眠时间的方案:通过消息队列发送延迟消息,在指定时间后执行删除;或者直接订阅MySQL binlog,由消费端去删除缓存。这些属于最终一致性架构,应对高并发写场景更可靠,但对初学者来说理解成本高,项目里可以先记下,面试问到能说出思路就行。

3.4 更新策略选型小结

策略操作顺序优点缺点适用场景
Cache Aside先更新DB,再删缓存实现最简单,脏数据概率低极端并发下仍可能短时不一致绝大多数业务,黑马点评首选
延迟双删删缓存→更新DB→延迟删缓存进一步降低不一致窗口写操作有额外延迟对一致性要求更高的场景
先删缓存再更新DB删缓存→更新DB没有了并发下易出现旧值回填不推荐单独使用
订阅binlog异步删缓存更新DB→异步消费binlog→删缓存一致性高,无侵入业务代码引入额外中间件,复杂度高并发极高、一致性要求严苛的架构

4. 缓存穿透:不存在商户带来的流量冲击

4.1 穿透的本质:查一个根本不存在的数据

缓存穿透这个词听起来玄乎,实际场景特别简单:用户在电商App里搜一个不存在的商品,或者恶意攻击者故意构造一批乱填的ID去请求商户详情接口。这些请求的特点是:缓存里没有,数据库里也没有,于是每一次请求都直接穿透缓存打到数据库。

如果我每秒收到1000个cache:shop:99999999的查询,缓存里查不到,数据库也查不到,这1000个请求就全部变成了数据库压力。由于不存在的数据不会回填到缓存,这个状况会持续下去,数据库被无效查询拖垮。黑马点评的Shop接口虽然只是个学习项目,但你在真实业务里一定会遇到有人拿脚本扫你接口的ID枚举。

4.2 方案一:缓存空值,给不存在的商户也留个位置

穿透最简单的解法,就是"查不到也别放过它"——把空结果也缓存起来,设置一个较短的过期时间。这样下个请求再来,直接命中缓存里的空值,返回"商户不存在",数据库就不用再被轰炸了。

public Shop queryWithPassThrough(Long id) { String key = "cache:shop:" + id; // 1. 先查缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 考虑空值缓存的情况:缓存里存的是空字符串 if (shopJson != null && shopJson.isEmpty()) { return null; // 命中的是空值缓存 } // 2. 缓存未命中,查数据库 Shop shop = shopMapper.selectById(id); if (shop == null) { // 3. 数据库也没有,缓存空值,5分钟后过期 stringRedisTemplate.opsForValue().set(key, "", 5L, TimeUnit.MINUTES); return null; } // 4. 查到数据,回填缓存,过期时间30分钟 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); return shop; }

这段代码里有几个细节值得注意。第一个,判断非空用的是StrUtil.isNotBlank,而不是直接shopJson != null。因为如果是空值缓存,缓存里存的是空字符串,isNotBlank不成立,就会往下走查数据库,等于空值缓存白白浪费了。第二个,空值缓存的TTL一定不能太长,设5分钟足够挡一波攻击流量,时间太长会导致"本来已经新增的商户,用户查询仍然提示不存在"这类问题。

4.3 方案二:布隆过滤器,把不存在的请求挡在门外

空值缓存有个缺陷:如果一个攻击者每秒构造一万个不存在的ID,每个ID都要走一遍Redis查询+空值写入,虽然数据库压力缓解了,但Redis本身会积累大量无意义的空Key,内存被消耗。更优雅的方案是布隆过滤器。

布隆过滤器可以在Redis之外维护一个bitmap结构,把所有真实存在的商户ID预先存进去。查询时先用布隆过滤器判断"这个ID是否存在",如果过滤器说"不存在",就直接返回,连Redis都不查了;如果过滤器说"可能存在",才继续走正常的缓存查询流程。

// 初始化布隆过滤器:把DB中已有的商户ID全部放入 public void initBloomFilter() { List<Long> shopIds = shopMapper.getAllIds(); shopIds.forEach(id -> bloomFilterUtil.add("shopId", id)); } // 查询时先判断 public Shop queryWithBloomFilter(Long id) { if (!bloomFilterUtil.contains("shopId", id)) { // 布隆过滤器判定不存在,直接返回 return null; } // 后续走正常的缓存查询逻辑 return queryWithPassThrough(id); }

为什么说"可能存在"而不是"一定存在"?因为布隆过滤器的底层用多个哈希函数映射到bit数组的多个位上,不同元素有可能哈希后映射的位被覆盖。它允许一定概率的误判,但绝不漏判,也就是说:过滤器说"不存在"就一定不存在,说"存在"可能是误判。对穿透场景来说,这个特性刚好适用——被误判的请求最多是多查一次Redis和数据库,但攻击性ID里绝大多数会被精确拦截。

布隆过滤器的选择上,你可以用Redisson内置的RBloomFilter,也可以自研。黑马点评项目里用空值缓存就够,但面试官问"穿透怎么优化",布隆过滤器是一个绕不开的加分项。

4.4 参数校验是第一道防线,别忽略

很多菜鸟看缓存穿透的教程,一上来就写空值缓存、布隆过滤器,完全没想到最简单有效的办法是接口参数校验。商户ID是个Long类型,你可以先判断id是否为null、是否大于0、是否在合理范围。比如商户ID不会超过100万,那来了个id=999999999的直接在Controller层就拒绝掉,根本不用打到Service层。

这就像保安拦人:先检查身份证件(参数校验),再进大厅(布隆过滤器),进了大厅还能再登记一次(空值缓存)。三层防御各司其职,不是选一个就完事。


5. 热点商户的缓存击穿:互斥锁与逻辑过期

5.1 什么是击穿:热点key的魂飞魄散

缓存穿透和缓存击穿的区别,很多面试者容易搞混。穿透是"数据本身不存在",击穿是"数据存在,但缓存恰好过期,瞬间大量请求同时发现缓存Miss,一起去查数据库"。

为什么会发生击穿?前提是某个key特别热。比如黑马点评里的"评分最高店铺"接口,或者一个被推荐到首页Top榜的网红店,每秒可能有几千上万个请求同时查询它。这个key的缓存一旦到期,所有请求几乎在同一时刻发现缓存没数据,然后一起涌向MySQL。数据库在那一瞬间承受的压力,可能比穿透还要猛,因为它查的还都是真实存在的数据。

防击穿的思路有两个方向:一是让同一时刻只有一个线程能去查库,其他线程等着;二是干脆让缓存"永不过期",到期后异步刷新逻辑。对应两个经典方案——互斥锁和逻辑过期。

5.2 互斥锁方案:让第一个线程去回源

互斥锁的核心是Redis的setnx命令:只有第一个去查库的线程能成功设置这个锁,其他线程看到锁存在就进入等待或重试,等第一个线程把缓存重建好,再让等待的线程直接走缓存。

public Shop queryWithMutex(Long id) { String key = "cache:shop:" + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 1. 尝试获取互斥锁 String lockKey = "lock:shop:" + id; String requestId = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 10L, TimeUnit.SECONDS); if (!locked) { // 2. 获取锁失败,说明有其他线程在查库,休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return queryWithMutex(id); // 递归重试 } // 3. 成功获取锁,再次检查缓存(双重检查,防止上一个线程已重建缓存) try { shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 4. 查数据库重建缓存 Shop shop = shopMapper.selectById(id); if (shop == null) { stringRedisTemplate.opsForValue().set(key, "", 5L, TimeUnit.MINUTES); return null; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); return shop; } finally { // 5. 释放锁:只释放自己创建的锁 if (requestId.equals(stringRedisTemplate.opsForValue().get(lockKey))) { stringRedisTemplate.delete(lockKey); } } }

这个方案里有两个细节必须注意。第一,锁必须带超时时间,否则线程在查库过程中挂了,锁永远不释放,其他线程就会一直重试到最后超时。第二,释放锁时要校验值是不是自己加的,通过UUID做标识,否则可能出现线程A加锁后超时,锁自动释放,线程B获取锁,线程A结束时把线程B的锁误删了的问题。

递归重试的写法有个隐患:极端情况下锁一直被持有,递归调用链会无限加深。更稳妥的做法是加一个重试次数上限,比如循环最多尝试3次,超过次数直接返回null。

5.3 逻辑过期方案:让缓存永不过期

互斥锁会阻塞部分请求,对响应时间要求极高的场景不够优雅。逻辑过期是另一种思路:缓存数据里自己存一个过期时间字段,Redis那边不设置TTL(或者设一个很长的TTL),当发现逻辑过期时,不阻塞请求,而是先返回旧数据,同时异步去重建缓存。

// 缓存里存的数据结构:数据本身 + 过期时间 @Data public class RedisData { private LocalDateTime expireTime; private Object data; } public Shop queryWithLogicalExpire(Long id) { String key = "cache:shop:" + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(shopJson)) { // 缓存里没有数据,说明还在预热阶段,直接查库(同时回填) return queryWithPassThrough(id); } RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); LocalDateTime expireTime = redisData.getExpireTime(); Shop shop = (Shop) redisData.getData(); if (expireTime.isAfter(LocalDateTime.now())) { // 1. 未过期,直接返回 return shop; } // 2. 逻辑过期,尝试获取互斥锁进行异步重建 String lockKey = "lock:shop:" + id; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10L, TimeUnit.SECONDS); if (locked) { // 3. 获取锁成功,开启独立线程重建缓存 CACHE_REBUILD_EXECUTOR.submit(() -> { try { // 查库并重建缓存,填上新的过期时间 Shop dbShop = shopMapper.selectById(id); RedisData newData = buildRedisData(dbShop, LocalDateTime.now().plusMinutes(30)); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newData)); } finally { stringRedisTemplate.delete(lockKey); } }); } // 4. 不管是否抢到锁,都先返回旧数据 return shop; }

逻辑过期方案的精髓是用旧数据换取可用性。用户看到的数据可能已经是过期几秒、甚至几十秒前的,但对大多数查询场景无感知,而数据库的压力从"瞬间被打爆"变成了"后台一条线程慢慢刷新"。图上有个明显的取舍:互斥锁保证强一致性但牺牲了部分吞吐,逻辑过期保证吞吐但允许短暂脏读。

5.4 两个方案的取舍建议

维度互斥锁逻辑过期
一致性强一致,缓存必定最新弱一致,可能返回旧数据
性能部分请求需要等待请求不等待,吞吐高
实现复杂度较低较高,需管理异步线程池
缓存预热不依赖,冷启动自然重建需要提前预热
适用场景商户金额、价格等敏感数据商户介绍、评分等非敏感数据

黑马点评里的商户查询,理论上两个方案都能用。我的建议是学习阶段两个都写一遍,面试讲清楚取舍逻辑;真实项目里,如果商户数据不涉及实时性强的量级数据,逻辑过期用起来更舒服。但要注意CACHE_REBUILD_EXECUTOR线程池必须做参数限制,不然热点key一多,后台疯狂创建线程,内存和CPU照样会被拖垮。


6. 缓存雪崩:过期时间抖动与降级兜底

6.1 雪崩的两个层面:一个key和一群key

缓存击穿解决的是一个热点key过期引发的并发问题,而缓存雪崩解决的是大量key同时过期或Redis整体不可用导致的数据库海量压力。

大量key同时过期最常见的场景是:所有商户缓存都设置了固定的30分钟TTL,然后整点一到,几十万个key在同一秒钟集体失效。所有请求同时发现缓存Miss,流量瞬间全打到数据库。黑马点评数据量小看不出问题,但我可以负责任地告诉你,真实线上一旦发生这种同频过期,数据库连接池直接被打穿。

6.2 TTL随机化:最便宜的雪崩防御

解决大量key同时过期,最简单的策略就是给TTL加一个随机扰动值。原来所有缓存都是30分钟,现在变成25到35分钟之间随机分布。这样即使它们在同一个时刻写入缓存,过期时间也会错开,不会出现"整点大爆发"。

// 设置缓存时,在基础TTL之上加随机扰动 int ttl = 30 + new Random().nextInt(10); // 30~40分钟随机 stringRedisTemplate.opsForValue() .set(key, jsonStr, ttl, TimeUnit.MINUTES);

这个方案成本几乎为零,但很多人会忽略。凡是批量写入、批量预热的场景,都必须做TTL随机化。另外还有一个"缓存预热"的问题:如果服务重启后要一次性往Redis里灌大量缓存,应该用脚本慢速渐进式写入,不要一个循环全量灌,那等于主动制造雪崩。

6.3 Redis不可用时的三层兜底

如果雪崩不是因为过期时间,而是Redis本身挂了,那再多的TTL策略也没用。这时候要从高可用架构上想办法:

  • 集群化部署:用Redis主从复制加哨兵,或者Redis Cluster分片,让Redis具备故障转移能力。黑马点评单机Redis够学习用,但你要心里清楚生产环境的Redis不会这么裸奔。
  • 多级缓存兜底:在Redis前面加一层本地缓存(比如Caffeine),Redis挂了时请求还能从本地缓存拿到数据。缺点是本地缓存一致性弱,但"降级可用"比"完全不可用"强太多。
  • 限流降级:Redis彻底不可用且本地缓存也没命中时,接口层面做限流,超出阈值的请求直接返回兜底数据或者友好提示,保证服务不被拖死。等Redis恢复后再逐步放量。

这里有个经验值得分享:线上排查雪崩,第一件事不是看Redis,而是看数据库的慢查询日志和连接池监控。因为雪崩的最终表现是数据库被打垮,从数据库端能反推出是哪一批key集中失效,再顺着去查缓存写入逻辑里TTL是不是写死了。


7. 压测结果与排查记录:缓存到底快了多少

7.1 用JMeter给商户查询接口做了组对比

说了这么多原理,最终要落到数据上。我自己在本地起了一套Spring Boot服务,用JMeter对/shop/{id}接口做了三组对比:直连数据库、Redis缓存(含空值防护)、Redis缓存+逻辑过期。

环境是笔记本本地跑,MySQL和Redis都在同一台机器,配置比较寒碜,但相对结论还是有参考意义的:

压测组线程数循环次数平均响应时间吞吐量数据库QPS
直连数据库20010042ms约1200/s约1200
Redis缓存命中2001003ms约9800/s几乎为0
Redis缓存+互斥锁(击穿模拟)2001006ms约6200/s约100

数据非常直观:加了Redis缓存后,接口平均响应时间从42ms降到3ms,吞吐量翻了将近8倍,数据库的查询压力趋近于零。互斥锁方案因为部分请求需要等待锁释放,平均响应时间略高,但换来了数据一致性,代价可接受。

注意:本地压测的网络延迟、机器性能都会影响绝对值,但缓存带来的数量级提升是真实的,你在自己环境里跑一遍,也能得出同样的趋势。

7.2 实际开发中踩过的三个缓存相关的坑

1. 缓存雪崩的隐藏触发点:同一个循环批量设置预热。

有一回我把商户列表做了批量缓存预热,想着方便一点,一个for循环把所有店铺的缓存全部写好,统一设了30分钟过期。结果每到30分钟整点,后台数据库的CPU使用率就出现一次尖峰。排查了半天才反应过来,这是批量预热+固定TTL造成的"人为雪崩"。改成随机TTL后尖峰立刻消失。

2. RedisTemplate的泛型类型问题导致的反序列化失败。

用GenericJackson2JsonRedisSerializer时,如果缓存的对象里包含泛型字段,反序列化时Jackson经常报类型转换异常。比如Shop对象里有个List<String>的标签字段,序列化时正常,反序列化就报"cannot deserialize"或者最终得到一个ArrayList里全是LinkedHashMap的怪物。解决方式是存之前手动转成JSON字符串,读出来再手动解析,或者用TypeReference指定类型。所以我现在基本都用StringRedisTemplate手动处理JSON,省心。

3. 缓存穿透防护与新增数据的矛盾。

某次商户模块上线了新店铺录入功能,录入完立刻在后台查看店铺详情,结果一直提示"店铺不存在"。排查后发现是新店铺ID在空值缓存里存了一定时长,录入后空值缓存还没过期,导致查询被误拦。后来处理方式是:录入或更新数据时,主动删除该ID的空值缓存,保证新建数据立即可见。

7.3 排查缓存问题的方法论

按照重要性排序,给大家一套排查缓存问题的固定套路:

  1. 先看DB慢查询:是不是有一批SQL执行时间异常变长?这说明缓存Miss率升高了。
  2. 查Redis命中率:用INFO stats命令看keyspace_hits和keyspace_misses的比值,命中率骤降说明大量key失效或者穿透。
  3. 看key分布:用SCAN命令配合前缀扫描,确认是不是有成片key同时过期,TLL是否过于均匀。
  4. 复现请求链路:在接口里打日志,记录缓存hit/miss、DB查询耗时、缓存回填耗时,定位瓶颈是Redis网络还是DB查询。

这套流程基本能覆盖90%的缓存问题排查。最后一招是看Redis的慢查询日志或者monitor命令,不过monitor慎用,线上开启它会拖垮Redis性能。


写这篇笔记的时候,我一直在想"菜鸟"和"老手"在缓存这事上的核心差距到底是什么。跟黑马点评的代码走了两遍之后我觉得,差距不在会不会敲互斥锁和逻辑过期那几十行代码,而在于遇到问题时的判断顺序——先分析业务场景是读多写少还是写多读少,再选缓存更新策略,再预测可能出现穿透还是击穿,最后才是动手写代码。这套从业务倒推设计的能力,比任何技术方案本身都值钱。

最后分享一个我自己坚持的习惯:不管项目多小,给每个缓存的Key都按"业务场景:标识"写好注释,TTL的取值在代码里标注依据。这样三个月后回头看,不需要重新翻需求文档,也能知道当初为什么这么设计。缓存这东西,代码写出来只是一瞬间的事,真正难的是让它长期稳定地跑在线上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询