Redis学习笔记:缓存必知必会 —— 穿透、击穿、雪崩、一致性,从原理到实战

前言

缓存是提升系统性能最直接的手段,但用不好也会带来大麻烦。面试常问的三大问题——穿透、击穿、雪崩,加上线上最难缠的缓存一致性,这四个问题是 Redis 缓存实践中的核心关卡。

本文不讲八股文式的"是什么→怎么办",而是从实际问题出发,分析根因,对比方案,让你看完就知道怎么选。

一、缓存穿透

问题现象

请求一个不存在的数据,缓存里没有,数据库里也没有。每次请求都穿过缓存直接打到数据库:

请求 A: GET product:999999
  → 缓存 MISS
  → 数据库 SELECT,结果为空
  → 什么都不缓存,下次继续穿
  
请求 B: GET product:999999(重复)
  → 缓存 MISS
  → 数据库 SELECT,结果为空
  → ...

如果这种请求量很大(被恶意攻击或爬虫遍历不存在的 ID),数据库会被无效查询压垮。

根因

缓存穿透的本质:缓存只缓存了"有"的数据,"没有"的数据不缓存,导致每次都要查数据库确认没有。

解决方案

方案一:缓存空值(最简单有效)

思路:既然穿透的原因是"不存在的数据不缓存",那就把空结果也缓存起来。

示例

第一次请求 product:999999(不存在):
  缓存 MISS → 查数据库 → 结果为空
  → 把这个空结果也缓存起来:"product:999999 = 空标记"(TTL 5 分钟)
  
第二次请求 product:999999:
  缓存 HIT(空标记)→ 直接返回 null,不查数据库 ✅

这样同一个 ID 第二次来就不会穿透了。

public Product getProduct(String id) {
    // 1. 查缓存
    Product cache = redisTemplate.opsForValue().get("product:" + id);
    if (cache != null) {
        if (cache.isEmptyMarker()) {
            return null;  // 空值标记,直接返回 null
        }
        return cache;
    }

    // 2. 缓存未命中,查数据库
    Product db = productMapper.selectById(id);

    // 3. 缓存结果(无论是否为空)
    if (db == null) {
        // 空值也缓存,TTL 设短一些,防止长期占用内存
        redisTemplate.opsForValue().set("product:" + id, EMPTY_MARKER, Duration.ofMinutes(5));
    } else {
        redisTemplate.opsForValue().set("product:" + id, db, Duration.ofHours(1));
    }

    return db;
}

要点

  • 空值的 TTL 要短(3~5 分钟),避免内存浪费
  • 要用空值标记对象,和正常数据区分开
  • 适合不存在的数据量不大的场景

局限性:缓存空值只防同一个 ID 反复穿透,不防大量不同 ID 的穿透

攻击者遍历 ID:
  GET /product/不存在-1 → 缓存 MISS → 查数据库 → 缓存空值 ✅
  GET /product/不存在-2 → 缓存 MISS → 查数据库 → 缓存空值 ✅
  GET /product/不存在-3 → 缓存 MISS → 查数据库 → 缓存空值 ✅
  ...(每次换一个新 ID,每个都 MISS 一次)

每次 MISS 都实实在在地查了一次数据库,100 万个不存在的 ID 就查 100 万次,数据库照样被打垮。缓存空值只是"查完后把这个 ID 记住",但防不住攻击者换 ID

如果要防这种场景,需要和布隆过滤器配合布隆过滤器前置拦截,让不存在的 ID 连缓存 Miss 的机会都没有。

方案二:布隆过滤器(防恶意遍历)

思路:在缓存前面增加一道过滤,用布隆过滤器判断 ID 是否存在。判断为不存在的 ID 直接拦截,不再查询缓存和数据库。

和缓存空值的区别

缓存空值:来了一个不存在的 ID → 先查缓存 MISS → 再查数据库确认没有 → 把空结果缓存
          每次都查了数据库(只是同一个 ID 下次不查了)

布隆过滤器:来了一个不存在的 ID → 过滤器直接拦截 ❌
            → 根本不查缓存,更不查数据库
            → 100 万个不存在的 ID 也全部拦截,数据库 0 压力 ✅

布隆过滤器的实际工作过程

第一步 — 初始化(从数据库加载所有真实 ID 填入过滤器):

  数据库查询: SELECT id FROM product(返回 1000 万条真实 ID)
  逐条写入过滤器:
    添加 "product:1001" → 哈希 → 位置 1234567, 7654321, 5555555 → 置为 1
    添加 "product:1002" → 哈希 → 位置 2345678, 8765432, 6666666 → 置为 1
    添加 "product:1003" → 哈希 → 位置 3456789, 9876543, 7777777 → 置为 1
    ...(1000 万条全部写入)
  
第二步 — 查询(用过滤器判断 ID 是否存在):

  查询 "product:999999"(不存在的 ID):
    哈希函数 1 → 位置 1111111 → 不是 1 → ❌ 一定不存在
    直接返回 null ✅(不查缓存不查数据库)

  查询 "product:1001"(真实存在的 ID):
    哈希函数 1 → 位置 1234567 → 是 1 ✅
    哈希函数 2 → 位置 7654321 → 是 1 ✅
    哈希函数 3 → 位置 5555555 → 是 1 ✅
    三个位置都是 1 → 可能存在,放行(查缓存→查数据库)

代码实现:启动时加载所有存在的 ID 到过滤器,查询时先用过滤器判断。

public class ProductService {

    // 布隆过滤器:预计 1000 万数据,误判率 1%
    private final BloomFilter<String> bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()),
            10_000_000, 0.01);

    @PostConstruct
    public void init() {
        // 启动时从数据库加载所有存在的 productId
        List<String> allIds = productMapper.selectAllIds();
        allIds.forEach(bloomFilter::put);
    }

    public Product getProduct(String id) {
        // 布隆过滤器判断 id 是否存在
        if (!bloomFilter.mightContain(id)) {
            // 一定不存在,直接返回,连缓存都不用查
            return null;
        }

        // 可能存在(有误判率),走正常缓存流程
        return getFromCacheOrDb(id);
    }
}

要点

  • 只能判断"一定不存在",不能判断"一定存在"(有误判率,通常 1% 以内)
  • 适合 ID 总量可穷举的场景(如商品 ID 范围已知,启动时一次性加载)
  • 新增数据时不能立即反映到过滤器(需要定期重建),所以不能完全替代缓存空值的兜底
  • 空间效率极高:1 亿条数据、1% 误判率,只需要约 120MB 内存
对比 缓存空值 布隆过滤器
实现复杂度 中(需要全量初始化)
内存占用 取决于空 key 数量 固定(取决于数据量和误判率)
防御效果 受 TTL 限制 彻底拦截不存在 key
新增数据 不需要额外处理 需要更新过滤器

推荐:一般业务用缓存空值就够了。如果遇到明显的恶意遍历攻击,加一层布隆过滤器前置拦截。


二、缓存击穿

问题现象

一个热点 key 在某一时刻恰好过期失效,同时有大量并发请求访问这个 key:

时间轴:
  T0: 缓存中 hot:key 存在
  T1: 缓存中 hot:key 过期
  T2: 1000 个请求同时抵达
  
  请求 1-1000: GET hot:key
    → 缓存 MISS(同时发生)
    → 请求 1-1000 同时查数据库
    
  数据库 QPS 瞬间飙升到 1000,可能被打垮

和穿透的区别

  • 穿透:查询一个本来就不存在的 key,每次都穿过缓存
  • 击穿:查询一个存在但刚好过期的热点 key,大量请求同时穿过缓存

解决方案

方案一:互斥锁(Mutex)

思路:1000 个请求同时发现缓存过期,只让第一个人去查数据库重建缓存,其他人排队等他回来。

实际过程

T0: 缓存 hot:key 过期
T1: 请求 A 到达
     → 缓存 MISS
     → 获取锁 "lock:hot:key" ✅(成功)
     → 查数据库,重建缓存
     → 释放锁
T2: 请求 B 到达(请求 A 还在查数据库)
     → 缓存 MISS
     → 获取锁 "lock:hot:key" ❌(被 A 持有)
     → 等待 100ms 后重试
T3: 请求 B 重试
     → 缓存 HIT ✅(A 已经重建好了)
     → 直接返回
public String get(String key) {
    // 1. 查缓存
    String value = redisTemplate.opsForValue().get(key);
    if (value != null) return value;

    // 2. 缓存不命中,加锁重建(只让一个线程查数据库)
    String lockKey = "lock:" + key;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(locked)) {
        // 拿到了锁,查数据库
        try {
            // 双重检查:锁等待期间可能已经有其他线程重建了缓存
            value = redisTemplate.opsForValue().get(key);
            if (value != null) return value;

            // 查数据库
            value = queryFromDb(key);

            // 重建缓存
            redisTemplate.opsForValue().set(key, value, Duration.ofHours(1));
            return value;
        } finally {
            redisTemplate.delete(lockKey); // 释放锁
        }
    } else {
        // 没拿到锁,等待后重试
        Thread.sleep(100);
        return get(key); // 递归重试
    }
}

要点

  • 锁的 TTL 要大于查数据库 + 重建缓存的时间(否则锁提前释放,多个请求同时重建)
  • 双重检查避免锁等待期间缓存已被重建
  • 适合 QPS 极高的热点 key

方案二:热点 key 永不过期(逻辑过期)

思路:不让 key 真正过期(不设 TTL),而是在 value 里存一个"这个数据应该在哪一秒之前有效"。数据过期了也返回旧数据,同时后台异步更新。

实际过程

T0: 缓存 hot:key 数据 = "旧值",逻辑过期时间 = 12:00
T1: 现在 12:05,逻辑过期
     请求 A 到达 → 发现数据逻辑过期
     → 返回"旧值"给用户(不阻塞)
     → 发起异步任务重建缓存
T2: 请求 B 到达(重建还没完成)
     → 也返回"旧值"(不等待)
T3: 异步任务完成,缓存更新为"新值",逻辑过期时间延后
public class CacheItem<T> {
    private T data;
    private long expireTime;  // 逻辑过期时间戳
}

public String get(String key) {
    CacheItem<String> item = redisTemplate.opsForValue().get(key);
    if (item == null) return null;

    if (item.getExpireTime() < System.currentTimeMillis()) {
        // 逻辑过期,异步重建缓存
        asyncRebuildCache(key);
    }

    // 过期了也返回旧数据(最终一致,不阻塞读)
    return item.getData();
}

要点

  • 读操作不被阻塞,永远返回数据
  • 适合允许短时间读到旧数据的场景(如商品详情、配置)
  • 需要后台线程异步重建缓存

方案三:热点 key 自动延长 TTL

思路:热点 key 之所以会击穿,是因为它在某个时刻真的过期了。如果每次被访问时都检查它的剩余 TTL,太短了就续命,它就一直不会过期。

T0: key 过期时间 = 12:00
    请求 A 访问 key,检查 TTL 还剩 5 分钟 → 续期到 13:00
T1: 请求 B 访问 key,检查 TTL 还剩 30 分钟 → 不续期(还早)
T2: 请求 C 访问 key,检查 TTL 还剩 3 分钟 → 续期到 14:00
...
key 永远不会在热点期间过期 → 不会击穿
// 每次读取时,如果 TTL 低于阈值就自动续期
public String getWithAutoRenew(String key) {
    String value = redisTemplate.opsForValue().get(key);
    if (value != null) {
        Long ttl = redisTemplate.getExpire(key);
        if (ttl != null && ttl < 300) { // TTL 低于 5 分钟
            redisTemplate.expire(key, 3600, TimeUnit.SECONDS); // 续到 1 小时
        }
    }
    return value;
}

适合定时批量失效的热点数据,但不能解决所有击穿场景。

注意:不推荐默认对所有 key 开启自动续期。 原因有三个:

  1. 写放大:一次读操作本来只是 GET,现在要多一次 EXPIRE。读操作变成了写操作(改 TTL 也是写),QPS 10 万的场景下等于多出 10 万次命令
  2. 冷数据不淘汰:偶尔被访问的冷 key 每次 TTL 都被续到 1 小时后,一直占着内存不释放
  3. 热点 key 不需要:真正热门的 key 几分钟内被访问几百万次,根本活不到过期的时候,续 TTL 是白费力气
场景 是否推荐续期 原因
大部分缓存数据 ❌ 不推荐 固定 TTL 到期淘汰即可,简单可靠
已识别的热点 key ✅ 推荐 TTL 低于阈值时续期,减少重建次数
用户 Session ✅ 推荐 用户在线就应该保持 session 活跃
缓存空值 ❌ 不推荐 空值本身是临时标记,续期无意义
方案 优点 缺点
互斥锁 数据一致性好,实现简单 锁等待影响性能
逻辑过期 读不阻塞,性能最好 数据短暂不一致,实现复杂
自动续期 简单,无锁 不能防止首次过期击穿

三、缓存雪崩

问题现象

大规模缓存同时失效,或者 Redis 服务宕机,导致所有请求直接打到数据库:

场景 A:大量 key 同时过期
  T0: 10000 个 key 同时过期
  T1: 大量请求同时 MISS
  T2: 数据库 QPS 暴涨 → 打垮

场景 B:Redis 宕机
  T0: Redis 服务不可用
  T1: 所有请求 MISS
  T2: 数据库被打垮,紧接着应用也跟着宕机(服务雪崩)

场景 A:大量 key 同时过期

问题:假设你有 10000 个订单数据,都设 1 小时过期。如果它们都在同一分钟写入的,那么 1 小时后也会在同一分钟集体过期。此时正好有大量用户访问这批订单——缓存全部 MISS,数据库 QPS 暴涨。

10:00: 写入 10000 个 key,TTL = 1 小时
11:00: 10000 个 key 同时过期 ← 灾难
       大量请求同时 MISS → 数据库扛不住

解决方案

给过期时间加随机偏移量,让每个 key 的过期时间都不一样,错开数据库压力。

错误做法(集体过期):
  写入 10000 个 key  → 全部 TTL = 3600 秒
  1 小时后 → 10000 个 key 同时过期 ❌

正确做法(错峰过期):
  写入 key-1  → TTL = 3600 + 300 秒随机  (1 小时 5 分后过期)
  写入 key-2  → TTL = 3600 + 1200 秒随机  (1 小时 20 分后过期)
  写入 key-3  → TTL = 3600 + 870 秒随机   (1 小时 14 分后过期)
  ...
  1 小时后 → key 分散在不同时刻过期 ✅
// 错误做法:所有 key 在同一时刻过期
redisTemplate.opsForValue().set("order:" + id, data, Duration.ofHours(1));

// 正确做法:过期时间加随机偏移
long baseTtl = 3600;                          // 基础 1 小时
long randomOffset = ThreadLocalRandom.current()
        .nextLong(300, 1800);                // 随机偏移 5~30 分钟
redisTemplate.opsForValue().set("order:" + id, data,
        Duration.ofSeconds(baseTtl + randomOffset));

场景 B:Redis 服务宕机

方案一:熔断降级

思路:Redis 挂了,所有请求都会报错。与其让请求一直报错,不如在代码里主动 catch 异常,走备选方案(查本地缓存或直接查数据库)。

正常情况:
  请求 → Redis ✅ → 返回数据

Redis 宕机时:
  请求 → Redis ❌(异常)
       → catch 异常 → 查本地缓存 ✅ → 返回数据
       → 如果本地缓存也没有 → 查数据库(兜底)
public String get(String key) {
    try {
        return redisTemplate.opsForValue().get(key);
    } catch (RedisException e) {
        // Redis 不可用,降级读取本地缓存或直接查数据库
        return localCache.get(key, k -> queryFromDb(k));
    }
}

方案二:本地缓存 + Redis 两级缓存

思路:在应用内存中放一份"迷你缓存",每次查数据先看本地有没有。Redis 挂了,本地缓存还能扛一阵子。

请求到达:
  ① 查本地缓存(Caffeine)→ 有 ✅ 直接返回
  ② 没有 → 查 Redis → 有 ✅ 写回本地缓存,返回
  ③ Redis 宕机 → 忽略错误 → 查数据库(兜底)
public class TwoLevelCacheService {

    // 一级:本地缓存(Caffeine)
    private final Cache<String, String> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();

    // 二级:Redis
    private final RedisTemplate<String, String> redisTemplate;

    public String get(String key) {
        // 1. 查本地缓存
        String local = localCache.getIfPresent(key);
        if (local != null) return local;

        // 2. 查 Redis
        try {
            String remote = redisTemplate.opsForValue().get(key);
            if (remote != null) {
                localCache.put(key, remote); // 回填本地缓存
                return remote;
            }
        } catch (RedisException e) {
            // Redis 宕机:忽略,直接走数据库
        }

        // 3. 查数据库(兜底)
        return queryFromDb(key);
    }
}

要点

  • 本地缓存不宜太大(10k~100k 条),防止内存溢出
  • 本地缓存的 TTL 应远短于 Redis(分钟级),保证数据及时更新
  • 更新数据时,需要同时删除本地缓存和 Redis 缓存

方案三:限流 + 排队

思路:不管 Redis 有没有宕机,数据库的承受能力总是有限的。给"查数据库"这个操作加个流量阀——超过阈值就拒绝,返回降级数据或直接报错,让数据库不被冲垮。

请求 1~50: 查 Redis → MISS → 限流器 通过 → 查数据库 ✅
请求 51~100:查 Redis → MISS → 限流器 通过 → 查数据库 ✅
请求 101:  查 Redis → MISS → 限流器 拒绝 → 返回降级数据(旧缓存/空)
请求 102:  查 Redis → MISS → 限流器 拒绝 → 返回降级数据(旧缓存/空)
...
数据库 QPS 始终不超过 100,安全 ✅
// 使用 Sentinel 或 RateLimiter 对数据库查询接口限流
// 超出阈值的请求直接返回失败或降级数据

@SentinelResource(value = "queryDb", fallback = "queryDbFallback")
public String queryFromDb(String key) {
    return databaseClient.query(key);
}

public String queryDbFallback(String key, Throwable e) {
    return null; // 或返回本地缓存的旧数据
}

四、缓存一致性

问题

缓存和数据库是两套独立的存储,更新数据时必须保证两者最终一致。但以下操作会导致不一致:

写操作:
  T0: 更新数据库成功(SET name = "新值")
  T1: 删除缓存失败(网络抖动)
  T2: 缓存中还是旧值 → 后续读请求读到旧数据

读操作:
  T0: 读请求发现缓存 MISS
  T1: 读请求查数据库读到旧值
  T2: 写请求更新数据库为"新值"
  T3: 读请求将旧值写入缓存
  → 缓存中是旧值,数据库是新值(不一致!)

核心思想

更新数据时,先更新数据库(数据源),再删除缓存。这样下次读请求会因为缓存 MISS 而重新从数据库拉取最新数据。

更新策略对比

策略一:先更新数据库,再删除缓存(Cache-Aside)

思路:更新数据时,直接更新数据库,然后把缓存删掉。下次读的时候自然会查数据库拿到最新值,重建缓存。

为什么不直接更新缓存? 因为缓存里的数据可能是加工后的格式(JSON、聚合字段),更新数据库时不一定能直接算出缓存值。而且删缓存是 O(1) 操作,比更新简单可靠。

T0: 更新数据库 "price:1001" = 99
T1: 删除缓存 "price:1001"
T2: 用户查询 → 缓存 MISS → 查数据库得到 99 → 写回缓存
T3: 后续用户 → 缓存 HIT → 得到 99 ✅
public void update(String key, String newValue) {
    // 1. 更新数据库
    database.update(key, newValue);

    // 2. 删除缓存(不是更新缓存)
    redisTemplate.delete(key);
}

问题:删除缓存可能失败(网络抖动、Redis 异常)。缓存没删掉,下次读到的还是旧数据。

优化:延迟双删 + 重试。

策略二:延迟双删(最常用)

思路:删缓存 → 更新数据库 → 等 500ms 再删一次缓存。第二次删除是为了干掉"在第一次删除和更新数据库之间,被并发读线程写进去的旧数据"。

为什么需要删两次? 第一次删除后、数据库更新完成前,可能有并发读线程查到了旧数据并写回了缓存。等数据库更新完再删一次,确保这个中途写进去的旧缓存也被清掉。

并发问题的全过程

T0: 线程 A(写)  → 删除缓存 ❌(网络延迟,删除还没到 Redis)
T1: 线程 B(读)  → 缓存 HIT(旧数据)→ 返回旧值 ✅
     (线程 A 还不知道删缓存操作还在路上)
T2: 线程 A(写)  → 更新数据库(新值)
T3: 线程 A(写)  → 再次删除缓存(把线程 B 可能读到的旧数据也清掉)

第一次删除是为了"让后续读能拿到新值";第二次删除是为了"干掉并发读产生的旧缓存"。

public void update(String key, String newValue) {
    // 1. 先删除缓存
    redisTemplate.delete(key);

    // 2. 更新数据库
    database.update(key, newValue);

    // 3. 延迟再次删除缓存(解决并发读的问题)
    executorService.schedule(() -> {
        redisTemplate.delete(key);
    }, 500, TimeUnit.MILLISECONDS);
}

两次删除中间的 500ms 内,如果有其他线程读到了旧数据并重建了缓存,第二次删除会将其移除。

更可靠的版本:配合消息队列 + 重试(删除失败时 MQ 保证最终删除)

public void updateWithRetry(String key, String newValue) {
    // 1. 更新数据库
    database.update(key, newValue);

    // 2. 发送延迟删除消息到 MQ
    //    MQ 消费者收到消息后执行 redisTemplate.delete(key)
    //    如果删除失败,MQ 重试机制保证最终执行
    messageQueue.send(new CacheDeleteMessage(key), 
            DelayLevel.SECONDS_5);
}

策略三:先删除缓存,再更新数据库(风险最高 ⚠️ 不推荐)

思路:先把缓存清掉,再改数据库。

为什么很危险?两个问题:

问题 1:数据库更新失败,缓存已经被删了。

T0: 删缓存 ✅
T1: 更新数据库 ❌(SQL 报错、事务回滚)
T2: 缓存空了,数据库还是旧值
T3: 用户查询 → MISS → 查数据库(旧值)→ 写回缓存 ✅(旧值)
     数据是旧值但是一致(勉强能接受)

问题 2(更致命):并发读时会产生缓存长期不一致

T0: 线程 A(写) → 删缓存 ✅
T1: 线程 B(读) → MISS → 查数据库(还来得及读到旧值)
T2: 线程 A(写) → 更新数据库(新值)
T3: 线程 B(读) → 把旧值写回缓存 ❌
      → 缓存 = 旧值,数据库 = 新值(长期不一致!)

这个问题的根源是:删除缓存和更新数据库之间的时间窗口内,读线程可能把旧数据写回缓存。由于数据库和缓存是两套系统,没有事务保证,一旦缓存被旧数据填上,直到它下一次过期之前,所有读都是错的。

// 这是最不推荐的做法 ⚠️
public void updateProblematic(String key, String newValue) {
    redisTemplate.delete(key);     // 1. 先删缓存
    database.update(key, newValue); // 2. 再更新数据库
}

这也是为什么推荐"更新数据库 → 再删缓存"的原因——先更新数据库再删缓存,即使删除失败,下次读到旧数据时发现 TTL 快到了或触发某种校验,还有机会修正;而先删缓存再更新,一旦并发读线程介入,旧数据直接写回缓存,就没有挽回余地了。

线上推荐方案

策略 一致性保证 复杂度 推荐度
更新数据库 → 删缓存 最终一致 ⭐⭐⭐⭐
更新数据库 → 删缓存 + 消息队列重试 最终一致,可靠性高 ⭐⭐⭐⭐⭐
延迟双删 最终一致 ⭐⭐⭐⭐
先删缓存 → 更新数据库 不一致风险高 ⭐(不推荐)
分布式锁 + 读写分离 强一致 ⭐⭐⭐(性能代价大)

最终推荐的方案

@Service
public class CacheService {

    private final RedisTemplate<String, String> redisTemplate;
    private final RabbitTemplate mqTemplate;

    /**
     * 更新数据:更新数据库 → 删除缓存(失败则 MQ 重试)
     */
    @Transactional
    public void updateData(String key, String newValue) {
        // 1. 更新数据库
        database.update(key, newValue);

        // 2. 删除缓存
        try {
            redisTemplate.delete(key);
        } catch (Exception e) {
            // 3. 删除失败 → 发送 MQ 消息异步重试
            mqTemplate.convertAndSend("cache.delete.exchange",
                    "cache.delete.routing", 
                    new CacheDeleteMessage(key));
        }
    }
}

// MQ 消费者
@Component
public class CacheDeleteConsumer {

    @RabbitListener(queues = "cache.delete.queue")
    public void handleDelete(CacheDeleteMessage msg) {
        try {
            redisTemplate.delete(msg.getKey());
        } catch (Exception e) {
            // 重试还是失败?记录日志,人工介入
            log.error("缓存删除失败,key={}", msg.getKey(), e);
            throw new AmqpRejectAndDontRequeueException(e);
        }
    }
}

核心思想

  • 数据库作为权威数据源
  • 缓存只是加速层,最终以数据库为准
  • 删除缓存失败通过 MQ 异步重试保证最终删除
  • 不追求强一致,只追求最终一致

五、热点 Key 与 Big Key

热点 Key

问题:某个 key 被超高并发访问(如热搜、秒杀商品),所有请求集中打到 Redis 的同一个节点上,导致该节点 CPU 100%。

无本地缓存:
  100 万请求 → 全部打到 Redis 同一个节点 → CPU 100% → 超时 ❌

有本地缓存(分摊流量):
  100 万请求 → 本地缓存扛住 80%(应用内存)
              → 只有 20 万请求到 Redis → 节点压力降低 80% ✅

方案:本地缓存 + 分布式限流

// 热点 key 的识别:通过 Redis 的 HotKey 分析工具
// 或者业务层面预判(秒杀商品、热搜词)

// 处理方式:本地缓存扛流量,减少 Redis 压力
@Cacheable(value = "local:hot", unless = "#result == null")
public String getHotKey(String key) {
    return redisTemplate.opsForValue().get(key);
}

Big Key

问题:一个 key 的 value 有几 MB 大(比如存了商品的完整详情 JSON,包含图片 base64)。Redis 是单线程处理命令的,每次读写大 key 都要传输几 MB 数据,会阻塞其他所有命令。

正常小 key:0.1ms 处理完
Big Key(几 MB):10 秒传输 + 处理
在这 10 秒内,其他所有 Redis 命令排队等待 ← 拖垮整体性能

方案

  • 大 value 拆分为多个小 key(哈希结构或分段存储)
  • 压缩 value(gzip 压缩后再存)
  • 禁止在 Redis 中存储大文本或图片(Redis 不是对象存储)
// 压缩存储
public void setCompressed(String key, String value) {
    byte[] compressed = gzipCompress(value);
    redisTemplate.opsForValue().set(key, compressed, Duration.ofHours(1));
}

public String getCompressed(String key) {
    byte[] compressed = redisTemplate.opsForValue().get(key);
    if (compressed == null) return null;
    return gzipDecompress(compressed);
}

总结

问题 一句话根因 推荐解法
穿透 查询不存在的数据 缓存空值 或 布隆过滤器
击穿 热点 key 刚好过期 互斥锁 或 逻辑过期
雪崩 大量 key 同时过期 / Redis 宕机 TTL 加随机偏移 + 熔断降级 + 两级缓存
一致性 缓存和数据库独立更新 更新数据库 → 删缓存 + MQ 重试
热点 Key 超高并发访问单个节点 本地缓存分摊
Big Key value 过大阻塞网络 压缩或拆分
posted @ 2026-08-08 21:59  PC2005-cloud  阅读(26)  评论(0)    收藏  举报