Skip to content

店铺缓存模块面经文档

项目:黑马点评(类 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 再删缓存,保证一致性。