别再背八股文了!Redis 缓存穿透、缓存雪崩?先搞懂这 30 行查询代码再说
别再背八股文了!Redis 缓存穿透、缓存雪崩?先搞懂这 30 行查询代码再说
前言
你刷了三天 Redis 缓存八股文,什么穿透、击穿、雪崩,背得滚瓜烂熟。然后打开 IDE,对着屏幕上这 30 行代码,光标闪了五分钟——第一行应该写什么?
我就是这样。
之前看到网上各种"缓存策略""缓存一致性"的文章,每个字都认识,凑在一起就是看不懂。后来我花了整整两周,一行一行地啃,把同事写的这段查询代码彻底搞懂了。
等你真正搞懂这 30 行代码怎么写、为什么这么写之后,那些八股文里的概念,不用背也自然会了。
环境说明
| 组件 | 版本 / 说明 |
|---|---|
| JDK | 1.8+ |
| Spring Boot | 2.x |
| Redis | 任意版本,Spring Data Redis 操作 |
| MyBatis-Plus | getById() 是它提供的,一句 SQL 不用写 |
| Hutool | StrUtil、JSONUtil 来自这里,工具库 |
这段代码到底在干什么?
先把代码完整贴出来,然后我拆开给你讲。看完这段,你就能理解我当时的感受——代码不多,但每一行都扛着一个知识点。
@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:
StrUtil的isNotBlank内部先检查了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 字符串,不存在乱码问题。
这张图把整个流程说清楚了
下面这张时序图搞懂了,整段代码你就真会了:
你看,核心逻辑只有两条路:
- 缓存有 → 直接返回,秒级响应
- 缓存没有 → 查数据库 → 写缓存 → 返回,下次就不用查库了
这就是 Cache-Aside 模式的核心思想:以数据库为准,缓存只是加速。
几个你可能会问的问题
Q1:为什么缓存命中后不用再查数据库?
因为 Redis 存在内存里,读取速度是微秒级的。MySQL 存在磁盘上,读取速度是毫秒级的。差了 1000 倍。
第一次查询虽然慢(查库 + 写缓存),但从第二次开始,Redis 直接命中,速度跟飞一样。
Q2:如果数据库的数据变了怎么办?
好问题。这段代码只处理了查询。如果用户更新了店铺信息,你需要把 Redis 里对应的缓存删掉或更新,否则用户看到的是旧数据。这就是"缓存一致性"问题,八股文里常说的"缓存更新策略"。
简单说:更新数据库 → 删除缓存(下次查询时会重新从数据库加载),这叫"延迟双删"策略的简化版。
Q3:如果有人一直用一个不存在的 id 来查呢?
比如 id = -1,这段代码的逻辑是:
- 查 Redis → 没有
- 查 MySQL → 也没有
- 返回"店铺不存在"
Redis 里不会存这个 -1。那下次又有人查 -1 呢?又走一遍查库流程。 每次都穿透到数据库,这就是缓存穿透。解决办法是:把不存在的 id 也缓存一个空值进去,给个很短的过期时间。
总结
这段代码虽然只有 30 行,但把它彻底搞懂,你就能理解:
- Redis 缓存的基本流程:先查缓存,命中返回,未命中查库并回写
- 序列化与反序列化:Java 对象 ↔ JSON 字符串,跨语言可读
- Key 命名规范:用前缀区分数据类型,避免 key 冲突
- 查询过程的两种状态:缓存命中(快)和缓存未命中(慢)
- 缓存穿透的概念:不存在的 key 每次都打穿到数据库
如果你现在能对着这张时序图,把每一行代码的流程讲出来——恭喜你,你已经超过了 90% 只背八股文的人。
延伸思考:这段代码如果改成高并发场景,会有什么问题?两个线程同时查到同一个不存在的 id,会不会同时查两次数据库?这就是"缓存击穿"和"缓存雪崩"的话题了——留到下篇文章聊。
参考资料
- Hutool 官方文档 — JSONUtil
- Hutool 官方文档 — StrUtil
- Spring Data Redis 官方文档
- MyBatis-Plus 官方文档 — BaseMapper
- Redis 数据类型介绍
本文为原创内容,转载请注明出处。
如果这篇文章对你有帮助,欢迎 点赞 👍、收藏 ⭐、关注 ➕,你的支持是我持续输出的动力!
更多推荐

所有评论(0)