Skip to content

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!= nullnull,区分"有数据" / "空值缓存" / "未命中"。

模式二:互斥锁防击穿 — 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 校验!

场景:

  1. 线程 A 获取锁,开始重建缓存
  2. 重建耗时超过 10 秒,锁自动过期
  3. 线程 B 获取到锁,开始重建
  4. 线程 A 完成,调用 unlock()删除了线程 B 的锁!
  5. 线程 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 写入比读取快),但理论上存在。

追问:怎么解决?

几种方案:

  1. 延迟双删:更新 DB → 删缓存 → sleep 一段时间 → 再删一次缓存
  2. Canal/Binlog 监听:通过 MySQL binlog 异步更新缓存
  3. 版本号/时间戳:缓存写入时携带版本号,旧版本不覆盖新版本
  4. 加锁:对同一个 key 的读写加分布式锁(但性能差)

项目中使用的"先更新 DB 再删缓存 + 短 TTL"方案,在多数业务场景下已经足够。

Q4: 缓存空值防穿透有什么局限性?

  1. 内存浪费:如果攻击者用随机 ID 请求,会缓存大量空值 key
  2. TTL 窗口:2 分钟内如果数据被创建,查询仍返回 null(短暂不一致)

更好的方案是 布隆过滤器,项目的点赞子系统就用了(BloomFilterService)。但布隆过滤器有误判率且不支持删除(Counting Bloom Filter 可以但更占内存)。


踩坑点

踩坑点说明面试官考察意图
StrUtil.isNotBlank vs != nullHutool 的 isNotBlank"" 返回 false空值缓存的边界判断
递归重试无上限queryWithMutex 递归调用自身,无最大重试次数可能 StackOverflow
逻辑过期无 Redis TTL数据永远不过期,Redis 内存一直增长内存管理
CACHE_REBUILD_EXECUTOR 固定10线程无界队列 + 固定线程,缓存大面积重建时可能积压线程池设计
CacheClient.unlock 无 owner 校验SimpleRedisLock 形成对比,可能误删他人锁分布式锁正确性

加分回答

  • 对比 isNotBlank("") = false vs isNotEmpty("") = false vs != null,展示对 Hutool API 的熟悉
  • 提到 queryWithMutex 的递归可以改为 while + 计数器,避免栈溢出
  • 提到 RedisData 的逻辑过期方案不设 Redis TTL,需要配合定期清理或手动管理
  • 分析多级缓存(Caffeine + Redis)的一致性问题:本地缓存更新滞后于 Redis
  • 提到 @TransactionalShopServiceImpl.update() 上保证了 DB 更新和缓存删除的原子性(但缓存删除在事务提交前执行,如果事务回滚缓存已被删除 → 可能导致多一次缓存 miss)

关联文档