[toc]
| 单词 | 词性 | 音标 | 含义 |
|---|---|---|---|
| penetrate | v. | /ˈpenɪtreɪt/ | 穿透(缓存穿透:查询一个不存在的数据,打到数据库) |
| breakdown | n. | /ˈbreɪkdaʊn/ | 击穿(缓存击穿:某个热点 key 过期瞬间高并发打爆数据库) |
| avalanche | n. | /ˈævəlɑːnʃ/ | 雪崩(缓存雪崩:大量 key 同时过期或 Redis 宕机) |
| cacheable | adj. | /ˈkæʃəbl/ | 可缓存的(@Cacheable 注解,方法结果自动缓存) |
| evict | v. | /ɪˈvɪkt/ | 驱逐(@CacheEvict 注解,删除缓存) |
| bloom | n. | /bluːm/ | 布隆(布隆过滤器,判断 key 是否存在,防穿透) |
| hot key | n. | /hɒt kiː/ | 热点键(被高并发访问的 key) |
| consistent | adj. | /kənˈsɪstənt/ | 一致的(缓存与数据库的数据一致性) |
Lecture07-Redis-缓存实战与三大问题
本讲是 Redis 在真实业务里最核心的一课:手动缓存、注解缓存、以及面试/生产必考的缓存穿透、击穿、雪崩。
1、缓存的基本模式
提问:为什么需要缓存机制?
回答:关系型数据库在硬盘存储数据,硬盘天然比内存速度更慢,查询关系型数据库耗时更长。所以从总体架构设计方面来说,大型项目一定会做缓存。做了缓存之后,大部分查询请求都是从缓存中拿取数据,这样还有一个好处是减轻关系型数据库的负担,让关系型数据库更多的负责写操作,读操作大部分由缓存承担。
1、读操作:Cache Aside(先查缓存)
① 查 Redis
├── 命中 → 直接返回(不查询数据库)
└── 未命中 → ② 查数据库 → ③ 回填 Redis(带过期)→ 返回
public Goods getGoods(Long id) {
// 1. 先查缓存
String key = "goods:" + id;
String json = stringRedis.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Goods.class); // 命中
}
// 2. 未命中查数据库
Goods goods = goodsMapper.selectById(id);
if (goods != null) {
// 3. 回填缓存(带过期时间)
stringRedis.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
}
return goods;
}
2、写操作:先更库、再删缓存
为什么是"删缓存"而不是"更新缓存"?因为更新缓存容易和并发读产生不一致(两个写同时改、乱序)。删掉缓存,下次读会重新从库回填,最终一致。
public void updateGoods(Goods goods) {
// 1. 先更新数据库
goodsMapper.updateById(goods);
// 2. 再删除缓存,让下次读重新回填
stringRedis.delete("goods:" + goods.getId());
}
2、注解缓存(@Cacheable)
Spring 提供声明式缓存,配一个 CacheManager 指向 Redis 即可。
1、启用缓存 + 配置 Redis 缓存管理器
spring:
cache:
type: redis
redis:
time-to-live: 30m # 默认缓存 30 分钟
cache-null-values: false # 不缓存空值
2、使用注解
@Cacheable(value = "goods", key = "#id")
public Goods getGoods(Long id) { // 命中缓存直接返回,未命中才执行方法并自动缓存
return goodsMapper.selectById(id);
}
@CacheEvict(value = "goods", key = "#goods.id")
public void updateGoods(Goods goods) { // 执行后删除缓存
goodsMapper.updateById(goods);
}
注解缓存适合简单场景;复杂缓存(手动控制过期、防穿透、分布式锁)用手动缓存更灵活。
3、缓存穿透(Cache Penetration)
1、现象
客户端持续查询数据库中不存在的数据,缓存永远不命中,每次都打到数据库——恶意攻击/异常请求可把数据库打崩。
请求 不存在ID → 缓存没有 → 数据库没有 → 每次都查库 ← 穿透!
2、解决方案
| 方案 | 思路 | 缺点 |
|---|---|---|
| 缓存空值 | 查库为 null 也写缓存(value=null,过期短,如 5 分钟) | 大量空值占用内存 |
| 布隆过滤器 | 请求前先判断 key 是否存在,不存在直接拒绝,不查库 | 需维护、有小误差 |
| 参数校验 | 非法/越界 ID 提前拦截 | 只能挡"明显非法" |
| 限流 / 黑名单 | 对异常来源限流 | 治标 |
推荐组合:参数校验 + 布隆过滤器 + 缓存空值兜底。
3、缓存空值代码
public Goods getGoods(Long id) {
String key = "goods:" + id;
String json = stringRedis.opsForValue().get(key);
if (json != null) {
if ("NULL".equals(json)) return null; // 命中的空值标记
return JSON.parseObject(json, Goods.class);
}
Goods goods = goodsMapper.selectById(id);
if (goods == null) {
// 缓存空值,防止穿透(过期时间设短些)
stringRedis.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
stringRedis.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
}
return goods;
}
4、缓存击穿(Cache Breakdown)
1、现象
某个热点 key(如秒杀商品、热搜)正好过期,一瞬间大量并发请求同时查库。
热点key过期瞬间 → 大量请求同时打数据库 ← 击穿!
2、解决方案
| 方案 | 思路 |
|---|---|
| 互斥锁 | 查库前先抢锁,只有抢到锁的线程查库回填,其余等待后走缓存 |
| 逻辑过期 | 缓存不设物理过期,value 里带过期时间,过期后异步刷新(缓存永在) |
互斥锁代码(基于 SETNX,第 8 讲完整讲锁)
public Goods getGoods(Long id) {
String key = "goods:" + id;
String json = stringRedis.opsForValue().get(key);
if (json != null) return JSON.parseObject(json, Goods.class);
String lockKey = "lock:goods:" + id;
// 抢锁(带过期,防止锁不释放)
Boolean ok = stringRedis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(ok)) {
try {
// 双重检查:可能别人已回填
json = stringRedis.opsForValue().get(key);
if (json != null) return JSON.parseObject(json, Goods.class);
Goods goods = goodsMapper.selectById(id);
stringRedis.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
return goods;
} finally {
stringRedis.delete(lockKey); // 释放锁
}
}
// 没抢到锁:睡一下再查缓存(简单降级)
Thread.sleep(50);
return getGoods(id); // 递归重试(生产一般用循环限次)
}
5、缓存雪崩(Cache Avalanche)
1、现象
- 大量 key 在同一时刻过期(如统一设置 30 分钟,同时写入的缓存同时失效)→ 大量请求同时打库。
- 或 Redis 整体宕机 → 所有请求直接压向数据库。
大量key同时过期 或 Redis宕机 → 海量请求打爆数据库 ← 雪崩!
2、解决方案
| 层面 | 方案 |
|---|---|
| 过期时间 | 设置过期时间时加随机值(30min + random(0~5min)),错开失效时刻 |
| Redis 高可用 | 主从 + 哨兵 / 集群,避免单点宕机(第 10、11 讲) |
| 多级缓存 | 本地缓存(Caffeine)兜底,Redis 挂了还有本地内存 |
| 服务降级 | 缓存不可用时返回旧数据/兜底数据,而不是打爆数据库 |
| 提前预热 | 高峰期前预热热点 key |
过期时间加随机值
// 缓存写入时给过期时间加随机扰动
int expire = 30 * 60 + ThreadLocalRandom.current().nextInt(5 * 60);
stringRedis.opsForValue().set(key, json, expire, TimeUnit.SECONDS);
6、缓存与数据库一致性(够用版)
| 策略 | 做法 | 一致性 |
|---|---|---|
| 先更库、再删缓存(推荐) | 写库成功→删缓存,读时重新回填 | 最终一致,简单 |
| 延迟双删 | 删缓存→等一小段时间→再删一次缓存 | 更强一致(能接受短暂不一致) |
| 分布式事务 / 监听 binlog | 更复杂,本课不做展开 | 强一致 |
生产最常用:先更新数据库,再删除缓存。若想进一步降低"删除缓存失败"的风险,可配合延迟双删或对删除做重试。
7、课堂练习
- 写一个
getGoods手动缓存方法:命中返回、未命中查库回填、缓存空值防穿透。 - 在热点 key 场景下,用
setIfAbsent实现互斥锁防击穿(参考上文代码)。 - 给缓存过期时间加随机扰动,模拟"错开过期"。
- (思考)为什么写操作要"删缓存"而不是"更新缓存"?用并发场景推理说明。
8、本节验证
- 掌握 Cache Aside 读/写模式(先查缓存、先更库再删缓存)✅
- 会用 @Cacheable / @CacheEvict 注解缓存 ✅
- 区分缓存穿透/击穿/雪崩三种问题及其解决方案 ✅
- 会写缓存空值、布隆过滤器、互斥锁、过期时间加随机值等应对代码 ✅