别再背八股文了!Redis 缓存穿透、缓存雪崩?先搞懂这 30 行查询代码再说

前言

你刷了三天 Redis 缓存八股文,什么穿透、击穿、雪崩,背得滚瓜烂熟。然后打开 IDE,对着屏幕上这 30 行代码,光标闪了五分钟——第一行应该写什么?

我就是这样。

之前看到网上各种"缓存策略""缓存一致性"的文章,每个字都认识,凑在一起就是看不懂。后来我花了整整两周,一行一行地啃,把同事写的这段查询代码彻底搞懂了。

等你真正搞懂这 30 行代码怎么写、为什么这么写之后,那些八股文里的概念,不用背也自然会了。


环境说明

组件 版本 / 说明
JDK 1.8+
Spring Boot 2.x
Redis 任意版本,Spring Data Redis 操作
MyBatis-Plus getById() 是它提供的,一句 SQL 不用写
Hutool StrUtilJSONUtil 来自这里,工具库

这段代码到底在干什么?

先把代码完整贴出来,然后我拆开给你讲。看完这段,你就能理解我当时的感受——代码不多,但每一行都扛着一个知识点。

@Override
public Result queryById(Long id) {
     // 1. 构造 Redis 的 key
     String key = SystemConstants.CACHE_SHOP_KEY_PREFIX + id;

     // 2. 从 Redis 查缓存
     String shopJson = stringRedisTemplate.opsForValue().get(key);

     // 3. 判断缓存是否命中
     if (StrUtil.isNotBlank(shopJson)) {
         // 命中 → JSON 字符串转成 Java 对象 → 直接返回
         Shop shop = JSONUtil.toBean(shopJson, Shop.class);
         return Result.ok(shop);
     }

     // 4. 没命中 → 从数据库查
     Shop shop = getById(id);
     if (shop == null) {
         return Result.fail("店铺不存在");
     }

     // 5. 数据库查到了 → 写入 Redis,下次就不用查库了
     stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
     return Result.ok(shop);
}

这段代码的"学名"叫 Cache-Aside Pattern(旁路缓存模式),也叫缓存穿透防护的基础写法。不急着记名字,我们先看每一行到底在干嘛。


逐行拆解:初学者友好版

第 1 行:定义 key

String key = SystemConstants.CACHE_SHOP_KEY_PREFIX + id;

这里你可能会问:为什么不直接用 id 当 key?

因为 Redis 里存的不是只有店铺数据。如果所有数据的 key 都直接用 id,万一将来存了用户数据(key 也是 1)、订单数据(key 也是 1),就全串了。

所以 Redis 的 key 一般有个命名规范

数据 Key 示例 说明
店铺 cache:shop:1001 前缀区分数据类型
用户 cache:user:1001 和店铺的 id=1001 不冲突
订单 cache:order:20240718001 前缀 + 唯一标识

SystemConstants.CACHE_SHOP_KEY_PREFIX 就是一个常量,比如 "cache:shop:",拼上 id 就变成了 "cache:shop:1001"看到冒号了吗?Redis 客户端会把它显示成文件夹层级,方便你在可视化工具里按前缀浏览。


第 2 行:从 Redis 查缓存

String shopJson = stringRedisTemplate.opsForValue().get(key);

这一排方法名很长,我当初看到就头大。拆开看其实很简单:

部分 什么意思
stringRedisTemplate 一个现成的工具对象,专门操作 Redis 中的字符串
opsForValue() 告诉它"我要操作 String 类型的值"(Redis 有 5 种数据类型)
get(key) 拿着 key 去 Redis 里取值,有就返回,没有返回 null

所以整句话翻译成人话就是:“嘿,Redis,帮我看看 cache:shop:1001 这个 key 有没有值?”

注意这里拿到的是一个 String。因为 Redis 里存的是 JSON 字符串(后面会讲为什么),所以变量的类型是 String shopJson


第 3-6 行:判断缓存是否命中

if (StrUtil.isNotBlank(shopJson)) {
     Shop shop = JSONUtil.toBean(shopJson, Shop.class);
     return Result.ok(shop);
}
StrUtil.isNotBlank() 是什么?

StrUtil.isNotBlank() 是 Hutool 工具包的一个静态方法。要理解它,先看这张表:

方法 传入 null 传入 "" 传入 " "(空格) 传入 "abc"
isBlank() true true true false
isNotBlank() false false false true
isEmpty() true true false false

为什么用 isNotBlank 而不是 != null

因为在 Redis 里 get(key) 返回 null 确实表示"没有这个 key"。但如果有人在 Redis 里存了一个空字符串或一堆空格呢?isNotBlank 把这些"脏数据"也拦住了,更加安全。

一个小 TIP:StrUtilisNotBlank 内部先检查了 null,再检查了长度,最后检查了是否全是空格。不用你写三行 if 了。

JSONUtil.toBean() 在干什么?

现在 shopJson 的内容长这样:

{"id":1001,"name":"茶百道","type":2,"score":4.5}

这是个 JSON 字符串。但 Java 是强类型语言,你不能直接拿这个字符串去调 shop.getName()必须转成一个 Shop 对象。

JSONUtil.toBean(shopJson, Shop.class) 做的就是这件事:

JSON 字符串                          Java 对象
{"id":1001,"name":"茶百道"}   →   Shop{id=1001, name='茶百道'}

它把 JSON 里的每个字段名,去 Shop 类里找同名的属性,把值填进去。这就是反序列化(Deserialize)


第 7-10 行:缓存没命中,查数据库

Shop shop = getById(id);
if (shop == null) {
     return Result.fail("店铺不存在");
}

getById(id) 是 MyBatis-Plus 提供的。它自动拼出 SQL SELECT * FROM shop WHERE id = ?,把结果封装成 Shop 对象返回。

这里有个细节:MyBatis-Plus 只需要你写一个继承 BaseMapper<T> 的接口,getById 就能用了。 对初学者来说,你只需要知道它帮你查数据库就行,具体怎么做到的以后慢慢看。

如果数据库里也没这个 id,说明这个 id 压根不存在,直接返回"店铺不存在"。


第 11-13 行:数据库查到了,回写 Redis

stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop);

这是整套流程的"点睛之笔"。来,我把这行拆开:

  • shop 是一个 Shop 对象(Java 对象)
  • JSONUtil.toJsonStr(shop) 把 Shop 对象**序列化(Serialize)**成 JSON 字符串
  • stringRedisTemplate.opsForValue().set(key, json字符串) 把这个字符串存进 Redis

为什么要存 JSON 字符串,而不是直接把 Shop 对象存进去?

存储方式 说明 问题
存 Java 对象 需要用 RedisTemplate<Object, Object> 存进去是一堆乱码字节,别的语言读不了
存 JSON 字符串 StringRedisTemplate 人类可读,任何语言都能解析

这就是用 StringRedisTemplate 而不是 RedisTemplate 的原因。StringRedisTemplate 只处理字符串,序列化策略就是简单的 UTF-8 字符串,不存在乱码问题。


这张图把整个流程说清楚了

下面这张时序图搞懂了,整段代码你就真会了:

MySQL Redis Service(本段代码) 客户端 MySQL Redis Service(本段代码) 客户端 alt [数据库有数据] [数据库也没有] alt [缓存命中] [缓存未命中] queryById(1001) 查 cache:shop:1001 返回 JSON 字符串 JSON → Shop 对象(反序列化) 直接返回,不查数据库 返回 null SELECT * FROM shop WHERE id = 1001 返回 Shop 对象 Shop 对象 → JSON 字符串(序列化) 写入缓存 cache:shop:1001 返回结果 返回 null "店铺不存在"

你看,核心逻辑只有两条路:

  1. 缓存有 → 直接返回,秒级响应
  2. 缓存没有 → 查数据库 → 写缓存 → 返回,下次就不用查库了

这就是 Cache-Aside 模式的核心思想:以数据库为准,缓存只是加速。


几个你可能会问的问题

Q1:为什么缓存命中后不用再查数据库?

因为 Redis 存在内存里,读取速度是微秒级的。MySQL 存在磁盘上,读取速度是毫秒级的。差了 1000 倍。

第一次查询虽然慢(查库 + 写缓存),但从第二次开始,Redis 直接命中,速度跟飞一样。

Q2:如果数据库的数据变了怎么办?

好问题。这段代码只处理了查询。如果用户更新了店铺信息,你需要把 Redis 里对应的缓存删掉更新,否则用户看到的是旧数据。这就是"缓存一致性"问题,八股文里常说的"缓存更新策略"。

简单说:更新数据库 → 删除缓存(下次查询时会重新从数据库加载),这叫"延迟双删"策略的简化版。

Q3:如果有人一直用一个不存在的 id 来查呢?

比如 id = -1,这段代码的逻辑是:

  1. 查 Redis → 没有
  2. 查 MySQL → 也没有
  3. 返回"店铺不存在"

Redis 里不会存这个 -1。那下次又有人查 -1 呢?又走一遍查库流程。 每次都穿透到数据库,这就是缓存穿透。解决办法是:把不存在的 id 也缓存一个空值进去,给个很短的过期时间。


总结

这段代码虽然只有 30 行,但把它彻底搞懂,你就能理解:

  1. Redis 缓存的基本流程:先查缓存,命中返回,未命中查库并回写
  2. 序列化与反序列化:Java 对象 ↔ JSON 字符串,跨语言可读
  3. Key 命名规范:用前缀区分数据类型,避免 key 冲突
  4. 查询过程的两种状态:缓存命中(快)和缓存未命中(慢)
  5. 缓存穿透的概念:不存在的 key 每次都打穿到数据库

如果你现在能对着这张时序图,把每一行代码的流程讲出来——恭喜你,你已经超过了 90% 只背八股文的人。

延伸思考:这段代码如果改成高并发场景,会有什么问题?两个线程同时查到同一个不存在的 id,会不会同时查两次数据库?这就是"缓存击穿"和"缓存雪崩"的话题了——留到下篇文章聊。


参考资料


本文为原创内容,转载请注明出处。

如果这篇文章对你有帮助,欢迎 点赞 👍、收藏 ⭐、关注 ➕,你的支持是我持续输出的动力!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐