Redis 缓存穿透、击穿、雪崩:从一次凌晨报警说起

jxq
0
2026-05-11

Redis 缓存穿透、击穿、雪崩:从一次凌晨报警说起

一个真实的凌晨故事

凌晨 3 点,手机疯狂震动——数据库 CPU 飙到 100%,接口 P99 从 50ms 暴涨到 5s。打开监控一看:Redis 命中率从 99% 骤降到 15%,所有请求像潮水一样涌向 MySQL。

排查后发现:运营同学上线了一个新活动页,URL 里带了一个数据库不存在的商品 ID。这个 ID 在 Redis 里找不到,在 MySQL 里也找不到,于是每次请求都穿透到数据库。 10 万 QPS 的流量,MySQL 直接被打满。

这就是经典的缓存穿透——而它只是 Redis 三大缓存危机中最好理解的一个。


一、缓存穿透:查不到的数据,永远穿透

是什么

请求查询的数据既不在缓存中,也不在数据库中,每次请求都会直达数据库,缓存形同虚设。

为什么危险

正常请求: Client → Redis(命中) → 返回         ✅ 耗时 < 1ms
穿透请求: Client → Redis(未命中) → MySQL(未命中) → 返回  ❌ 耗时 > 10ms

单个穿透请求不可怕,可怕的是规模化穿透

  • 恶意攻击者构造大量不存在的 key(如 user:-1order:999999999
  • 业务 bug 产生大量无效查询
  • 热点数据被批量删除后,大量请求同时回源

怎么办:三种方案对比

方案 1:缓存空值

// 伪代码:查询时缓存空结果
public Object getUser(String userId) {
    String cacheKey = "user:" + userId;
    String value = redis.get(cacheKey);
    
    // 命中缓存(包括空值标记)
    if (value != null) {
        return "NULL_MARKER".equals(value) ? null : deserialize(value);
    }
    
    // 未命中,查数据库
    Object result = mysql.query("SELECT * FROM user WHERE id = ?", userId);
    
    if (result == null) {
        // 缓存空值,设置较短过期时间
        redis.set(cacheKey, "NULL_MARKER", 60);  // 60秒过期
    } else {
        redis.set(cacheKey, serialize(result), 3600);
    }
    return result;
}

优点:实现简单,效果直接
缺点:占用 Redis 内存;同一 key 短时间大量穿透时仍会回源;攻击者变换 key 时无效

方案 2:布隆过滤器

flowchart LR
    A[请求] --> B{布隆过滤器判断}
    B -->|可能存在| C[查询 Redis]
    B -->|一定不存在| D[直接返回]
    C -->|命中| E[返回数据]
    C -->|未命中| F[查询 MySQL]
    F --> G[回填 Redis]
// 伪代码:基于布隆过滤器的防护
public Object getUser(String userId) {
    // 第一层:布隆过滤器快速判断
    if (!bloomFilter.mightContain("user:" + userId)) {
        return null;  // 一定不存在,直接返回
    }
    
    // 第二层:查询缓存
    String value = redis.get("user:" + userId);
    if (value != null) {
        return deserialize(value);
    }
    
    // 第三层:查询数据库
    return queryAndCache(userId);
}

优点:内存占用极小(1亿 key 约 120MB);拦截效率高
缺点:存在误判率(可接受,约 0.01%);不支持删除;需要预热数据

方案 3:请求参数校验

# 伪代码:入口层拦截非法参数
def get_user(user_id: str):
    # 合法性校验:ID 必须为正整数且在合理范围
    if not user_id.isdigit() or int(user_id) <= 0:
        raise ValueError("Invalid user ID")
    if int(user_id) > MAX_USER_ID:
        raise ValueError("User ID out of range")
    
    return cache_or_db_query(user_id)

优点:零成本,从源头拦截
缺点:只能防御已知规则的非法请求

方案选择

场景 推荐方案
少量固定 key 穿透 缓存空值
大规模数据防穿透 布隆过滤器
参数明显非法 请求校验
生产环境 三者组合使用

二、缓存击穿:热点的坍塌

是什么

一个极度热点的 key 在过期的瞬间,大量并发请求同时回源,压垮数据库。

与穿透的区别:数据是存在的,只是过期的那个瞬间被大量请求同时击穿。

为什么危险

sequenceDiagram
    participant C1 as 请求1
    participant C2 as 请求2
    participant C3 as 请求3
    participant R as Redis
    participant M as MySQL
    
    Note over R: 热点key过期
    C1->>R: GET hot_key → miss
    C2->>R: GET hot_key → miss
    C3->>R: GET hot_key → miss
    C1->>M: SELECT ... (回源)
    C2->>M: SELECT ... (重复回源)
    C3->>M: SELECT ... (重复回源)
    Note over M: 同一时刻大量相同SQL

典型场景:秒杀商品信息、热搜词条、明星微博——单个 key 承载数万 QPS。

怎么办

方案 1:互斥锁

// 伪代码:只允许一个线程回源
public Object getWithLock(String key) {
    Object value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    String lockKey = "lock:" + key;
    // 尝试获取分布式锁(SETNX)
    if (redis.setnx(lockKey, "1", 10)) {  // 10秒超时
        try {
            // 双重检查
            value = redis.get(key);
            if (value != null) return value;
            
            // 回源查询
            value = mysql.query(key);
            redis.set(key, value, 3600);
            return value;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 未获取锁,短暂等待后重试
        sleep(50);
        return getWithLock(key);
    }
}

优点:只回源一次,保护数据库
缺点:增加延迟(等待锁的请求会阻塞);实现复杂,需处理锁超时、死锁

方案 2:逻辑过期(不设 TTL)

// 伪代码:逻辑过期方案
public Object getWithLogicalExpire(String key) {
    CacheData data = redis.get(key);
    
    if (data == null) {
        return null;  // 真正不存在
    }
    
    if (!data.isExpired()) {
        return data.getValue();  // 未过期,直接返回
    }
    
    // 已过期,尝试获取锁异步更新
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 10)) {
        // 获取锁,异步更新
        asyncExecute(() -> {
            Object newValue = mysql.query(key);
            redis.set(key, new CacheData(newValue, newExpiry), 0);  // 永不过期
            redis.del(lockKey);
        });
    }
    
    // 无论是否获取锁,先返回旧数据
    return data.getValue();
}

优点:高可用,永远返回数据;无阻塞等待
缺点:可能返回过期数据;需额外存储过期字段

方案 3:热点 key 永不过期 + 主动更新

1. 核心热点 key 不设 TTL
2. 数据变更时,通过 Binlog / 消息队列 主动更新 Redis
3. 兜底:定时任务巡检,发现过期数据主动刷新

优点:最稳妥,彻底消除击穿风险
缺点:维护成本高;需保障数据同步链路可靠性

方案选择

场景 推荐方案
强一致性要求 互斥锁
可接受短暂不一致 逻辑过期
超级热点 key 永不过期 + 主动更新

三、缓存雪崩:大面积的崩溃

是什么

大量 key 同一时刻集中过期,或 Redis 实例整体宕机,导致请求全部涌向数据库。

与击穿的区别:击穿是一个热点 key,雪崩是大量 key 同时失效。

为什么危险

缓存穿透:针扎一个洞       → 单点穿透
缓存击穿:锤砸一个坑       → 热点坍塌  
缓存雪崩:整个屋顶塌了     → 系统级故障

真实案例:某电商大促前,运维批量导入了 10 万个商品缓存,过期时间统一设为 2 小时。2 小时后,10 万个 key 同时过期,数据库瞬间被打挂。

怎么办

方案 1:过期时间加随机抖动

// 伪代码:过期时间 = 基础时间 + 随机偏移
int baseExpire = 3600;  // 1小时
int randomJitter = random(0, 300);  // 0~5分钟随机
redis.set(key, value, baseExpire + randomJitter);

一行代码解决 80% 的雪崩问题。

方案 2:Redis 高可用

flowchart TB
    subgraph "Redis Cluster"
        M[Master-1] --- S1[Slave-1]
        M2[Master-2] --- S2[Slave-2]
        M3[Master-3] --- S3[Slave-3]
    end
    
    subgraph "Sentinel 监控"
        SE1[Sentinel-1]
        SE2[Sentinel-2]
        SE3[Sentinel-3]
    end
    
    SE1 -.-> M
    SE2 -.-> M2
    SE3 -.-> M3
    
    S1 -.->|故障自动切换| M
  • Redis Sentinel:主从自动故障转移
  • Redis Cluster:数据分片 + 高可用
  • 多可用区部署:跨机房容灾

方案 3:多级缓存

// 伪代码:L1 本地缓存 + L2 Redis + L3 MySQL
public Object getWithMultiLevel(String key) {
    // L1: 本地缓存(Caffeine / Guava Cache)
    Object value = localCache.get(key);
    if (value != null) return value;
    
    // L2: Redis
    value = redis.get(key);
    if (value != null) {
        localCache.put(key, value);
        return value;
    }
    
    // L3: MySQL(限流保护)
    value = rateLimitedQuery(key);
    if (value != null) {
        redis.set(key, value, expireWithJitter());
        localCache.put(key, value);
    }
    return value;
}

方案 4:兜底降级

- 数据库侧:连接池限流 + 熔断(Hystrix / Sentinel)
- 业务侧:返回兜底数据 / 静态页面
- 监控侧:缓存命中率低于阈值时自动告警

方案选择

层面 推荐方案
预防 过期时间加随机抖动 ⭐
架构 Redis Cluster 高可用
防护 多级缓存 + 限流降级
生产环境 四者组合

回到那个凌晨的故事

最后,那次事故是怎么解决的?

  1. 紧急止血:网关层对不存在的商品 ID 返回默认值(方案 3:参数校验)
  2. 根因修复:上线布隆过滤器,预热全量商品 ID(方案 2)
  3. 兜底保护:缓存空值,过期时间 60 秒(方案 1)
  4. 长效改进:所有缓存过期时间加随机抖动,防止雪崩

从凌晨 3 点报警到早上 6 点,三个人没合眼。但这次事故也让团队重新审视了缓存防护体系——穿透、击穿、雪崩,不是三个独立的问题,而是同一根链条上的三个薄弱环节。


思考:缓存的本质

缓存的核心矛盾是数据一致性 vs 可用性

  • 缓存空值牺牲一致性,换取穿透防护
  • 逻辑过期牺牲强一致,换取击穿防护
  • 随机过期牺牲精确控制,换取雪崩防护

这些 trade-off 的本质,和 CAP 定理一脉相承——在分布式系统中,你无法同时拥有强一致性、高可用性和分区容错性。 缓存策略的选择,就是在特定业务场景下,找到三者之间的最优平衡点。

所以下次设计缓存方案时,不要问"用哪种方案",而要问:我的业务,能容忍多大程度的不一致?

这个问题想清楚了,方案自然就出来了。

动物装饰