面试知识库

店铺缓存模块面经文档#

项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL


1. 模块概览#

店铺缓存模块解决的核心问题:用户查询店铺信息时,直接打 MySQL 压力大、响应慢

引入 Redis 作为缓存层后,派生出三个经典问题需要处理:

问题场景描述本项目解决方案
缓存穿透请求查询一个数据库中根本不存在的 key缓存空值
缓存击穿单个热点 key 过期瞬间,大量请求同时打到 DB互斥锁 / 逻辑过期(二选一)
缓存雪崩大量 key 同时过期,或 Redis 宕机TTL 随机化 + Redis 高可用

2. 整体架构图#

用户请求


┌─────────────────────────────────────────────────────┐
│                   CacheClient(工具类)               │
│                                                      │
│  queryWithPassThrough()   ← 解决缓存穿透              │
│  queryWithMutex()         ← 解决缓存击穿(互斥锁)    │
│  queryWithLogicalExpire() ← 解决缓存击穿(逻辑过期)  │
└─────────────────────────────────────────────────────┘
         │                  │
         ▼                  ▼
     Redis 缓存          MySQL 数据库
   (热点数据快速返回)   (缓存未命中时回源)
plaintext

3. 三大缓存问题详解#

3.1 缓存穿透#

问题定义:请求的 key 在 Redis 中不存在,且在 MySQL 中也不存在,导致每次请求都穿透缓存直接打到 DB。恶意攻击者可以构造大量不存在的 key,将 DB 打垮。

本项目解决方案:缓存空值

请求到来


查 Redis
    ├── 命中(非空值)→ 直接返回数据 ✅
    ├── 命中(空值"")→ 返回 null,不查 DB ✅(拦截穿透请求)
    └── 未命中 → 查 MySQL
                    ├── DB 有数据 → 写入 Redis(正常 TTL)→ 返回数据 ✅
                    └── DB 无数据 → 写入空值到 Redis(TTL=2min)→ 返回 null ✅
plaintext

关键代码:

缓存空值的缺点

  • 浪费 Redis 内存(存储大量空 key)
  • 如果 DB 后续插入了该数据,空值缓存会导致短暂的数据不一致(靠 TTL 自然失效解决)

其他方案:布隆过滤器(对比见第5节)


3.2 缓存击穿#

问题定义:一个高频访问的热点 key 过期瞬间,大量并发请求同时发现缓存未命中,全部涌向 DB 重建缓存,造成 DB 瞬间压力激增甚至宕机。

本项目提供了两种解决方案,可通过注释切换:

方案一:互斥锁(Mutex Lock)#

核心思路:缓存未命中时,只允许一个线程去 DB 重建缓存,其余线程等待重试。

多个请求同时到达,缓存未命中

    ├── 线程1:抢到互斥锁 → 查 DB → 写 Redis → 释放锁 → 返回数据

    ├── 线程2:抢锁失败 → sleep(50ms) → 重试

    └── 线程3:抢锁失败 → sleep(50ms) → 重试(此时线程1已重建缓存,命中返回)
plaintext

关键代码:

互斥锁优缺点:

  • ✅ 数据强一致性,重建完成前其他线程拿到的是 DB 的最新数据
  • ❌ 等待线程会阻塞,吞吐量下降,极端情况下可能死锁(需要设置锁 TTL 兜底)

方案二:逻辑过期(Logical Expire)#

核心思路:热点 key 永不过期(不设 Redis TTL),但 value 中携带一个逻辑过期时间字段。发现逻辑过期时,异步开启新线程重建缓存,当前线程直接返回旧数据。

请求到来


查 Redis(key 永不过期,一定命中)

    ├── 逻辑时间未过期 → 直接返回旧数据 ✅

    └── 逻辑时间已过期

            ├── 尝试获取互斥锁
            │       ├── 获取成功 → 开新线程异步重建缓存 → 当前线程返回旧数据(可接受短暂脏读)
            │       └── 获取失败(其他线程已在重建)→ 当前线程直接返回旧数据
plaintext

关键代码:

逻辑过期优缺点:

  • ✅ 不阻塞线程,吞吐量高,几乎无等待
  • ❌ 数据有短暂不一致(脏读),需要业务上能接受
  • ❌ 需要提前预热(数据必须在活动前写入 Redis),冷启动有问题

3.3 缓存雪崩#

问题定义:大量 key 在同一时间段内同时过期,或 Redis 服务宕机,导致所有请求直接打到 DB,造成 DB 压力激增甚至宕机。

解决方案组合(本项目 + 通用方案):

方案描述是否在本项目中实现
TTL 随机化在基础 TTL 上加随机值,错开过期时间✅(可在 set 时加随机偏移量)
Redis 高可用哨兵模式或集群模式,防止单点宕机❌(项目未部署集群)
服务限流降级Redis 宕机时启动熔断,控制打到 DB 的流量❌(设计层面提到)
多级缓存本地缓存(Caffeine)兜底❌(仅点赞模块有)

TTL 随机化示例:

// 基础 TTL + 随机 0~5分钟,防止同批数据同时过期
long randomTTL = time + new Random().nextInt(5);
stringRedisTemplate.opsForValue().set(key, value, randomTTL, unit);
java

4. 缓存与数据库一致性#

4.1 更新策略:先更新 DB,再删除缓存#

本项目采用的策略:更新数据库,然后删除 Redis 缓存(Cache-Aside 模式的写路径)。

@Override
@Transactional
public Result update(Shop shop) {
    Long id = shop.getId();
    // 1. 先更新数据库
    updateById(shop);
    // 2. 再删除缓存(而非更新缓存)
    stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}
java

为什么是「删除缓存」而不是「更新缓存」?

更新缓存需要经过业务逻辑重新计算(可能涉及多表),成本高;而且在并发写场景下,两次更新的顺序难以保证,更容易造成数据不一致。删除缓存简单粗暴,下次读时直接从 DB 重建。

为什么先更新 DB,再删缓存?

若先删缓存再更新 DB:线程A删除缓存后、更新DB前,线程B读取缓存未命中,查DB拿到旧值写入缓存,之后线程A更新DB完成——缓存中就是旧数据,且这个旧数据会一直存在直到 TTL 过期。

先更新 DB 再删缓存:极端情况下也可能有短暂不一致,但概率极低(需要查询请求恰好在更新 DB 和删缓存之间完成),且通过 TTL 最终一致。

4.2 延迟双删(补充方案)#

对于先删缓存再更新 DB 的场景,可以加入延迟双删来降低不一致风险:

1. 删除缓存
2. 更新数据库
3. sleep 一段时间(等待其他线程可能的旧值读取完成)
4. 再次删除缓存
plaintext

缺点:需要评估 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 再删缓存,保证一致性。