Redis 缓存穿透、击穿与雪崩:三个问题的成因与标准解法

一、结论先给

三个问题名字像,成因和解法完全不同,先分清:

问题 触发条件 后果 核心解法
穿透 查询根本不存在的数据(如 id=-1、恶意随机 id) 每次都打穿到数据库 布隆过滤器 + 缓存空值
击穿 某个热点 key 正好过期,瞬间几万请求同时回源 数据库瞬时被打爆 互斥锁 / 逻辑过期
雪崩 大批 key 同一时刻集体过期,或 Redis 整体宕机 数据库压力骤增甚至宕机 TTL 随机化 + 多级缓存 + 高可用

一句话记:穿透是"查了个不存在的",击穿是"一个热的没了",雪崩是"一片都没了"。

👉 排查时先看数据库慢日志和 QPS 曲线,用 日志分析工具 统计错误高发时段,能快速定位是哪一个。

二、缓存穿透

2.1 成因

// 恶意请求或爬虫:id 是随机数,数据库里根本没有
GET /user?id=-1
GET /user?id=999999999

缓存查不到 → 查数据库也查不到 → 不会写进缓存 → 下次又打穿。攻击者用几万个随机 id 循环请求,数据库直接被打满。

2.2 解法一:缓存空值(最简单,首选)

public User getUser(Long id) {
    String key = "user:" + id;
    String json = redis.get(key);
    if (json != null) {
        return "".equals(json) ? null : parse(json);   // 空串表示"确认不存在"
    }
    User u = db.queryById(id);
    if (u == null) {
        redis.setex(key, 120, "");      // ⭐ 空值也缓存,TTL 短一些(2-5 分钟)
        return null;
    }
    redis.setex(key, 3600, toJson(u));
    return u;
}

要点:

  • 空值 TTL 要短(2-5 分钟),避免真有数据写入后长时间查不到;
  • 空值用特殊标记("" 或 "__NULL__"),别用 null(很多客户端无法区分"缓存里是 null"和"缓存没命中")。

2.3 解法二:布隆过滤器(数据量极大时用)

布隆过滤器:一个 bit 数组 + 多个 hash 函数。判定"不存在"是 100% 准确的,判定"存在"可能误判(误判率可配置)。

# Redis 4.0+ 通过模块支持(RedisBloom)
BF.RESERVE user_filter 0.001 10000000    # 误判率 0.1%,预计 1000 万元素
BF.ADD user_filter 10001
BF.EXISTS user_filter 10001              # 1 可能存在
BF.EXISTS user_filter -1                 # 0 一定不存在 ⭐
BF.MADD user_filter 1 2 3 4
BF.MEXISTS user_filter 1 2 -1

Java 用 Redisson:

RBloomFilter<Long> bf = redisson.getBloomFilter("user_filter");
bf.tryInit(10_000_000L, 0.001);        // 预计元素数、误判率

// 数据写入 DB 时同步加进过滤器
bf.add(user.getId());

// 查询时先过过滤器
if (!bf.contains(id)) return null;      // 一定不存在,直接返回

布隆过滤器的局限:不支持删除(计数布隆/Cuckoo Filter 可以),需要初始化时把所有合法 id 灌进去,数据新增时要同步 add。

2.4 解法三:接口层校验

// 最容易被忽略但最有效的一招
if (id == null || id <= 0) return Result.paramsError();
// 分页参数上限、id 格式校验、频率限制(同一 IP 每分钟最多 N 次)

性价比排序:参数校验 > 缓存空值 > 布隆过滤器。大多数业务做到前两条就够了。

三、缓存击穿

3.1 成因

某个超级热点 key(比如首页爆款商品、顶流主播信息)在某一秒过期,此时有 10 万并发请求进来,全部发现缓存没有,全部去查数据库。

3.2 解法一:互斥锁(一致性优先)

public User getHotUser(Long id) {
    String key = "user:" + id;
    String json = redis.get(key);
    if (json != null) return parse(json);

    String lockKey = "lock:user:" + id;
    // SETNX + 过期时间,原子实现(Redis 2.6.12+ 支持 NX EX 组合)
    Boolean ok = redis.set(lockKey, "1", "NX", "EX", 10);
    if (Boolean.TRUE.equals(ok)) {
        try {
            User u = db.queryById(id);        // 只有一个线程回源
            redis.setex(key, 3600, toJson(u));
            return u;
        } finally {
            redis.del(lockKey);
        }
    } else {
        Thread.sleep(50);                     // 没拿到锁,稍等重试
        return getHotUser(id);                // 递归重试(注意加次数上限)
    }
}

要点:

  • 锁必须设过期时间,否则持有锁的线程挂了就死锁;
  • 释放锁要校验是自己加的(防误删别人的锁),生产建议直接用 Redisson 的 RLock;
  • 没拿到锁的线程睡一下再重试,不要死循环空转。

3.3 解法二:逻辑过期(性能优先)

物理上永不过期,把过期时间存进 value,谁发现过期了谁去异步刷新,其他线程先返回旧值。

@Data
class CacheItem {
    private String data;      // 业务 JSON
    private long expireAt;    // 逻辑过期时间戳
}

public User getHotUser(Long id) {
    String key = "user:" + id;
    CacheItem item = redis.get(key);
    if (item == null) return null;                 // 预热过就不该为 null

    if (item.getExpireAt() > System.currentTimeMillis()) {
        return parse(item.getData());              // 未过期,直接返回
    }
    // 已逻辑过期:抢锁后开新线程刷新,自己先返回旧数据
    if (Boolean.TRUE.equals(redis.set("lock:user:" + id, "1", "NX", "EX", 10))) {
        executor.submit(() -> {
            try {
                User u = db.queryById(id);
                redis.set(key, new CacheItem(toJson(u), now + 3600_000));
            } finally {
                redis.del("lock:user:" + id);
            }
        });
    }
    return parse(item.getData());                  // ⭐ 立刻返回旧值,不阻塞
}
方案 一致性 性能 适用
互斥锁 强(返回的一定是新数据) 有等待 金融、库存
逻辑过期 弱(可能短暂返回旧值) 无等待,最优 首页、详情页、资讯

热点 key 还需要预热:大促前把爆款商品提前写进缓存,别让用户来触发第一次回源。

四、缓存雪崩

4.1 成因

  • 大批 key 设了相同的 TTL(比如凌晨批量预热,全部 1 小时过期);
  • Redis 实例整体宕机,所有请求瞬间落到数据库。

4.2 解法

// 1. TTL 加随机抖动 ⭐最简单有效
int base = 3600;
int ttl = base + new Random().nextInt(600);     // 3600-4200 秒随机
redis.setex(key, ttl, value);
手段 做法
TTL 随机化 基础 TTL + 随机 0-10% 偏移
多级缓存 本地缓存(Caffeine/Guava)+ Redis,Redis 挂了还有本地
Redis 高可用 主从 + 哨兵,或 Redis Cluster,避免单点
熔断降级 数据库压力过大时返回兜底数据/排队页,保护 DB
限流 网关层限流,超过阈值直接拒绝部分请求
数据预热 低峰期提前加载,且分散 TTL

4.3 多级缓存示例(Caffeine + Redis)

@PostConstruct
public void init() {
    localCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(60))   // 本地只存 1 分钟,容忍短暂不一致
        .build();
}

public User getUser(Long id) {
    User u = localCache.getIfPresent(id);           // L1 本地
    if (u != null) return u;

    String json = redis.get("user:" + id);          // L2 Redis
    if (json != null) {
        u = parse(json);
        localCache.put(id, u);
        return u;
    }
    u = db.queryById(id);                           // L3 DB
    if (u != null) {
        redis.setex("user:" + id, 3600 + random(600), toJson(u));
        localCache.put(id, u);
    }
    return u;
}

本地缓存的过期时间要短(几十秒到几分钟),否则多实例之间数据不一致会很严重。

五、缓存一致性:更新时是先删缓存还是先更新数据库

这是个经典问题,结论取决于你能接受什么:

方案 做法 风险
先删缓存,再更新 DB(推荐) DEL cache → UPDATE db 并发下可能把旧值写回缓存(概率较低)
先更新 DB,再删缓存 UPDATE db → DEL cache 删缓存失败则长期不一致(可用 MQ 重试兜底)
更新 DB 同时更新缓存 双写 并发写会导致脏数据,不推荐

业界通行做法是"先更新数据库,再删除缓存",并加两道保险:

@Transactional
public void updateUser(User u) {
    db.update(u);
    // 1. 删缓存
    redis.del("user:" + u.getId());
    // 2. 发 MQ,消费端再删一次(失败自动重试)→ 双删保障
    mq.send("cache-invalidate", "user:" + u.getId());
    // 3. 可选:延迟双删,sleep 几百毫秒后再删一次
}

另外给所有缓存 key 设 TTL,即使删除失败,过期后也会自愈。这是最朴素的兜底。

六、排查清单

当你发现数据库压力异常时,按顺序查:

# 1. Redis 还活着吗、命中率多少
redis-cli -a pwd INFO stats | grep -E "keyspace_hits|keyspace_misses"
# 命中率 = hits / (hits + misses),低于 90% 要警惕

# 2. 有没有大量 key 同时过期
redis-cli -a pwd INFO keyspace        # 看 db0 的 expires 数量

# 3. 慢命令
redis-cli -a pwd SLOWLOG GET 20

# 4. 大 key / 热 key
redis-cli -a pwd --bigkeys
redis-cli -a pwd --hotkeys

# 5. 内存是否到上限触发淘汰
redis-cli -a pwd INFO memory | grep -E "used_memory_human|maxmemory_human|maxmemory_policy"

命中率突然下降 + 数据库 QPS 暴涨 = 典型的击穿或雪崩;命中率长期很低 = 缓存设计有问题(key 设计不合理或穿透)。

七、常见误区

  1. 三个问题混为一谈:穿透是"不存在",击穿是"一个热的没了",雪崩是"一片没了",解法不同。
  2. 空值缓存 TTL 设太长:真数据插进来了还查不到,业务异常。
  3. 用 SETNX 加锁不加过期时间:线程挂了锁永远不释放,该 key 永久瘫痪。
  4. 互斥锁递归重试不设上限:会栈溢出,应该设最多重试 3 次。
  5. 以为加了 Redis 就不用管数据库容量:缓存一挂,数据库必须扛得住全量流量(通常扛不住,所以要熔断限流)。
  6. 所有 key 用相同 TTL:这就是给雪崩铺路,必须加随机。

八、几条纪律

  1. 所有缓存 key 设 TTL 且加随机抖动,基础值上下浮动 5-10%。
  2. 接口层做参数校验和限流,把明显非法的请求挡在缓存之前。
  3. 热点 key 用互斥锁或逻辑过期,并提前预热。
  4. Redis 必须高可用(哨兵/集群),且数据库侧要有熔断降级兜底。
  5. 更新数据采用"更新 DB + 删缓存 + MQ 补偿删",不双写缓存。
  6. 监控三件事:缓存命中率、Redis 内存水位、数据库 QPS,三者一起看才能发现问题。

九、延伸阅读

👉 相关在线工具:日志分析 · Linux 命令速查 · Docker 命令速查,免登录、纯前端。

还有 65 个免费在线工具

纯前端实现,不用注册,数据不上传服务器。

浏览全部工具