Redis 缓存穿透、击穿、雪崩解决方案

缓存是提升系统性能的利器,但也带来了三大经典问题:穿透、击穿、雪崩

三大问题概览

问题 定义 核心特征
缓存穿透 查询不存在的数据 缓存和数据库都没有
缓存击穿 热点数据过期瞬间 大量并发查询同一 key
缓存雪崩 大量缓存同时过期 多个 key 同时失效
复制代码
                    请求
                     │
                     ▼
              ┌─────────────┐
              │   缓存层     │
              └─────────────┘
                │         │
         命中 ↓         ↓ 未命中
            返回    ┌─────────────┐
                    │  数据库层    │
                    └─────────────┘
                      │         │
               有数据 ↓         ↓ 无数据
                  写入缓存     穿透问题!
                    │
                    ▼
                  返回

击穿:热点 key 过期 → 大量请求打到数据库
雪崩:大量 key 过期 → 数据库压力剧增

一、缓存穿透

问题场景

sql 复制代码
-- 恶意请求查询不存在的数据
SELECT * FROM user WHERE id = -1;   -- 缓存没有,数据库也没有
SELECT * FROM user WHERE id = -2;   -- 每次都打到数据库
SELECT * FROM user WHERE id = -3;
...
复制代码
恶意请求:
id = -1, -2, -3, -4, -5 ... -100000

每次请求:
1. 查缓存 → 没有
2. 查数据库 → 没有
3. 返回空

结果:数据库被打挂

解决方案

方案一:缓存空值
java 复制代码
public User getUser(Long id) {
    // 1. 参数校验
    if (id == null || id <= 0) {
        return null;
    }
    
    // 2. 查缓存
    String key = "user:" + id;
    String cachedValue = redis.get(key);
    
    // 3. 缓存命中
    if (cachedValue != null) {
        // 判断是否为空值标记
        if ("NULL".equals(cachedValue)) {
            return null;
        }
        return JSON.parseObject(cachedValue, User.class);
    }
    
    // 4. 查数据库
    User user = userMapper.findById(id);
    
    // 5. 写入缓存
    if (user == null) {
        // 缓存空值,设置较短过期时间
        redis.set(key, "NULL", 60);
        return null;
    }
    
    redis.set(key, JSON.toJSONString(user), 3600);
    return user;
}

优点 :实现简单
缺点

  • 占用内存(大量空值)
  • 可能缓存不一致(数据新增后仍返回空)
方案二:布隆过滤器
java 复制代码
@Service
public class UserService {
    
    @Autowired
    private BloomFilter<Long> bloomFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器,加载所有有效 ID
        List<Long> allIds = userMapper.findAllIds();
        for (Long id : allIds) {
            bloomFilter.put(id);
        }
    }
    
    public User getUser(Long id) {
        // 1. 布隆过滤器判断
        if (!bloomFilter.mightContain(id)) {
            // 一定不存在,直接返回
            return null;
        }
        
        // 2. 可能存在,正常查询
        return getUserFromCacheOrDB(id);
    }
}

布隆过滤器原理

复制代码
位数组(Bit Array):[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

添加元素 "user:123":
  hash1("user:123") % 10 = 3  → 位数组[3] = 1
  hash2("user:123") % 10 = 7  → 位数组[7] = 1
  hash3("user:123") % 10 = 9  → 位数组[9] = 1

位数组:[0, 0, 0, 1, 0, 0, 0, 1, 0, 1]

查询元素 "user:456":
  hash1("user:456") % 10 = 3  → 位数组[3] = 1 ✓
  hash2("user:456") % 10 = 5  → 位数组[5] = 0 ✗
  
结果:一定不存在!

查询元素 "user:789":
  hash1("user:789") % 10 = 3  → 位数组[3] = 1 ✓
  hash2("user:789") % 10 = 7  → 位数组[7] = 1 ✓
  hash3("user:789") % 10 = 9  → 位数组[9] = 1 ✓
  
结果:可能存在(有误判概率)

特点

  • 不存在则一定不存在:可以100%过滤掉不存在的请求
  • 存在则可能误判:有一定的误判率,但可以接受
方案三:参数校验 + 限流
java 复制代码
public User getUser(Long id) {
    // 1. 参数合法性校验
    if (id == null || id <= 0) {
        throw new IllegalArgumentException("无效的用户ID");
    }
    
    // 2. 限流保护
    if (!rateLimiter.tryAcquire()) {
        throw new RateLimitException("请求过于频繁");
    }
    
    // 3. 正常查询流程
    return getUserFromCacheOrDB(id);
}

方案对比

方案 优点 缺点 适用场景
缓存空值 实现简单 占内存、可能不一致 数据量小
布隆过滤器 内存占用小 有误判率、需初始化 数据量大
参数校验 防止恶意请求 无法完全避免 配合其他方案

二、缓存击穿

问题场景

复制代码
热点数据(如:商品详情 id=1001):

时间线:
T1: 缓存过期,key 被删除
T2: 10000 个请求同时查询 id=1001
T3: 10000 个请求都发现缓存不存在
T4: 10000 个请求同时查询数据库
T5: 数据库压力瞬间飙升

           缓存过期
              │
              ▼
    ┌─────────────────────┐
    │   10000 个并发请求    │
    └─────────────────────┘
              │
              ▼
    ┌─────────────────────┐
    │      数据库          │  ← 瞬间被打挂
    └─────────────────────┘

解决方案

方案一:互斥锁
java 复制代码
public User getUser(Long id) {
    String key = "user:" + id;
    
    // 1. 查缓存
    User user = getUserFromCache(key);
    if (user != null) {
        return user;
    }
    
    // 2. 获取分布式锁
    String lockKey = "lock:user:" + id;
    try {
        if (redis.tryLock(lockKey, 10)) {
            // 3. Double Check
            user = getUserFromCache(key);
            if (user != null) {
                return user;
            }
            
            // 4. 查数据库
            user = userMapper.findById(id);
            
            // 5. 写入缓存
            if (user != null) {
                redis.set(key, JSON.toJSONString(user), 3600);
            }
            
            return user;
        } else {
            // 6. 获取锁失败,等待后重试
            Thread.sleep(50);
            return getUser(id);
        }
    } finally {
        redis.unlock(lockKey);
    }
}

流程图

复制代码
请求1 ──┐
请求2 ──┼──→ 查缓存(未命中)──→ 竞争锁 ──→ 请求1获得锁
请求3 ──┤                                    │
请求4 ──┤                                    ▼
请求5 ──┘                              查数据库 → 写缓存
                                            │
        请求2,3,4,5 等待 ◄────────────────────┘
              │
              ▼
        重试查缓存(命中)
方案二:逻辑过期
java 复制代码
@Data
public class RedisData {
    private Object data;        // 实际数据
    private Long expireTime;    // 逻辑过期时间
}

public User getUser(Long id) {
    String key = "user:" + id;
    
    // 1. 查缓存
    String json = redis.get(key);
    if (json == null) {
        // 缓存不存在,需要重建
        return rebuildCache(key, id);
    }
    
    // 2. 反序列化
    RedisData redisData = JSON.parseObject(json, RedisData.class);
    User user = (User) redisData.getData();
    
    // 3. 判断是否逻辑过期
    if (redisData.getExpireTime() < System.currentTimeMillis()) {
        // 已过期,异步重建
        asyncRebuildCache(key, id);
    }
    
    // 4. 返回旧数据(保证可用性)
    return user;
}

private void asyncRebuildCache(String key, Long id) {
    String lockKey = "lock:" + key;
    
    // 尝试获取锁
    if (redis.tryLock(lockKey, 10)) {
        // 异步重建
        CompletableFuture.runAsync(() -> {
            try {
                rebuildCache(key, id);
            } finally {
                redis.unlock(lockKey);
            }
        });
    }
}

private User rebuildCache(String key, Long id) {
    // 1. 查数据库
    User user = userMapper.findById(id);
    
    // 2. 封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(user);
    redisData.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
    
    // 3. 写入缓存(不设置 TTL)
    redis.set(key, JSON.toJSONString(redisData));
    
    return user;
}

特点

  • 缓存永不过期(物理 TTL)
  • 通过逻辑过期时间判断是否需要刷新
  • 过期时返回旧数据,异步重建新数据
方案三:热点数据永不过期
java 复制代码
// 热点数据设置较长过期时间或不设置过期时间
public void cacheHotData(Long id) {
    User user = userMapper.findById(id);
    
    // 方式1:设置很长过期时间
    redis.set("user:" + id, JSON.toJSONString(user), 24 * 3600);
    
    // 方式2:不设置过期时间,主动更新
    redis.set("user:" + id, JSON.toJSONString(user));
}

// 数据变更时主动更新缓存
public void updateUser(User user) {
    userMapper.update(user);
    redis.set("user:" + user.getId(), JSON.toJSONString(user), 24 * 3600);
}

方案对比

方案 优点 缺点 适用场景
互斥锁 强一致性 需等待、可能死锁 对一致性要求高
逻辑过期 高可用、无等待 可能返回旧数据 对可用性要求高
永不过期 简单可靠 需主动维护 热点数据

三、缓存雪崩

问题场景

复制代码
场景1:大量缓存同时过期
  - 缓存预热时设置了相同的过期时间
  - 如:所有商品缓存都在凌晨 2 点过期

场景2:Redis 宕机
  - Redis 服务挂掉
  - 所有请求直接打到数据库

时间线:
T1: 10000 个 key 同时过期
T2: 10000 个请求同时查数据库
T3: 数据库负载飙升
T4: 系统崩溃

         缓存同时过期
              │
              ▼
    ┌─────────────────────┐
    │   大量请求打到数据库   │
    └─────────────────────┘
              │
              ▼
    ┌─────────────────────┐
    │     数据库崩溃        │
    └─────────────────────┘

解决方案

方案一:随机过期时间
java 复制代码
public void cacheData(String key, Object value) {
    // 基础过期时间
    long baseExpire = 3600;
    
    // 添加随机偏移(0-300秒)
    long randomExpire = ThreadLocalRandom.current().nextLong(300);
    
    // 设置过期时间
    redis.set(key, JSON.toJSONString(value), baseExpire + randomExpire);
}

// 批量缓存时
public void batchCache(List<User> users) {
    for (User user : users) {
        long expire = 3600 + ThreadLocalRandom.current().nextLong(600);
        redis.set("user:" + user.getId(), JSON.toJSONString(user), expire);
    }
}

效果

复制代码
原来:
key1: 过期时间 3600 秒
key2: 过期时间 3600 秒
key3: 过期时间 3600 秒
→ 同时过期!

现在:
key1: 过期时间 3600 + 123 = 3723 秒
key2: 过期时间 3600 + 456 = 4056 秒
key3: 过期时间 3600 + 789 = 4389 秒
→ 分散过期!
方案二:多级缓存
java 复制代码
@Service
public class UserService {
    
    // 本地缓存(Caffeine)
    private Cache<Long, User> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(300, TimeUnit.SECONDS)
        .build();
    
    public User getUser(Long id) {
        // 1. 查本地缓存
        User user = localCache.getIfPresent(id);
        if (user != null) {
            return user;
        }
        
        // 2. 查 Redis
        String key = "user:" + id;
        String json = redis.get(key);
        if (json != null) {
            user = JSON.parseObject(json, User.class);
            // 回填本地缓存
            localCache.put(id, user);
            return user;
        }
        
        // 3. 查数据库
        user = userMapper.findById(id);
        if (user != null) {
            // 写入 Redis
            redis.set(key, JSON.toJSONString(user), 3600);
            // 写入本地缓存
            localCache.put(id, user);
        }
        
        return user;
    }
}

多级缓存架构

复制代码
请求
 │
 ▼
┌─────────────┐
│  本地缓存    │ ← L1 缓存,毫秒级
│ (Caffeine)  │
└─────────────┘
 │ 未命中
 ▼
┌─────────────┐
│ 分布式缓存   │ ← L2 缓存,亚毫秒级
│  (Redis)    │
└─────────────┘
 │ 未命中
 ▼
┌─────────────┐
│   数据库     │ ← 最后防线
└─────────────┘
方案三:熔断降级
java 复制代码
@Service
public class UserService {
    
    @Autowired
    private CircuitBreaker circuitBreaker;
    
    public User getUser(Long id) {
        return circuitBreaker.executeSupplier(() -> {
            // 正常查询流程
            return getUserFromCacheOrDB(id);
        }, () -> {
            // 降级逻辑:返回默认值或缓存数据
            return getDefaultUser(id);
        });
    }
    
    private User getDefaultUser(Long id) {
        // 返回默认用户或从备份缓存读取
        return new User(id, "默认用户", "default.png");
    }
}

熔断器状态

复制代码
        请求失败率 > 阈值
    ┌─────────────────────┐
    │                     │
    ▼                     │
┌────────┐  半开状态  ┌────────┐
│  关闭   │◄─────────│  打开   │
│ (正常)  │          │ (熔断)  │
└────────┘──────────►└────────┘
    │   请求成功        ▲
    │                   │
    └───────────────────┘
        请求失败率 < 阈值
方案四:Redis 高可用
复制代码
┌─────────────────────────────────────────────────────────┐
│                    Redis 高可用架构                       │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  ┌─────────────┐                                        │
│  │   Sentinel  │ ← 哨兵监控                              │
│  │   集群      │   自动故障转移                          │
│  └─────────────┘                                        │
│         │                                                │
│    ┌────┴────┐                                          │
│    ▼         ▼                                          │
│ ┌──────┐  ┌──────┐                                      │
│ │Master│  │Slave │ ← 主从复制                            │
│ │(读写)│  │(只读)│                                       │
│ └──────┘  └──────┘                                      │
│                                                          │
│  或                                                      │
│                                                          │
│ ┌────┬────┬────┬────┬────┬────┐                        │
│ │节点1│节点2│节点3│节点4│节点5│节点6│ ← Redis Cluster    │
│ └────┴────┴────┴────┴────┴────┘   数据分片              │
│                                                          │
└─────────────────────────────────────────────────────────┘
yaml 复制代码
# application.yml
spring:
  redis:
    sentinel:
      master: mymaster
      nodes:
        - 192.168.1.1:26379
        - 192.168.1.2:26379
        - 192.168.1.3:26379
    password: yourpassword

方案对比

方案 优点 缺点 适用场景
随机过期时间 实现简单 不能完全避免 预防为主
多级缓存 高可用 数据一致性复杂 高并发系统
熔断降级 保护系统 影响用户体验 兜底方案
Redis 高可用 根本解决 成本高 核心业务

四、综合防护方案

完整代码示例

java 复制代码
@Service
@Slf4j
public class CacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private BloomFilter<String> bloomFilter;
    
    @Autowired
    private Cache<String, Object> localCache;
    
    @Autowired
    private RateLimiter rateLimiter;
    
    /**
     * 综合防护的缓存查询
     */
    public <T> T getWithProtection(String key, Class<T> type, Supplier<T> dbLoader) {
        // 1. 限流保护
        if (!rateLimiter.tryAcquire()) {
            log.warn("请求被限流: {}", key);
            return null;
        }
        
        // 2. 布隆过滤器判断(防止穿透)
        if (!bloomFilter.mightContain(key)) {
            log.debug("布隆过滤器判断不存在: {}", key);
            return null;
        }
        
        // 3. 查本地缓存(防止雪崩)
        T value = (T) localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // 4. 查 Redis
        value = getFromRedis(key, type);
        if (value != null) {
            localCache.put(key, value);
            return value;
        }
        
        // 5. 获取分布式锁(防止击穿)
        String lockKey = "lock:" + key;
        try {
            if (tryLock(lockKey)) {
                // Double Check
                value = getFromRedis(key, type);
                if (value != null) {
                    return value;
                }
                
                // 6. 查数据库
                value = dbLoader.get();
                
                // 7. 写入缓存
                if (value != null) {
                    setToRedis(key, value);
                    localCache.put(key, value);
                } else {
                    // 缓存空值(防止穿透)
                    redisTemplate.opsForValue().set(key, "NULL", 60, TimeUnit.SECONDS);
                }
                
                return value;
            } else {
                // 等待后重试
                Thread.sleep(50);
                return getWithProtection(key, type, dbLoader);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            unlock(lockKey);
        }
    }
    
    private <T> T getFromRedis(String key, Class<T> type) {
        Object value = redisTemplate.opsForValue().get(key);
        if (value == null || "NULL".equals(value)) {
            return null;
        }
        return (T) value;
    }
    
    private <T> void setToRedis(String key, T value) {
        // 随机过期时间(防止雪崩)
        long expire = 3600 + ThreadLocalRandom.current().nextLong(600);
        redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
    }
}

防护策略总结

复制代码
┌─────────────────────────────────────────────────────────────┐
│                      缓存防护体系                             │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  第一层:限流                                                 │
│  ├── 令牌桶/漏桶算法                                          │
│  └── 防止恶意请求                                             │
│                                                              │
│  第二层:布隆过滤器                                           │
│  ├── 过滤不存在的 key                                         │
│  └── 防止缓存穿透                                             │
│                                                              │
│  第三层:本地缓存                                             │
│  ├── Caffeine/Guava Cache                                    │
│  └── 防止缓存雪崩                                             │
│                                                              │
│  第四层:分布式锁                                             │
│  ├── Redisson/Redis Lock                                     │
│  └── 防止缓存击穿                                             │
│                                                              │
│  第五层:随机过期                                             │
│  ├── 过期时间 + 随机值                                        │
│  └── 防止缓存雪崩                                             │
│                                                              │
│  第六层:熔断降级                                             │
│  ├── Sentinel/Hystrix                                        │
│  └── 兜底保护                                                 │
│                                                              │
└─────────────────────────────────────────────────────────────┘

五、监控告警

关键监控指标

yaml 复制代码
# Prometheus 监控指标
- name: cache_hit_rate
  type: gauge
  description: 缓存命中率
  
- name: cache_qps
  type: counter
  description: 缓存 QPS
  
- name: db_qps
  type: counter
  description: 数据库 QPS
  
- name: cache_error_rate
  type: gauge
  description: 缓存错误率

告警规则

yaml 复制代码
# 告警配置
groups:
  - name: cache_alerts
    rules:
      - alert: CacheHitRateLow
        expr: cache_hit_rate < 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "缓存命中率过低"
          
      - alert: CacheErrorRateHigh
        expr: cache_error_rate > 0.05
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "缓存错误率过高"
          
      - alert: DbQpsSpike
        expr: rate(db_qps[1m]) > 10000
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "数据库 QPS 激增,可能发生缓存雪崩"

总结

问题速查表

问题 根因 核心方案
穿透 查询不存在的数据 布隆过滤器 + 缓存空值
击穿 热点 key 过期 互斥锁 + 逻辑过期
雪崩 大量 key 同时过期 随机过期 + 多级缓存

最佳实践

  1. 预防为主:随机过期时间、布隆过滤器
  2. 多层防护:本地缓存 + Redis + 熔断降级
  3. 监控告警:缓存命中率、数据库 QPS
  4. 高可用架构:Redis 集群、主从复制

一句话总结

穿透用布隆过滤器,击穿用互斥锁,雪崩用随机过期,三者结合多级缓存和熔断降级,构建完整的缓存防护体系。

相关推荐
程序员敲代码吗2 小时前
提升Redis性能的关键:深入探讨主从复制
数据库·redis·github
程序员酥皮蛋2 小时前
Redis 零基础入门本地实现数据增删
数据库·redis·缓存
014-code2 小时前
Redis 旁路缓存深度解析
redis·缓存
你这个代码我看不懂3 小时前
Redis TTL
数据库·redis·缓存
We་ct3 小时前
LeetCode 146. LRU缓存:题解+代码详解
前端·算法·leetcode·链表·缓存·typescript
青衫码上行4 小时前
Redis持久化 (快速入门)
数据库·redis·缓存
敲上瘾5 小时前
从虚拟地址到物理页框:Linux 页表与内存管理全解析
linux·运维·服务器·缓存
青春:一叶知秋5 小时前
【Redis存储】Redis客户端
java·数据库·redis
独泪了无痕6 小时前
通过Homebrew安装Redis指南
数据库·redis·缓存