
刚把黑马点评项目里“商铺缓存”的正课啃完正准备松口气就看到了课后练习“商品类型缓存”。说实话第一眼觉得这题很简单——不就是把缓存的代码复制一遍换个实体类吗真动手做了才发现这个练习比正课更刁钻它把“单条数据缓存”换成了“整个列表缓存”序列化、泛型反序列化、空值处理、并发初始化全都在这一步暴露问题。折腾了一下午把这次练习的完整思路、踩坑过程和自查方法整理出来给同样卡在这道题上的朋友一个参考。黑马点评这个项目大家应该不陌生它是用 Redis 给商旅点评类业务做缓存加速的经典实战项目。这节课后练习的目标很明确把首页顶部的商品类型店铺分类列表做成缓存减少数据库压力。适合刚学完 Redis 缓存章节、准备做练习的人也适合想复盘“列表缓存怎么写才规范”的同学。1. 动笔之前先把“商品类型该不该缓存”这件事想透1.1 商品类型在点评项目里的真实身份打开点评类 App 首页顶部那一排横向滚动的分类导航——美食、酒店、景点、休闲娱乐——就是这里说的“商品类型”。在项目里它对应shop_type表实体类叫ShopType核心字段就这几个id、name类型名、icon图标地址、sort排序权重。前端首页加载时会一次性查全表并按sort排序展示。这个数据有什么特点体量极小撑死几十条变化频率极低商家分类一年半载都不一定改一次但读取频率极高每个用户打开首页都会触发一次查询。这就是典型的“读多写少、近乎静态”的数据。我见过不少新手一上来就说“数据量这么小查数据库也没事”这句话在单机、低并发时确实没错但一旦并发上来首页接口被高频刷每次都去全表扫shop_type数据库连接池会被这些琐碎查询白白占掉真正重要的写操作反而排不上队。RDBMS 擅长的是事务和复杂查询这种“读多写少”的场合恰恰应该交给 Redis 来扛。1.2 如果直接查数据库问题到底出在哪我们模拟一下一天早晨 10 点一万个用户同时打开首页每个请求都要执行一次SELECT * FROM shop_type ORDER BY sort。虽然单条 SQL 可能只有几毫秒但这一万次查询叠加起来数据库的连接池直接被打满后续所有请求排队等待。更麻烦的是这个查询结果对所有用户完全一样属于“一人查、万人用”的公共数据——这种数据不缓存等于让数据库反复做无用功。Redis 在这里扮演的角色就是“前置缓冲区”第一次有人查的时候去数据库取数据并放进缓存之后所有请求直接命中缓存数据库一次都不用碰。理解了这一点后面的代码逻辑其实就是在回答三个问题Key 怎么设计Value 用什么格式过期时间设多久2. 动手实现Key怎么设计、Value用什么形态存2.1 key命名不是随便写个字符串很多初学者写缓存key 就写死一个typeList跑通是能跑通但这是给自己埋雷。项目里缓存多了以后你根本分不清这个 key 是哪个业务的排查问题全靠猜。黑马项目里的规范是“业务前缀:模块名:唯一标识”商品类型列表我用的是cache:shop-type:list拆开看cache是统一前缀方便将来用KEYS cache:*批量清理shop-type说明这是商铺类型模块list表示缓存的是整个列表。对比正课里缓存单个商铺用的cache:shop:1你会发现规律完全一致前缀 业务名 区分维度。这样命名还有一个好处——商品类型一旦有改动可以直接按前缀精准删除不会误伤其他缓存。2.2 序列化方式选择为什么课堂里推荐JSON字符串而不是二进制这一步是我踩坑最久的地方。项目里默认注入的RedisTemplate如果不做任何配置Value 序列化器是 JDK 自带的JdkSerializationRedisSerializer。它会产生什么效果你往 Redis 里写数据存进去的是一坨带\xAC\xED开头的二进制垃圾用可视化工具根本读不出人话而且反序列化时必须依赖原来的实体类跨语言、跨系统直接歇菜。我这里强烈建议直接使用StringRedisTemplate它的 Key 和 Value 都是字符串序列化器固定为StringRedisSerializer。数据进去之前我们自己把对象转成 JSON 字符串读出来之后再把 JSON 反序列化成对象。整个过程可控、可读、可排查。我用个表格把三种方案摆在一起对比大家看得更清楚方案可读性体积跨语言性反序列化约束适用场景默认 JDK 序列化极差一堆乱码最大含类描述信息差只能 Java 读强依赖实体类完整路径不推荐仅本地测试GenericJackson2JsonRedisSerializer较好带class类型信息较大每条数据多存类型字段一般需要实体类但自动携带类型单条对象缓存时比较省事String 手动 JSON最好标准 JSON 字符串最小好任何语言都能解析手动指定泛型类型推荐列表缓存尤其合适商品类型是一个ListShopType如果用 GenericJackson2JsonRedisSerializer序列化时虽然也会得到 JSON但每条记录里会额外塞一个class字段数据冗余不说读的时候泛型信息依然容易丢失。所以最稳的方案就是Redis 里只存纯 JSON 字符串读写都由自己代码控制。2.3 过期时间也要想清楚商品类型几乎不更新那是不是就可以不设过期时间永久缓存我劝你别这么干。不设 TTL 意味着一旦将来后台新增了一个分类用户永远看不到只能靠手动删 key 才能恢复运维成本极高。缓存这类数据正确的姿势是“兜底过期 主动失效”。我给类型列表设置的过期时间是 30 分钟。为什么不选 24 小时因为如果 TTL 太长遇到紧急数据修正比如下架某个违规分类最长 24 小时内线上都是脏数据30 分钟是一个平衡点——用户能容忍半小时的延迟而数据库每天被真实查询的次数也极其有限。这里还有个进阶细节如果所有 key 都设成 30 分钟整零点时分一大批 key 同时过期数据库会被瞬间涌入的请求打崩这就是缓存雪崩的雏形。所以我在设置过期时间时通常会加一个随机扰动int expireTime 30 RandomUtil.randomInt(0, 10); stringRedisTemplate.opsForValue().set(key, json, expireTime, TimeUnit.MINUTES);这一点对单 key 列表意义不大但对整个项目里所有缓存都适用养成良好的随机过期习惯后面能少踩很多坑。3. 完整实现与实战中的“坑”逐一排查3.1 核心Service代码示例在ShopTypeServiceImpl里添加一个方法queryTypeList()核心逻辑就四步查缓存、缓存命中直接返回、缓存未命中查数据库、查完回填缓存。我的完整实现如下Override public ListShopType queryTypeList() { // 1. 从Redis查询缓存 String key cache:shop-type:list; String json stringRedisTemplate.opsForValue().get(key); // 2. 缓存命中直接返回 if (StrUtil.isNotBlank(json)) { return JSONUtil.toList(json, ShopType.class); } // 3. 缓存未命中查询数据库 ListShopType typeList this.list( new QueryWrapperShopType().orderByAsc(sort) ); // 4. 数据库查询结果为空缓存空集合防止缓存穿透 if (typeList null || typeList.isEmpty()) { stringRedisTemplate.opsForValue().set(key, [], 2, TimeUnit.MINUTES); return typeList; } // 5. 数据库查询结果非空回填缓存并设置过期时间 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList), 30, TimeUnit.MINUTES); return typeList; }这里我用的JSONUtil是 Hutool 的工具类JSONUtil.toList(json, ShopType.class)内部会正确处理 List 的泛型信息所以读出来直接就是ListShopType不存在类型转换问题。如果你用的不是 Hutool 而是 Spring 自带的ObjectMapper写法要稍作调整这个我在下一节详细说。Controller 端没有任何难点直接调 serviceGetMapping(/list) public Result queryTypeList() { return Result.ok(shopTypeService.queryTypeList()); }启动项目打开首页第一次请求会慢一点查库 回填缓存第二次开始就能明显感觉到响应变快Redis 里也能看到一个可读的 JSON 字符串。到这一步练习的基础功能已经完成了。3.2 最容易翻车的反序列化写法如果你没有用 Hutool而是用了ObjectMapper很大概率会遇到一个经典报错java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.example.domain.ShopType这个错我第一次见时一脸懵——明明 JSON 字符串里就是 ShopType 的字段为什么反序列化完变成了一堆 LinkedHashMap原因很简单ObjectMapper.readValue(json, List.class)只能告诉你“这是一个 List”但不知道 List 里装的是什么类型。Jackson 没有泛型信息只能把它当成最通用的LinkedHashMap。解决办法是明确告诉 Jackson 目标泛型类型用TypeReference包装一下ListShopType typeList objectMapper.readValue( json, new TypeReferenceListShopType() {} );同理如果你在 RedisConfig 里配置了GenericJackson2JsonRedisSerializer并用RedisTemplateString, Object直接读取读单个对象没问题但读 List 时同样要留意泛型丢失的问题。我的建议是列表缓存统一走StringRedisTemplate JSONUtil / ObjectMapper手动序列化责任清晰出现问题时也容易定位。3.3 空集合也要缓存顺手解决缓存穿透缓存穿透是什么就是大量请求查询一个数据库里根本不存在的数据导致缓存永远不生效每个请求都打到数据库。在商品类型这个场景里正常情况类型不会为空但如果哪天数据库里shop_type表被清空了而请求依然源源不断此时每个请求都会穿透到数据库并返回空结果这就是缓存穿透。代码里第 4 步处理的正是这个问题查不到数据时往 Redis 里写入一个空集合[]过期时间设短一点我设的 2 分钟这样短时间内后续请求都会命中缓存数据库得到喘息。等缓存过期后系统再自动尝试重新查询。有的同学可能会问空值缓存会不会造成“明明有数据了却查不到”的假象确实存在这种可能所以空值 TTL 一定要短2 分钟足以抵挡瞬时洪峰又不会导致长期数据不一致。生产环境还会配合布隆过滤器但课后练习掌握空值缓存就足够了。4. 练习做完之后的“自查清单”像面试官一样审视代码功能跑通只是第一步。题目既然叫“课后练习”那它一定在考察正课里的某些核心思想。做完之后我建议你按下面四个维度自己检查一遍。4.1 功能自查数据能不能正确写入、命中先开着 Redis 可视化工具比如 Another Redis Desktop Manager调用一次/shop-type/list观察 keycache:shop-type:list是否存在Value 是不是可读的 JSON 数组里面有没有class这种多余字段。然后连续刷新接口确认 Redis 里没有出现重复 key日志里也没有继续打印数据库查询 SQL。这里有一个小技巧我习惯把 MyBatis-Plus 的 SQL 日志打开如果同一个接口短时间内连续调用多次日志里只出现一次SELECT * FROM shop_type说明缓存生效了如果每次都出现说明缓存逻辑有问题要么没有写入要么 key 没对上。4.2 并发自查两个线程同时来查有没有写坏缓存这个点正课不会明说但面试必问高并发下第一个线程发现缓存没命中去查数据库还没写回第二个线程也发现缓存没命中也去查数据库。两个线程都查出相同数据都写回结果不会错但数据库被白查询了两次浪费了资源。更严重的情况是如果查询的是复杂数据多表联查重复查询的成本会很高。针对商品类型这种列表最简单的优化是在 Service 方法上加一个 JVM 级别的锁配合双重检查public ListShopType queryTypeList() { // 先查缓存 String json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toList(json, ShopType.class); } // 加锁防止多线程同时查库 synchronized (shopTypeLock) { // 双重检查拿到锁后再次查缓存 json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toList(json, ShopType.class); } // 查库 回填 } return typeList; }更通用、更面向分布的解法是用 Redis 自身的setnx实现互斥锁这也是黑马课程在“缓存击穿”章节的重点。课后练习阶段先在代码里理解“双重检查 锁”的思路后面学互斥锁会轻松很多。4.3 一致性自查如果类型变了缓存怎么办商品类型几乎不变但绝不是永远不变。如果运营在后台新增了一个类型前端什么时候能看到一种方式是等 30 分钟 TTL 过期这是被动失效另一种方式是后台执行变更操作时主动删除缓存这是主动失效。推荐用主动失效代码很简单// 新增或修改类型后删除缓存 stringRedisTemplate.delete(cache:shop-type:list);这样下一次请求发现缓存不存在就会重新查库并回填新数据实现了“最终一致性”。你可以在练习报告里明确写出这个策略比单纯“会写缓存代码”高一个段位。数据库和缓存双写时常见的坑是先删缓存、后更新数据库或者反过来导致缓存里存了旧数据。规范做法是“先更新数据库再删除缓存”配合缓存的延迟时间兜底可以最大限度保证一致性。5. 一次练习背后的面试考点商品类型缓存能延伸出什么问题5.1 缓存击穿与“逻辑过期”思路回到这个练习商品类型列表有一个显著特征它是一个热点 key所有用户进首页都会读它。假如这个 key 恰好在某个瞬间过期了成千上万个请求会同时打到数据库上——这就是缓存击穿。黑马课程里一般会用互斥锁setnx来解决第一个线程拿到锁去查库其他线程拿不到锁就先等一会缓存回填后大家继续读缓存。这个方案实现简单但缺点是“等锁”期间部分请求的响应时间会拉长极端情况下形成排队。进阶思路是逻辑过期把真实的过期时间写进 Value 里作为一个字段Redis 本身不设 TTL。每次读取时判断逻辑是否过期如果过期先返回旧数据同时异步开启一个线程去刷新缓存。这个方案对用户无感知但实现复杂容易造成短时间内数据不一致。两种方案的取舍如下方案优点缺点适用场景互斥锁setnx实现简单数据强一致锁等待期间有线程阻塞并发量中等对一致性要求高逻辑过期请求无阻塞性能更好实现复杂短时数据不一致并发量极高能容忍短暂脏读商品类型这个场景我建议先实现互斥锁因为代码量少逻辑清晰面试也能讲明白。5.2 缓存雪崩为什么在类型缓存上不是大问题雪崩是指大量 key 在同一时间集体过期或 Redis 宕机导致流量直接压到数据库。商品类型只有一个 key如果它过期了对整体数据库的影响远小于“几百个商铺 key 同时过期”。但这不代表可以不设随机过期时间——同一个项目里商品类型、商铺详情、用户信息等各类 key 如果都喜欢设整点过期它们叠加起来依然可能造成明显的流量峰值。我在第 2.3 节里提到的“过期时间加随机扰动”就是对付雪崩的最朴素手段。5.3 缓存穿透在自己项目里怎么预防前面说过的空值缓存是最轻量级的防穿透方案。企业项目里更常用的是布隆过滤器——请求到达时先判断这个 key 是否可能存在不存在直接返回根本不会打到数据库。不过布隆过滤器有误判率实现也复杂课后练习不要求掌握。你只需要明白商品类型列表因为数据基本固定穿透风险极低真正容易穿透的是用户昵称、订单号这类用户可控的数据代码里写空值缓存属于“习惯性防御”值得保留。我在实际练习中最深的体会是这道题表面上是“Redis 缓存数据”实际上是在逼你把缓存三兄弟穿透、击穿、雪崩提前过一遍。单条数据缓存时你只需要考虑“有没有”列表缓存时你必须考虑“有没有、是不是空、并发来了怎么办、写后如何更新”。做练习时别急着抄代码多问自己一层“为什么”收获会大得多。最后分享一个小技巧调试缓存时用redis-cli monitor实时观察 Redis 收到的命令你能看到哪些请求真正命中了缓存、哪些正在回填数据库比对着日志猜快很多。