*Redis 缓存穿透/击穿/雪崩:原理与 6 种解决方案全解析

摘要:缓存穿透、缓存击穿、缓存雪崩是 Redis 应用中最经典的三类高并发问题。本文从底层原理出发,结合 Redis 7.x 特性,提供 6 种经过生产验证的解决方案,并附带可执行的命令示例与架构图解。


*一、三大问题速查对比

问题 触发条件 影响范围 核心危害
缓存穿透 查询不存在的数据 数据库承受非法请求 DB 被打穿,服务雪崩
缓存击穿 热点 Key突然过期 单个热点数据 瞬间流量直击 DB
缓存雪崩 大量 Key 同时过期 整片缓存数据 全量请求压垮 DB

*问题决策流程

用户请求
    │
    ▼
缓存是否命中?
    ├─ 是 → 返回缓存数据 ✅
    └─ 否 → 数据是否存在?
            ├─ 不存在 → 缓存穿透 ⚠️
            └─ 存在但过期 → 是热点 Key?
                    ├─ 是单点 → 缓存击穿 🔥
                    └─ 大面积 → 缓存雪崩 ❄️

*二、缓存穿透:请求"幽灵数据"

*2.1 原理剖析

缓存穿透指查询一个数据库中不存在的数据。由于缓存中也没有,请求会直接打到数据库。攻击者可能利用这一点,构造大量不存在的 Key 进行恶意请求,导致数据库压力剧增。

典型时序:

用户          Redis缓存        MySQL数据库
 │               │                │
 │── GET id=-99999 ──>│                │
 │<──── nil ──────────│                │
 │               │                │
 │─────────────── SELECT * FROM user WHERE id=-99999 ──>│
 │<────────────── Empty Set ────────────────────────────│
 │               │                │
 │               │   每次请求都查DB!  │

*2.2 解决方案

*方案 1:布隆过滤器(Bloom Filter)

在缓存层之前加一道布隆过滤器,快速判断 Key 是否可能存在。

# Redis 4.0+ 可通过 RedisBloom 模块实现
# 安装模块后
127.0.0.1:6379> BF.RESERVE user_filter 0.001 1000000
OK

# 添加元素
127.0.0.1:6379> BF.ADD user_filter user:1001
(integer) 1

# 检查可能存在
127.0.0.1:6379> BF.EXISTS user_filter user:1001
(integer) 1

# 检查一定不存在
127.0.0.1:6379> BF.EXISTS user_filter user:-99999
(integer) 0

原理:布隆过滤器使用位数组和多个哈希函数,可能存在误判(false positive),但不会漏判(false negative)。即:返回"不存在"的数据一定不存在;返回"可能存在"的才需要查缓存/DB。

注意:BF.* 命令需要安装 RedisBloom 模块。安装后在 redis.conf 中添加 loadmodule /path/to/redisbloom.so。

*方案 2:缓存空值(Cache Null)

对于数据库中确实不存在的数据,也将"空值"写入缓存,设置较短的过期时间(如 60 秒)。

# 查询数据库为空后,缓存空值
127.0.0.1:6379> SET user:-99999 "__NULL__" EX 60 NX
OK

# 后续相同请求直接命中空值缓存
127.0.0.1:6379> GET user:-99999
"__NULL__"

注意:空值缓存时间不宜过长,防止真实数据新增后长期无法访问。


*三、缓存击穿:热点 Key"猝死"

*3.1 原理剖析

某个极高访问量的热点 Key 在缓存中突然过期(或尚未加载),此时大量并发请求同时到达,全部穿透到数据库。

并发时序:

时间点: 热点 Key 过期瞬间

用户1 ──> Redis: GET hot_product:1001
          返回: nil
用户2 ──> Redis: GET hot_product:1001
          返回: nil
用户3 ──> Redis: GET hot_product:1001
          返回: nil

用户1 ──> MySQL: SELECT * FROM product WHERE id=1001
用户2 ──> MySQL: SELECT * FROM product WHERE id=1001
用户3 ──> MySQL: SELECT * FROM product WHERE id=1001

          MySQL: 瞬间N个线程查同一行!🔥

*3.2 解决方案

*方案 3:互斥锁(Mutex / 分布式锁)

只让一个线程去查数据库并重建缓存,其他线程等待缓存重建完成。

# 尝试获取互斥锁
127.0.0.1:6379> SET lock:hot_product:1001 1 EX 10 NX
OK

# 获取锁成功的线程查询DB并重建缓存
127.0.0.1:6379> SET hot_product:1001 "<product_data>" EX 3600
OK

# 删除锁
127.0.0.1:6379> DEL lock:hot_product:1001
(integer) 1
# Python 伪代码
import redis
import time

r = redis.Redis()

def get_hot_product(product_id):
    cache_key = f"hot_product:{product_id}"
    lock_key = f"lock:{cache_key}"

    data = r.get(cache_key)
    if data:
        return data

    # 尝试获取锁(SET NX EX)
    locked = r.set(lock_key, 1, nx=True, ex=10)
    if locked:
        try:
            # 只有获得锁的线程查DB
            data = query_database(product_id)
            r.set(cache_key, data, ex=3600)
            return data
        finally:
            r.delete(lock_key)
    else:
        # 未获得锁,等待后重试
        time.sleep(0.1)
        return get_hot_product(product_id)  # 递归重试

*方案 4:逻辑过期(Logical Expiration)

不设置 Redis 的 TTL,而是在 Value 中存储逻辑过期时间,由业务层判断是否"过期"并异步重建。

# Value 结构:{"data": "...", "expire_time": 1716540000}
127.0.0.1:6379> SET hot_product:1001 '{"data": "<product_data>", "expire_time": 1716543600}'
OK
# 业务层判断逻辑
import json, time

def get_product_logical(product_id):
    cache_key = f"hot_product:{product_id}"
    raw = r.get(cache_key)

    if not raw:
        return query_database(product_id)

    value = json.loads(raw)
    now = int(time.time())

    if value["expire_time"] > now:
        return value["data"]  # 未过期,直接返回
    else:
        # 已过期但返回旧数据,异步启动线程重建
        threading.Thread(target=rebuild_cache, args=(product_id,)).start()
        return value["data"]  # 返回"脏"数据,保证可用性

*四、缓存雪崩:集体"暴雷"

*4.1 原理剖析

缓存雪崩指大量 Key 在同一时间集中过期,或者 Redis 集群整体宕机,导致所有请求同时涌向数据库。

状态对比:

正常状态                            雪崩瞬间
────────────────────────────────    ────────────────────────────────

请求1 ──> Redis缓存                 请求1 ──> Redis? (过期/宕机)
请求2 ──> Redis缓存    ────────>    请求2 ──> Redis? (过期/宕机)
请求3 ──> Redis缓存                 请求3 ──> Redis? (过期/宕机)
    │                                   │
    ▼                                   ▼
MySQL: 低负载                       MySQL: 10000+ QPS
                                       │
                                       ▼
                                  连接池耗尽
                                       │
                                       ▼
                                  服务超时/宕机 ❄️

*4.2 解决方案

*方案 5:过期时间加随机偏移

避免大量 Key 同时失效,在基础过期时间上增加随机偏移量。

# 基础过期时间 3600 秒,随机增加 0-600 秒
# 实际过期时间范围:3600 ~ 4200 秒
127.0.0.1:6379> SET product:1001 "data" EX 3600
OK
# Python 中实现
import random

base_ttl = 3600
random_offset = random.randint(0, 600)  # 0~10分钟随机
final_ttl = base_ttl + random_offset
r.setex(f"product:{id}", final_ttl, data)

*方案 6:多级缓存架构

构建 L1(本地缓存,如 Caffeine/Guava)+ L2(Redis 分布式缓存)+ L3(数据库)的三级缓存体系。

架构层级:

用户请求
    │
    ▼
┌─────────────────┐
│ 本地缓存 Caffeine │ ← L1,微秒级,进程内
│   (Guava/Ehcache) │
└────────┬────────┘
         │ 未命中
         ▼
┌─────────────────┐
│   Redis 缓存     │ ← L2,毫秒级,分布式
│  (共享缓存层)     │
└────────┬────────┘
         │ 未命中
         ▼
┌─────────────────┐
│     数据库        │ ← L3,毫秒级,持久化
│   (MySQL/PostgreSQL)
└─────────────────┘
// Spring Boot + Caffeine + Redis 多级缓存示例
@Cacheable(value = "product", key = "#id", cacheManager = "caffeineCacheManager")
public Product getProduct(Long id) {
    // 先查 Caffeine(本地,微秒级)
    // 未命中则查 Redis
    String cacheData = redisTemplate.opsForValue().get("product:" + id);
    if (cacheData != null) {
        return JSON.parseObject(cacheData, Product.class);
    }
    // 未命中则查数据库
    Product product = productDao.findById(id);
    redisTemplate.opsForValue().set("product:" + id, JSON.toJSONString(product), 1, TimeUnit.HOURS);
    return product;
}

*五、6 种方案对比与选型指南

方案 适用问题 复杂度 性能影响 数据一致性 推荐场景
布隆过滤器 缓存穿透 中 低(O(1)) 可能误判 海量非法请求防御
缓存空值 缓存穿透 低 低 弱(有延迟) 简单场景、少量空值
互斥锁 缓存击穿 中 中(有等待) 强 热点 Key 重建
逻辑过期 缓存击穿 高 低 弱(脏读) 极高可用性要求
随机偏移 缓存雪崩 低 无 强 批量 Key 过期
多级缓存 缓存雪崩 高 极低 弱(最终一致) 大型高并发系统

*六、Redis 命令速查表

命令 用途 示例
SET key value EX seconds 设置带过期时间的键 SET user:1 "Alice" EX 3600
GET key 获取键值 GET user:1
SET key value NX EX seconds 分布式锁(不存在才设置) SET lock:product:1001 1 EX 10 NX
DEL key 删除键 DEL lock:product:1001
EXPIRE key seconds 设置过期时间 EXPIRE user:1 3600
TTL key 查看剩余过期时间 TTL user:1
BF.RESERVE 创建布隆过滤器(需 RedisBloom) BF.RESERVE user_filter 0.001 1000000
BF.ADD 向布隆过滤器添加元素 BF.ADD user_filter user:1001
BF.EXISTS 检查元素可能存在 BF.EXISTS user_filter user:1001
INFO memory 查看内存使用 INFO memory
INFO stats 查看统计信息 INFO stats
MONITOR 实时监控命令 MONITOR
SLOWLOG GET 查看慢查询日志 SLOWLOG GET 10

*七、生产环境 checklist

# 1. 检查 Redis 内存使用,防止 OOM
127.0.0.1:6379> INFO memory

# 2. 查看 Key 过期策略
127.0.0.1:6379> CONFIG GET maxmemory-policy

# 3. 监控慢查询,发现潜在热点 Key
127.0.0.1:6379> SLOWLOG GET 20

# 4. 检查连接数是否接近上限
127.0.0.1:6379> INFO clients

# 5. 查看持久化状态
127.0.0.1:6379> INFO persistence

*八、总结

缓存穿透、击穿、雪崩本质上是高并发场景下缓存层与数据库层的协作问题。

问题 核心解决思路
缓存穿透 拦截非法请求 → 布隆过滤器 / 缓存空值
缓存击穿 保护单个热点 → 互斥锁 / 逻辑过期
缓存雪崩 分散过期时间 → 随机偏移 / 多级缓存

在实际生产环境中,通常需要组合使用多种方案,例如:

  • 布隆过滤器 + 缓存空值 → 防御穿透
  • 互斥锁 + 逻辑过期 → 保护热点
  • 随机偏移 + 多级缓存 + Redis Cluster → 防止雪崩

*相关阅读


作者注:本文所有命令均基于 Redis 7.x 测试通过。BF.* 系列命令需要安装 RedisBloom 模块。不同版本命令可能存在差异,请以实际环境为准。