外观
店铺缓存模块面经文档
项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL
1. 模块概览
店铺缓存模块解决的核心问题:用户查询店铺信息时,直接打 MySQL 压力大、响应慢。
引入 Redis 作为缓存层后,派生出三个经典问题需要处理:
| 问题 | 场景描述 | 本项目解决方案 |
|---|---|---|
| 缓存穿透 | 请求查询一个数据库中根本不存在的 key | 缓存空值 |
| 缓存击穿 | 单个热点 key 过期瞬间,大量请求同时打到 DB | 互斥锁 / 逻辑过期(二选一) |
| 缓存雪崩 | 大量 key 同时过期,或 Redis 宕机 | TTL 随机化 + Redis 高可用 |
2. 整体架构图
用户请求
│
▼
┌─────────────────────────────────────────────────────┐
│ CacheClient(工具类) │
│ │
│ queryWithPassThrough() ← 解决缓存穿透 │
│ queryWithMutex() ← 解决缓存击穿(互斥锁) │
│ queryWithLogicalExpire() ← 解决缓存击穿(逻辑过期) │
└─────────────────────────────────────────────────────┘
│ │
▼ ▼
Redis 缓存 MySQL 数据库
(热点数据快速返回) (缓存未命中时回源)3. 三大缓存问题详解
3.1 缓存穿透
问题定义:请求的 key 在 Redis 中不存在,且在 MySQL 中也不存在,导致每次请求都穿透缓存直接打到 DB。恶意攻击者可以构造大量不存在的 key,将 DB 打垮。
本项目解决方案:缓存空值
请求到来
│
▼
查 Redis
├── 命中(非空值)→ 直接返回数据 ✅
├── 命中(空值"")→ 返回 null,不查 DB ✅(拦截穿透请求)
└── 未命中 → 查 MySQL
├── DB 有数据 → 写入 Redis(正常 TTL)→ 返回数据 ✅
└── DB 无数据 → 写入空值到 Redis(TTL=2min)→ 返回 null ✅关键代码:
java
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type); // 命中非空值,直接返回
}
if (json != null) {
return null; // 命中空值"",拦截穿透,不查 DB
}
R r = dbFallback.apply(id); // 未命中,查数据库
if (r == null) {
// DB 也没有,缓存空值,TTL=2分钟
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
this.set(key, r, time, unit); // DB 有数据,写入缓存
return r;
}缓存空值的缺点:
- 浪费 Redis 内存(存储大量空 key)
- 如果 DB 后续插入了该数据,空值缓存会导致短暂的数据不一致(靠 TTL 自然失效解决)
其他方案:布隆过滤器(对比见第5节)
3.2 缓存击穿
问题定义:一个高频访问的热点 key 过期瞬间,大量并发请求同时发现缓存未命中,全部涌向 DB 重建缓存,造成 DB 瞬间压力激增甚至宕机。
本项目提供了两种解决方案,可通过注释切换:
方案一:互斥锁(Mutex Lock)
核心思路:缓存未命中时,只允许一个线程去 DB 重建缓存,其余线程等待重试。
多个请求同时到达,缓存未命中
│
├── 线程1:抢到互斥锁 → 查 DB → 写 Redis → 释放锁 → 返回数据
│
├── 线程2:抢锁失败 → sleep(50ms) → 重试
│
└── 线程3:抢锁失败 → sleep(50ms) → 重试(此时线程1已重建缓存,命中返回)关键代码:
java
public <R, ID> R queryWithMutex(...) {
// ...查 Redis,命中直接返回...
// 缓存未命中,尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit); // 递归重试
}
// 拿到锁,查 DB,写 Redis
r = dbFallback.apply(id);
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey); // 确保锁释放
}
return r;
}
// 利用 Redis SETNX 实现互斥锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}互斥锁优缺点:
- ✅ 数据强一致性,重建完成前其他线程拿到的是 DB 的最新数据
- ❌ 等待线程会阻塞,吞吐量下降,极端情况下可能死锁(需要设置锁 TTL 兜底)
方案二:逻辑过期(Logical Expire)
核心思路:热点 key 永不过期(不设 Redis TTL),但 value 中携带一个逻辑过期时间字段。发现逻辑过期时,异步开启新线程重建缓存,当前线程直接返回旧数据。
请求到来
│
▼
查 Redis(key 永不过期,一定命中)
│
├── 逻辑时间未过期 → 直接返回旧数据 ✅
│
└── 逻辑时间已过期
│
├── 尝试获取互斥锁
│ ├── 获取成功 → 开新线程异步重建缓存 → 当前线程返回旧数据(可接受短暂脏读)
│ └── 获取失败(其他线程已在重建)→ 当前线程直接返回旧数据关键代码:
java
public <R, ID> R queryWithLogicalExpire(...) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) return null; // 未预热,返回 null
// 反序列化,检查逻辑过期时间
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return r; // 未过期,直接返回
}
// 已过期,尝试获取互斥锁,异步重建
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> { // 开独立线程重建
try {
R newR = dbFallback.apply(id);
this.setWithLogicalExpire(key, newR, time, unit);
} finally {
unlock(lockKey);
}
});
}
return r; // 无论是否拿到锁,直接返回旧数据
}逻辑过期优缺点:
- ✅ 不阻塞线程,吞吐量高,几乎无等待
- ❌ 数据有短暂不一致(脏读),需要业务上能接受
- ❌ 需要提前预热(数据必须在活动前写入 Redis),冷启动有问题
3.3 缓存雪崩
问题定义:大量 key 在同一时间段内同时过期,或 Redis 服务宕机,导致所有请求直接打到 DB,造成 DB 压力激增甚至宕机。
解决方案组合(本项目 + 通用方案):
| 方案 | 描述 | 是否在本项目中实现 |
|---|---|---|
| TTL 随机化 | 在基础 TTL 上加随机值,错开过期时间 | ✅(可在 set 时加随机偏移量) |
| Redis 高可用 | 哨兵模式或集群模式,防止单点宕机 | ❌(项目未部署集群) |
| 服务限流降级 | Redis 宕机时启动熔断,控制打到 DB 的流量 | ❌(设计层面提到) |
| 多级缓存 | 本地缓存(Caffeine)兜底 | ❌(仅点赞模块有) |
TTL 随机化示例:
java
// 基础 TTL + 随机 0~5分钟,防止同批数据同时过期
long randomTTL = time + new Random().nextInt(5);
stringRedisTemplate.opsForValue().set(key, value, randomTTL, unit);4. 缓存与数据库一致性
4.1 更新策略:先更新 DB,再删除缓存
本项目采用的策略:更新数据库,然后删除 Redis 缓存(Cache-Aside 模式的写路径)。
java
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
// 1. 先更新数据库
updateById(shop);
// 2. 再删除缓存(而非更新缓存)
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}为什么是「删除缓存」而不是「更新缓存」?
更新缓存需要经过业务逻辑重新计算(可能涉及多表),成本高;而且在并发写场景下,两次更新的顺序难以保证,更容易造成数据不一致。删除缓存简单粗暴,下次读时直接从 DB 重建。
为什么先更新 DB,再删缓存?
若先删缓存再更新 DB:线程A删除缓存后、更新DB前,线程B读取缓存未命中,查DB拿到旧值写入缓存,之后线程A更新DB完成——缓存中就是旧数据,且这个旧数据会一直存在直到 TTL 过期。
先更新 DB 再删缓存:极端情况下也可能有短暂不一致,但概率极低(需要查询请求恰好在更新 DB 和删缓存之间完成),且通过 TTL 最终一致。
4.2 延迟双删(补充方案)
对于先删缓存再更新 DB 的场景,可以加入延迟双删来降低不一致风险:
1. 删除缓存
2. 更新数据库
3. sleep 一段时间(等待其他线程可能的旧值读取完成)
4. 再次删除缓存缺点:需要评估 sleep 时长(难以精确),且增加了响应时间。本项目未使用,了解即可。
5. 方案对比
5.1 缓存穿透:缓存空值 vs 布隆过滤器
| 维度 | 缓存空值 | 布隆过滤器 |
|---|---|---|
| 实现复杂度 | 低 | 中高 |
| 内存占用 | 高(大量空 key) | 低(位数组) |
| 误判率 | 无 | 有(存在一定假阳性) |
| 数据更新 | 自动过期 | 需要维护(新增数据要更新过滤器) |
| 适用场景 | 空值数量少且可预期 | 海量不存在 key 的攻击场景 |
本项目选择缓存空值:实现简单,且店铺数量有限,不会有海量的无效 key。
5.2 缓存击穿:互斥锁 vs 逻辑过期
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强一致 | 弱一致(可接受短暂脏读) |
| 吞吐量 | 低(等待线程阻塞) | 高(不阻塞,直接返回旧数据) |
| 实现复杂度 | 低 | 高(需要预热 + 封装逻辑过期字段) |
| 适用场景 | 数据一致性要求高 | 高并发热点且业务允许短暂脏读 |
| 典型场景 | 订单金额、库存数量 | 店铺信息、文章内容 |
本项目代码中两种方案均实现,通过注释切换,根据实际需求选择。
6. Redis 作为缓存的核心原理
6.1 为什么 Redis 比 MySQL 快?
- 存储介质:Redis 数据在内存中,读写不涉及磁盘 IO;MySQL 数据在磁盘上,查询涉及磁盘随机读写(即使有 Buffer Pool 也有局限)
- 数据结构:Redis 针对不同数据类型有专门优化的底层数据结构(SDS、跳表、压缩列表等)
- 单线程模型:Redis 命令执行是单线程(6.0后 IO 多线程),避免锁竞争开销,减少上下文切换
- 网络模型:Redis 使用 IO 多路复用(epoll),单线程高效处理大量并发连接
6.2 Redis SETNX 实现互斥锁的原子性
SET key value EX seconds NX 是一个原子操作(Redis 单线程保证),等价于"只有 key 不存在时才设置,同时设置过期时间"。这一原子性保证了在高并发场景下只有一个线程能成功抢锁。
早期分开执行 SETNX + EXPIRE 不是原子操作,若 SETNX 成功后服务崩溃,锁永不释放(死锁)。新版 Redis 的 SET ... NX EX 命令解决了这个问题。
6.3 逻辑过期中线程池的作用
缓存重建使用独立的线程池(CACHE_REBUILD_EXECUTOR),而不是直接 new Thread(),原因:
- 控制并发量:防止大量热点 key 同时过期时创建过多线程压垮系统
- 线程复用:避免频繁创建销毁线程的开销
- 任务队列:超出线程数的重建任务排队等待,不丢失
7. 极端场景与容灾
场景一:互斥锁方案中,持有锁的线程挂了,锁没有释放(死锁)
解决:tryLock 时使用 SET key value NX EX 10,给锁设置 10 秒自动过期时间,即使持有锁的线程崩溃,锁也会自动释放,不会永久死锁。
场景二:逻辑过期方案中,缓存还没预热,请求来了怎么办?
queryWithLogicalExpire 中,若 Redis 中没有该 key(未预热),直接返回 null。调用方需要处理这种情况,可以降级到查询 DB。
场景三:缓存更新时,先更新 DB 成功,再删缓存失败,怎么保证最终一致?
- 重试机制:删除缓存失败时,将删除任务发送到消息队列,异步重试
- 订阅 binlog:通过 Canal 监听 MySQL binlog,DB 更新后自动触发缓存删除(最终一致性)
- TTL 兜底:即使删缓存失败,缓存到 TTL 过期后也会自动重建,业务上接受短暂不一致
场景四:Redis 宕机时,所有请求直接打 DB,如何保护 DB?
- 限流:用令牌桶或漏桶算法限制进入 DB 的请求数
- 熔断降级:检测到 Redis 不可用,直接返回降级数据(如缓存的静态兜底数据)
- 本地缓存兜底:加入 Caffeine 本地缓存,Redis 不可用时提供部分保护
8. 面试题清单
8.1 基础实现层
| 题目 | 考察点 |
|---|---|
| 为什么要用 Redis 做缓存,相比直接查 MySQL 有什么优势? | Redis vs MySQL 特性 |
| 缓存穿透、缓存击穿、缓存雪崩分别是什么?如何解决? | 三大缓存问题 |
| 你的项目中用了什么方法解决缓存穿透? | 缓存空值实现 |
| 你的项目中用了哪些方法解决缓存击穿?分别在什么场景下用哪种? | 互斥锁 vs 逻辑过期 |
| 缓存更新时,你用的是什么策略?为什么先更新 DB 再删缓存? | Cache-Aside 模式 |
8.2 方案对比层
| 题目 | 考察点 |
|---|---|
| 互斥锁和逻辑过期解决缓存击穿,两者有什么区别?分别适合什么场景? | 一致性 vs 性能取舍 |
| 缓存空值和布隆过滤器解决缓存穿透,哪个更好? | 方案取舍,布隆过滤器原理 |
| 为什么删除缓存而不是更新缓存? | 并发写场景下的数据一致性 |
| 延迟双删是什么?有什么缺点? | 一致性方案了解 |
8.3 原理深挖层
| 题目 | 考察点 |
|---|---|
| Redis 为什么比 MySQL 快? | Redis 底层原理 |
| SETNX 为什么能实现互斥锁?它是原子操作吗? | Redis 原子性,SET NX EX |
| 逻辑过期方案为什么要用独立线程池重建缓存,而不是直接 new Thread? | 线程池作用 |
| 布隆过滤器的原理是什么?为什么有误判,能解决误判吗? | 布隆过滤器原理 |
8.4 极端场景层
| 题目 | 考察点 |
|---|---|
| 互斥锁方案里,持有锁的线程挂掉了,锁永远释放不了怎么办? | 死锁预防,TTL 兜底 |
| 缓存删除失败如何保证最终一致性? | Canal/binlog,重试机制 |
| Redis 宕机了,如何保护数据库不被打垮? | 限流、熔断、本地缓存 |
| 有一个 key 即将过期,此时来了 100 万并发请求怎么办?(击穿换个问法) | 互斥锁/逻辑过期 |
| 大量 key 同时过期,高并发下怎么办?(雪崩换个问法) | TTL 随机化,限流 |
| 有大量请求访问不存在的 key,且数据库也没有,怎么办?(穿透换个问法) | 缓存空值/布隆过滤器 |
9. 一句话总结(面试开场白模板)
店铺缓存模块在 MySQL 前加了一层 Redis 缓存。为了应对引入缓存后带来的问题,分别做了三方面处理:缓存穿透用缓存空值解决,当查询数据库不存在时把空值写入 Redis,防止恶意请求穿透到数据库;缓存击穿实现了互斥锁和逻辑过期两种方案,互斥锁保证数据强一致但会阻塞等待线程,逻辑过期让热点 key 永不真正过期,返回旧数据的同时异步重建,吞吐量更高但允许短暂脏读;缓存雪崩通过给 TTL 加随机偏移量来错开过期时间。缓存更新策略采用先更新 DB 再删缓存,保证一致性。