一次讲透 Redis 缓存击穿、穿透、雪崩,从原理到实战解决方案

在高并发系统中,Redis 缓存是提升性能的利器,但缓存一旦"出问题",数据库就会瞬间被海量请求打穿。本文系统性地讲透缓存击穿、缓存穿透、缓存雪崩三大经典问题的原理与解决方案,并给出可直接落地的代码示例。

前言

在实际生产环境中,我们经常会遇到这样的场景:一个热点活动突然上线,QPS 从平时的几百飙升到几万甚至几十万。如果缓存层没有做好防护,数据库会在几分钟内被压垮,整个系统随之雪崩。

缓存问题的本质是:缓存作为数据库的"挡箭牌",一旦失效,所有请求直接打到数据库,导致数据库过载甚至宕机。

根据缓存失效的原因和表现,我们将这些问题归为三大类:

问题 本质原因 核心特征
缓存穿透 查询根本不存在的数据 缓存和数据库都没有
缓存击穿 热点 Key 突然过期 大量并发请求同时打向 DB
缓存雪崩 大量 Key 同时过期 缓存大面积失效,DB 瞬间过载

下面我们逐一深入分析。


一、缓存穿透(Cache Penetration)

1.1 什么是缓存穿透?

缓存穿透是指用户请求的数据在缓存中不存在,在数据库中也不存在。由于缓存无法命中,每次请求都会穿透缓存层直接到达数据库,而数据库查询也返回空结果,缓存自然也不会被写入。如果这种请求大量出现,数据库将承受巨大压力。

markdown 复制代码
用户请求 → 缓存未命中 → 数据库查询 → 数据库也没有 → 返回空
    ↑                                                    |
    └──────────── 缓存不写入(因为查不到) ←──────────────┘

1.2 常见场景

  • 恶意攻击:攻击者构造大量不存在的 ID(如负数 ID、随机字符串)频繁请求接口
  • 业务 bug:前端传入了错误的参数,如删除了某条数据后仍然不断查询
  • 爬虫扫描:遍历式爬虫尝试访问不存在的资源

1.3 解决方案

方案一:缓存空值(最常用)

将数据库查询结果为空的 key 也缓存起来,设置一个较短的过期时间(如 60 秒),防止同一个不存在的 key 被反复查询。

java 复制代码
public String getUser(String userId) {
    String cacheKey = "user:" + userId;
    String cached = redis.get(cacheKey);
    
    if (cached != null) {
        // 命中缓存,直接返回(包括空值缓存)
        return "NULL_PLACEHOLDER".equals(cached) ? null : cached;
    }
    
    // 缓存未命中,查询数据库
    User user = userDao.findById(userId);
    
    if (user != null) {
        redis.set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
        return JSON.toJSONString(user);
    } else {
        // 数据库也没有,缓存空值,设置较短 TTL
        redis.set(cacheKey, "NULL_PLACEHOLDER", 60, TimeUnit.SECONDS);
        return null;
    }
}

优点:实现简单,对大多数场景有效。

缺点:需要额外存储空值缓存;如果攻击者构造大量不同的不存在 key,缓存会被大量空值占满,仍可能引发内存问题。

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

在缓存之前增加一层布隆过滤器,将所有可能存在的 key 预先加载到布隆过滤器中。请求到来时先过布隆过滤器,如果布隆过滤器判断 key 不存在,直接拒绝请求,不会查缓存也不会查数据库。

markdown 复制代码
用户请求 → 布隆过滤器判断
              ├── 不存在 → 直接返回空(不查缓存、不查DB)
              └── 可能存在 → 查缓存 → 查数据库
java 复制代码
// 系统启动时预加载布隆过滤器
public void initBloomFilter() {
    // 加载所有用户ID到布隆过滤器
    List<String> allUserIds = userDao.getAllUserIds();
    for (String userId : allUserIds) {
        bloomFilter.put(userId);
    }
}

public String getUser(String userId) {
    // 第一层:布隆过滤器拦截
    if (!bloomFilter.mightContain(userId)) {
        // 布隆过滤器判断不存在,直接返回
        return null;
    }
    
    // 第二层:查缓存
    String cached = redis.get("user:" + userId);
    if (cached != null) {
        return cached;
    }
    
    // 第三层:查数据库
    User user = userDao.findById(userId);
    if (user != null) {
        redis.set("user:" + userId, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
    }
    return user != null ? JSON.toJSONString(user) : null;
}

优点:内存占用极小(一个 bit 位),查询效率高(O(1)),能有效拦截大量不存在的 key。

缺点:存在误判率(可以调整参数降低);不支持删除(如果数据删除,布隆过滤器中的标记无法移除,可考虑使用布谷鸟过滤器解决)。

方案三:接口限流与参数校验

在入口层做防护:

java 复制代码
// 参数格式校验,拦截非法 ID
if (userId == null || userId <= 0 || userId > MAX_USER_ID) {
    throw new IllegalArgumentException("Invalid user ID");
}

// 限流:对同一个不存在的 key 进行限流
String rateLimitKey = "rate_limit:" + userId;
if (!rateLimiter.tryAcquire(rateLimitKey, 10, 1)) {
    // 1秒内最多10次请求
    throw new RateLimitException("请求过于频繁");
}

1.4 最佳实践

缓存空值 + 布隆过滤器 组合使用是最稳妥的方案。布隆过滤器拦截大部分不存在 key,缓存空值处理布隆过滤器误判和短时间内的重复请求。


二、缓存击穿(Cache Breakdown)

2.1 什么是缓存击穿?

缓存击穿是指某个热点 Key 在缓存中过期的瞬间,大量并发请求同时到达,由于缓存已过期,这些请求全部穿透到数据库,导致数据库瞬间过载。

markdown 复制代码
热点 Key TTL 到期
    ↓
请求 1 → 缓存未命中 → 查 DB → 写缓存
请求 2 → 缓存未命中 → 查 DB → 写缓存
请求 3 → 缓存未命中 → 查 DB → 写缓存
  ...(大量并发请求同时打到数据库)

2.2 与缓存穿透的区别

对比项 缓存穿透 缓存击穿
数据是否存在 数据库中不存在 数据库中存在
触发条件 查询不存在的 key 热点 key 过期
请求量 持续的少量到大量 瞬间的高并发

2.3 解决方案

方案一:互斥锁(Mutex Lock)------ 最经典

在缓存未命中时,只允许一个线程去查数据库并重建缓存,其他线程等待或重试。使用 Redis 的 SETNX(SET if Not eXists)实现分布式互斥锁。

java 复制代码
public String getHotData(String key) {
    String cacheKey = "hot:" + key;
    String cached = redis.get(cacheKey);
    
    if (cached != null) {
        return cached;
    }
    
    // 缓存未命中,尝试获取互斥锁
    String lockKey = "lock:" + key;
    String requestId = UUID.randomUUID().toString();
    
    // SETNX 加锁,设置过期时间防止死锁
    boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
    
    if (locked) {
        try {
            // 双重检查:获取锁后再次检查缓存(可能其他线程已写入)
            cached = redis.get(cacheKey);
            if (cached != null) {
                return cached;
            }
            
            // 查询数据库
            String data = db.query(key);
            
            // 写入缓存,设置过期时间
            redis.set(cacheKey, data, 30, TimeUnit.MINUTES);
            return data;
        } finally {
            // 释放锁(使用 Lua 脚本保证原子性)
            String luaScript = 
                "if redis.call('get', KEYS[1]) == ARGV[1] " +
                "then return redis.call('del', KEYS[1]) " +
                "else return 0 end";
            redis.eval(luaScript, Collections.singletonList(lockKey),
                      Collections.singletonList(requestId));
        }
    } else {
        // 未获取到锁,短暂等待后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getHotData(key); // 递归重试
    }
}

优点:控制精准,只让一个请求打到数据库。

缺点:增加了锁的复杂度;等待锁的线程会有延迟;需要注意死锁问题(设置锁过期时间)。

方案二:热点数据永不过期 + 异步刷新

对热点数据不设置 TTL(或设置极长的 TTL),通过后台异步任务定期刷新缓存。

java 复制代码
// 热点数据不设过期时间,通过后台任务刷新
public void scheduleRefresh() {
    // 定时任务:每隔 30 分钟刷新热点数据
    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
    
    scheduler.scheduleAtFixedRate(() -> {
        try {
            List<String> hotKeys = getHotKeys(); // 获取热点 key 列表
            for (String key : hotKeys) {
                String data = db.query(key);
                // 逻辑过期:在 value 中记录刷新时间
                CacheItem item = new CacheItem(data, System.currentTimeMillis());
                redis.set("hot:" + key, JSON.toJSONString(item));
            }
        } catch (Exception e) {
            log.error("刷新热点缓存失败", e);
        }
    }, 0, 30, TimeUnit.MINUTES);
}

// 请求处理:读到逻辑过期时间,如果过期则异步刷新
public String getHotDataWithLogicalExpire(String key) {
    String cacheKey = "hot:" + key;
    String cached = redis.get(cacheKey);
    
    if (cached == null) {
        // 物理不存在的情况,按正常流程处理
        return loadDataFromDB(key);
    }
    
    CacheItem item = JSON.parseObject(cached, CacheItem.class);
    
    // 检查逻辑是否过期
    if (System.currentTimeMillis() - item.getRefreshTime() > LOGICAL_TTL) {
        // 逻辑过期,异步刷新
        asyncRefreshCache(key);
    }
    
    // 无论是否过期,都返回旧数据(不阻塞用户请求)
    return item.getData();
}

优点:用户请求永远不会被阻塞;适合对实时性要求不极端的场景。

缺点:短暂数据不一致;需要额外维护异步刷新逻辑。

方案三:提前预热热点 Key

在活动开始前,主动将热点数据加载到缓存中,并设置合理的过期时间覆盖活动期间。

java 复制代码
// 活动预热脚本
public void preheatCache() {
    // 预测热点商品
    List<String> hotProducts = Arrays.asList("product:1001", "product:1002");
    
    for (String key : hotProducts) {
        String data = db.query(key);
        // 设置过期时间覆盖整个活动期间(如 3 小时)
        redis.set("hot:" + key, data, 3, TimeUnit.HOURS);
    }
}

2.4 最佳实践

对于不同场景灵活选择:短期突发热点用互斥锁,长期稳定热点用永不过期+异步刷新,可预测热点用提前预热。 互斥锁是最通用的方案,建议作为默认选择。


三、缓存雪崩(Cache Avalanche)

3.1 什么是缓存雪崩?

缓存雪崩是指大量 Key 在同一时间过期 ,或者 Redis 服务整体宕机,导致大量请求同时打到数据库,数据库瞬间过载甚至宕机,引发整个系统雪崩。

与缓存击穿的区别在于:缓存击穿是单个热点 Key 过期,而缓存雪崩是大量 Key 同时过期。

markdown 复制代码
大量 Key 同时过期
        ↓
缓存大面积失效
        ↓
所有请求 → 数据库(数据库被压垮)
        ↓
数据库宕机 → 服务不可用 → 系统雪崩

3.2 常见场景

  • 批量缓存同时过期:在缓存预热时,所有数据设置了相同的 TTL,导致同时过期
  • Redis 宕机:Redis 服务不可用,所有缓存失效
  • 缓存集群分片故障:某个分片故障导致该分片上的所有缓存失效

3.3 解决方案

方案一:随机过期时间(防止同时过期)

在设置缓存 TTL 时,在基准时间上加上一个随机值,避免大量 Key 同时过期。

java 复制代码
public void setCache(String key, String value, long baseTTL) {
    // 在基准 TTL 上增加随机偏移(±10%)
    Random random = new Random();
    int maxOffset = (int) (baseTTL * 0.1);
    long actualTTL = baseTTL + random.nextInt(maxOffset * 2) - maxOffset;
    
    redis.set(key, value, actualTTL, TimeUnit.SECONDS);
}

// 批量缓存加载时更要注意
public void batchLoadCache(List<String> keys) {
    for (String key : keys) {
        String data = db.query(key);
        // 每个key的TTL都不同,打散过期时间
        long ttl = 1800 + ThreadLocalRandom.current().nextInt(600); // 30~35分钟
        redis.set(key, data, ttl, TimeUnit.SECONDS);
    }
}

方案二:多级缓存架构

不把鸡蛋放在一个篮子里,构建多级缓存:

复制代码
用户请求
   ↓
  Nginx 本地缓存(OpenResty / Lua)     ← L1
   ↓ 命中
  应用本地缓存(Caffeine / Guava Cache)  ← L2
   ↓ 未命中
  Redis 分布式缓存                        ← L3
   ↓ 未命中
  数据库
java 复制代码
// L1: 本地缓存(Caffeine)
LoadingCache<String, String> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .refreshAfterWrite(1, TimeUnit.MINUTES)
    .build(key -> {
        // L2: Redis 缓存
        String redisValue = redis.get(key);
        if (redisValue != null) {
            return redisValue;
        }
        // L3: 数据库
        String dbValue = db.query(key);
        if (dbValue != null) {
            redis.set(key, dbValue, 30 + random.nextInt(10), TimeUnit.MINUTES);
        }
        return dbValue;
    });

方案三:Redis 高可用集群

确保 Redis 本身不成为单点故障:

复制代码
Redis Cluster(分片 + 哨兵自动故障转移)
├── Master-1 ←→ Slave-1
├── Master-2 ←→ Slave-2
└── Master-3 ←→ Slave-3
  • 主从复制 + 哨兵:自动故障检测和主节点切换
  • Redis Cluster:数据分片,每个分片有主从
  • 多机房部署:异地多活,防机房级故障

方案四:服务降级与限流熔断

当数据库压力过大时,启动保护机制:

java 复制代码
// 限流:控制打到数据库的请求量
public String getDataWithProtection(String key) {
    String cached = redis.get(key);
    if (cached != null) {
        return cached;
    }
    
    // 限流:使用令牌桶或漏桶算法
    if (!dbRateLimiter.tryAcquire()) {
        // 超过限流阈值,返回降级数据或默认值
        return getFallbackData(key);
    }
    
    try {
        String data = db.query(key);
        if (data != null) {
            redis.set(key, data, getRandomTTL(), TimeUnit.SECONDS);
        }
        return data;
    } catch (Exception e) {
        // 数据库异常,触发熔断
        circuitBreaker.recordFailure();
        return getFallbackData(key);
    }
}

方案五:事前预热

在系统上线或活动开始前,批量加载缓存:

java 复制代码
// 系统启动时执行缓存预热
public void warmupCache() {
    log.info("开始缓存预热...");
    
    List<String> allKeys = db.getAllKeys();
    for (String key : allKeys) {
        String data = db.query(key);
        // 随机 TTL,防止雪崩
        long ttl = 3600 + ThreadLocalRandom.current().nextInt(1800);
        redis.set(key, data, ttl, TimeUnit.SECONDS);
    }
    
    log.info("缓存预热完成,共加载 {} 个 key", allKeys.size());
}

3.4 最佳实践

随机 TTL + 多级缓存 + 高可用集群 + 限流熔断 是应对缓存雪崩的组合拳。随机 TTL 是预防手段,多级缓存是架构保障,高可用是基础设施,限流熔断是最后防线。


四、三者对比总结

维度 缓存穿透 缓存击穿 缓存雪崩
问题本质 查不存在的数据 热点 key 过期 大量 key 同时过期
发生范围 单个 key 单个热点 key 大量 key
数据库影响 逐渐增加 瞬间尖峰 瞬间洪峰
触发者 恶意攻击/bug 自然过期 批量过期/宕机
首选方案 布隆过滤器+空值缓存 互斥锁 随机 TTL+多级缓存
预防难度
危害程度 极高

五、通用防护策略

除了针对每种问题的特定方案,以下通用策略建议纳入所有缓存架构:

5.1 监控告警

yaml 复制代码
# 监控指标
cache_metrics:
  - cache_hit_rate:          # 缓存命中率,低于 90% 告警
  - cache_response_time:     # 缓存响应时间,P99 > 5ms 告警
  - db_connection_count:     # 数据库连接数,接近上限告警
  - redis_memory_usage:      # Redis 内存使用率,> 80% 告警
  - key_expiry_distribution: # Key 过期时间分布检测集中过期

5.2 合理的缓存策略

  • 读策略:先读缓存 → 未命中读 DB → 回写缓存
  • 写策略:推荐"延迟双删"------先删缓存 → 更新 DB → 延迟再删缓存
  • 过期策略:核心数据设较长 TTL,非核心数据设较短 TTL,统一加随机偏移

5.3 Key 设计规范

csharp 复制代码
业务:对象类型:唯一标识
user:profile:10001          # 用户信息
product:detail:20001        # 商品详情
lock:order:create:30001     # 分布式锁
bloom:user:ids              # 布隆过滤器

六、完整代码示例

下面是一个综合了所有防护策略的缓存工具类:

java 复制代码
public class CacheGuard {
    
    private final RedisTemplate<String, String> redis;
    private final BloomFilter<String> bloomFilter;
    private final RateLimiter dbRateLimiter;
    
    private static final long BASE_TTL = 1800; // 30分钟
    private static final long NULL_TTL = 60;   // 空值缓存60秒
    private static final String NULL_VALUE = "\0_NULL_\0";
    
    /**
     * 通用缓存读取(含穿透、击穿、雪崩防护)
     */
    public String get(String key, Supplier<String> dbSupplier) {
        String cacheKey = "cache:" + key;
        
        // 1. 布隆过滤器防穿透
        if (!bloomFilter.mightContain(key)) {
            return null;
        }
        
        // 2. 读缓存
        String cached = redis.opsForValue().get(cacheKey);
        if (cached != null) {
            if (NULL_VALUE.equals(cached)) {
                return null; // 空值缓存命中
            }
            return cached;
        }
        
        // 3. 互斥锁防击穿
        String lockKey = "lock:" + key;
        String requestId = UUID.randomUUID().toString();
        
        Boolean locked = redis.opsForValue()
            .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 双重检查
                cached = redis.opsForValue().get(cacheKey);
                if (cached != null) {
                    return NULL_VALUE.equals(cached) ? null : cached;
                }
                
                // 4. 限流防雪崩
                if (!dbRateLimiter.tryAcquire()) {
                    return getFallbackData(key);
                }
                
                // 查数据库
                String data = dbSupplier.get();
                
                if (data != null) {
                    // 5. 随机TTL防雪崩
                    long ttl = BASE_TTL + ThreadLocalRandom.current().nextInt(300);
                    redis.opsForValue().set(cacheKey, data, ttl, TimeUnit.SECONDS);
                    return data;
                } else {
                    // 缓存空值防穿透
                    redis.opsForValue().set(cacheKey, NULL_VALUE, NULL_TTL, TimeUnit.SECONDS);
                    return null;
                }
            } finally {
                releaseLock(lockKey, requestId);
            }
        } else {
            // 等待重试
            sleep(50);
            return get(key, dbSupplier);
        }
    }
    
    private void releaseLock(String lockKey, String requestId) {
        String luaScript = 
            "if redis.call('get',KEYS[1])==ARGV[1] then " +
            "return redis.call('del',KEYS[1]) " +
            "else return 0 end";
        redis.execute(new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(lockKey), requestId);
    }
    
    private String getFallbackData(String key) {
        // 返回降级数据或默认值
        return null;
    }
    
    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

总结

缓存问题是高并发系统的"必修课",核心思路可以总结为四句话:

  1. 防穿透:布隆过滤器 + 缓存空值,让"不存在的数据"也有一席之地
  2. 防击穿:互斥锁 + 热点永不过期,让"热点过期"不再致命
  3. 防雪崩:随机 TTL + 多级缓存 + 限流熔断,让"大面积失效"可控
  4. 通用保障:监控告警 + 合理策略 + 高可用部署,让系统具备自愈能力

实际生产中,不需要每种方案都用上,而是根据业务场景、数据特征和 QPS 规模选择合适的组合。关键在于:理解每种方案适用的场景,在问题发生前做好预防,在问题发生时能快速止损。


如果觉得有帮助,欢迎点赞收藏!有问题欢迎评论区交流。

相关推荐
IT_陈寒1 小时前
Vue的v-for为啥把我的渲染顺序搞乱套了?
前端·人工智能·后端
用户106911794101 小时前
SpringSecuirty自定义接口或者资源权限
后端
Python私教1 小时前
四个入口,一条流水线:如意智影的多入口架构取舍
人工智能·python·架构
腾渊信息科技公司1 小时前
腾渊科技重磅出品——多Agent集群实战:从通信层到调度中枢的完整架构方案
科技·架构·腾渊科技·腾渊信息科技
AI多Agent协作实战派2 小时前
AI多Agent协作系统实战(四十六):改了十次规则,AI员工还是老样子——会话缓存的坑
java·spring·缓存
天涯明月19932 小时前
SGLang深度研究报告
人工智能·后端·推理·sglang
一知半解仙2 小时前
当机器人学会泛化,而我在写Java:一名后端开发者的2026年8月21日观察手记
java·开发语言·机器人
0xBADCODE2 小时前
Flask SSTI读SECRET_KEY+伪造Session:税务系统渗透全流程
前端·后端·python·安全·web安全·网络安全·flask
程序员爱钓鱼2 小时前
Rust where详解:让泛型与Trait约束更加清晰
前端·后端·rust