外观
01 - Redis 缓存设计
知识图谱
缓存三大问题
┌──────────┼──────────┐
▼ ▼ ▼
缓存穿透 缓存击穿 缓存雪崩
(不存在的key) (热点key过期) (大量key同时过期)
│ │ │
▼ ▼ ▼
缓存空值 ┌────┴────┐ 随机TTL
(2min TTL) ▼ ▼
互斥锁 逻辑过期
(queryWith (queryWith
Mutex) LogicalExpire)
│ │
▼ ▼
阻塞等待重建 返回旧数据+
50ms 递归重试 异步线程重建
──────── CacheClient 统一封装 ────────核心代码走读
文件: src/main/java/com/hmdp/utils/CacheClient.java
模式一:缓存空值防穿透 — queryWithPassThrough
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;
// 1. 查 Redis
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 有值(非空字符串) → 直接返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
// 3. 命中空值缓存(json != null 但 isBlank) → 返回 null
if (json != null) {
return null; // 这是之前缓存的空值 ""
}
// 4. 真正的缓存未命中 → 查 DB
R r = dbFallback.apply(id);
if (r == null) {
// DB也没有 → 缓存空值, TTL=2min, 防止穿透
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5. DB有数据 → 写入缓存
this.set(key, r, time, unit);
return r;
}关键设计:三段式判断 isNotBlank → != null → null,区分"有数据" / "空值缓存" / "未命中"。
模式二:互斥锁防击穿 — queryWithMutex
java
public <R, ID> R queryWithMutex(...) {
// ...省略缓存查询和空值判断(同上)...
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id; // lock:shop:{id}
R r = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
// 获取锁失败 → sleep 50ms → 递归重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 获取锁成功 → 查DB → 写缓存
r = dbFallback.apply(id);
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey); // ⚠️ 直接 DELETE, 无 owner 校验!
}
return r;
}模式三:逻辑过期防击穿 — queryWithLogicalExpire
java
public <R, ID> R queryWithLogicalExpire(...) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 缓存不存在 → 直接返回null (热点数据应预热, 不走DB)
if (StrUtil.isBlank(json)) {
return null;
}
// 反序列化 RedisData 包装对象
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;
}锁实现(CacheClient 内部)
java
// ⚠️ 简单版本, 与 SimpleRedisLock 形成对比
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key); // ⚠️ 直接删除, 无 owner 校验
}缓存一致性:Cache-Aside 模式
文件: src/main/java/com/hmdp/service/impl/ShopServiceImpl.java
java
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
// 1. 先更新 DB
updateById(shop);
// 2. 再删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}多级缓存架构
请求 → Caffeine L1 (本地, 100条, 2h过期)
↓ miss
Redis L2 (分布式, 30min)
↓ miss
MySQL L3 (持久化)
用于: LikeBehaviorServiceImpl 的热点文章/用户点赞计数
预热: @Scheduled 每2小时从 Redis 拉取热点数据到 Caffeine面试 Q&A
Q1: 缓存穿透、击穿、雪崩分别是什么?你们怎么解决的?
缓存穿透:查询一个不存在的 key,每次都打到 DB。
- 解决:缓存空值(
""),TTL=2分钟。布隆过滤器也可以,但本项目在缓存层用的是空值方案。缓存击穿:热点 key 过期瞬间,大量并发请求打到 DB。
- 解决:两种方案都实现了——互斥锁(阻塞等待)和逻辑过期(返回旧数据)。
缓存雪崩:大量 key 同时过期或 Redis 宕机。
- 解决:给 TTL 加随机偏移量;Redis 集群部署保证高可用。
追问:互斥锁和逻辑过期怎么选?
维度 互斥锁 逻辑过期 一致性 强一致(等到最新数据) 最终一致(可能返回旧数据) 可用性 低(等待期间不可用) 高(始终有数据返回) 实现复杂度 简单 需要额外的过期时间字段 适用场景 数据一致性要求高 高可用优先、容忍短暂不一致 项目中默认使用缓存空值防穿透(
queryWithPassThrough),但在注释中保留了切换到其他方案的代码。
再追问:逻辑过期方案中,如果重建线程抛异常了怎么办?
看代码:异常在
submit的 Runnable 中被 catch 后 re-throw 为RuntimeException,然后在finally里释放锁。问题是:缓存中仍然是旧的过期数据。下一个请求会再次发现过期,再次尝试获取锁并重建。所以是自愈的——重建失败不会导致永久不一致,只是延迟了数据更新。
Q2: CacheClient 的 tryLock/unlock 有什么问题?
严重问题:
unlock()直接delete(key),没有 owner 校验!场景:
- 线程 A 获取锁,开始重建缓存
- 重建耗时超过 10 秒,锁自动过期
- 线程 B 获取到锁,开始重建
- 线程 A 完成,调用
unlock()→ 删除了线程 B 的锁!- 线程 C 也获取到锁 → 多个线程同时重建
对比项目中的
SimpleRedisLock,后者使用unlock.lua做原子的 compare-and-delete,检查锁的 owner 是否匹配才删除。对于缓存重建场景,这个 Bug 的影响较小(最多多查几次 DB),但原则上应该修复。
追问:为什么不直接复用 SimpleRedisLock?
CacheClient是工具类,通过构造函数注入StringRedisTemplate;而SimpleRedisLock不是 Spring Bean,需要手动 new。可以考虑将SimpleRedisLock改为 Spring 管理,或在CacheClient中内联 Lua 脚本实现。
Q3: 先更新 DB 再删缓存会有什么问题?
经典问题:在极端并发下可能出现不一致:
时间线: T1: 线程A 更新DB(新值) T2: 线程B 查询缓存 miss → 查DB(新值) T3: 线程A 删除缓存 T4: 线程B 写入缓存(新值) ← 这种情况下是一致的但如果缓存写入和 DB 更新交叉:
T1: 线程B 查询缓存 miss → 查DB(旧值) T2: 线程A 更新DB(新值) T3: 线程A 删除缓存 T4: 线程B 写入缓存(旧值) ← 不一致!这种情况概率极低(要求 DB 写入比读取快),但理论上存在。
追问:怎么解决?
几种方案:
- 延迟双删:更新 DB → 删缓存 → sleep 一段时间 → 再删一次缓存
- Canal/Binlog 监听:通过 MySQL binlog 异步更新缓存
- 版本号/时间戳:缓存写入时携带版本号,旧版本不覆盖新版本
- 加锁:对同一个 key 的读写加分布式锁(但性能差)
项目中使用的"先更新 DB 再删缓存 + 短 TTL"方案,在多数业务场景下已经足够。
Q4: 缓存空值防穿透有什么局限性?
- 内存浪费:如果攻击者用随机 ID 请求,会缓存大量空值 key
- TTL 窗口:2 分钟内如果数据被创建,查询仍返回 null(短暂不一致)
更好的方案是 布隆过滤器,项目的点赞子系统就用了(
BloomFilterService)。但布隆过滤器有误判率且不支持删除(Counting Bloom Filter 可以但更占内存)。
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
StrUtil.isNotBlank vs != null | Hutool 的 isNotBlank 对 "" 返回 false | 空值缓存的边界判断 |
| 递归重试无上限 | queryWithMutex 递归调用自身,无最大重试次数 | 可能 StackOverflow |
| 逻辑过期无 Redis TTL | 数据永远不过期,Redis 内存一直增长 | 内存管理 |
CACHE_REBUILD_EXECUTOR 固定10线程 | 无界队列 + 固定线程,缓存大面积重建时可能积压 | 线程池设计 |
CacheClient.unlock 无 owner 校验 | 与 SimpleRedisLock 形成对比,可能误删他人锁 | 分布式锁正确性 |
加分回答
- 对比
isNotBlank("")= false vsisNotEmpty("")= false vs!= null,展示对 Hutool API 的熟悉 - 提到
queryWithMutex的递归可以改为while + 计数器,避免栈溢出 - 提到 RedisData 的逻辑过期方案不设 Redis TTL,需要配合定期清理或手动管理
- 分析多级缓存(Caffeine + Redis)的一致性问题:本地缓存更新滞后于 Redis
- 提到
@Transactional在ShopServiceImpl.update()上保证了 DB 更新和缓存删除的原子性(但缓存删除在事务提交前执行,如果事务回滚缓存已被删除 → 可能导致多一次缓存 miss)
关联文档
- 02-分布式锁 — CacheClient 内部锁与 SimpleRedisLock 的对比
- 07-点赞子系统 — 多级缓存的实际使用
- 11-已知问题与优化方向 — CacheClient.unlock 的 Bug