Skip to content

缓存问题与解决方案

  缓存是提升性能的利器,但引入缓存也带来了新的问题。缓存穿透、击穿、雪崩、一致性、热 key、大 key……每个问题都能让系统崩溃。本章逐一分析原因和解决方案。

一、缓存穿透

1.1 什么是缓存穿透?

  查询一个根本不存在的数据,缓存和数据库都没有,每次请求都穿透到数据库。

请求流程:
  客户端 → 查缓存(不存在) → 查数据库(不存在) → 返回 null

  问题:大量请求查询不存在的数据,数据库压力巨大
  典型场景:黑客用不存在的 ID 攻击,如 /product/-1

1.2 解决方案

方案一:缓存空值

java
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product product = redis.get(key);
    if (product != null) {
        return product;
    }
    
    product = db.query(id);
    if (product == null) {
        // 缓存 null 值,过期时间短一些
        redis.set(key, "null", 60);  // 60 秒过期
        return null;
    }
    
    redis.set(key, product, 3600);
    return product;
}

方案二:布隆过滤器

java
// 启动时将所有商品 ID 加入布隆过滤器
BloomFilter<Long> bloomFilter = BloomFilter.create(
    Funnels.longFunnel(), 1000000, 0.01  // 100 万数据,1% 误判率
);

// 请求时先查布隆过滤器
public Product getProduct(Long id) {
    if (!bloomFilter.mightContain(id)) {
        return null;  // 一定不存在,直接返回
    }
    // 可能存在,继续查缓存和数据库
    return getFromCacheOrDB(id);
}
方案优点缺点
缓存空值简单,即时生效占用内存,数据不一致
布隆过滤器内存极少,误判率可控实现复杂,删除困难
组合方案双重保护略复杂

1.3 布隆过滤器原理

布隆过滤器 = 位数组 + 多个哈希函数

添加元素:
  key = "user:1001"
  hash1(key) % N = 3   →  bit[3] = 1
  hash2(key) % N = 7   →  bit[7] = 1
  hash3(key) % N = 12  →  bit[12] = 1

查询元素:
  key = "user:1001"
  hash1(key) % N = 3   →  bit[3] = 1 ✅
  hash2(key) % N = 7   →  bit[7] = 1 ✅
  hash3(key) % N = 12  →  bit[12] = 1 ✅
  → 可能存在(所有位都是 1)

  key = "user:9999"(不存在)
  hash1(key) % N = 3   →  bit[3] = 1 ✅
  hash2(key) % N = 9   →  bit[9] = 0 ❌
  → 一定不存在(有一位是 0)

特性:
  ✅ 一定不存在 → 100% 准确
  ❌ 可能存在 → 有误判率(p)
  误判率公式:p ≈ (1 - e^(-kn/m)) ^ k
  k = 哈希函数个数, n = 元素数量, m = 位数组长度

二、缓存击穿

2.1 什么是缓存击穿?

  一个热点 key 过期瞬间,大量请求同时打到数据库。

热点 key "product:1001" 过期:

  时间线:
  10:00:00  key 过期
  10:00:00.001  请求 1 → 查缓存(不存在) → 查数据库
  10:00:00.002  请求 2 → 查缓存(不存在) → 查数据库
  10:00:00.003  请求 3 → 查缓存(不存在) → 查数据库
  ...
  10:00:00.100  请求 100 → 查缓存(不存在) → 查数据库

  结果:100 个请求同时打到数据库,可能直接打挂 DB

2.2 解决方案

方案一:互斥锁

java
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product product = redis.get(key);
    if (product != null) {
        return product;
    }
    
    String lockKey = "lock:product:" + id;
    try {
        // 尝试获取锁
        if (redis.set(lockKey, "1", "NX", "EX", 10)) {
            // 双重检查
            product = redis.get(key);
            if (product != null) {
                return product;
            }
            // 查数据库
            product = db.query(id);
            redis.set(key, product, 3600);
            return product;
        } else {
            // 没获取到锁,等待后重试
            Thread.sleep(100);
            return getProduct(id);  // 递归重试
        }
    } finally {
        redis.del(lockKey);
    }
}

方案二:逻辑过期

java
public Product getProduct(Long id) {
    String key = "product:" + id;
    CacheData cacheData = redis.get(key);
    
    if (cacheData == null) {
        return getFromDBAndCache(id);
    }
    
    // 逻辑过期时间
    if (LocalDateTime.now().isAfter(cacheData.getExpireTime())) {
        // 异步重建缓存,当前请求返回旧数据
        executorService.submit(() -> rebuildCache(id));
        return cacheData.getData();  // 返回旧数据,保证可用性
    }
    
    return cacheData.getData();
}
方案优点缺点
互斥锁保证一致性,实现简单等待期间性能下降
逻辑过期非阻塞,用户体验好短暂数据不一致

三、缓存雪崩

3.1 什么是缓存雪崩?

  大量 key 同时过期Redis 宕机,所有请求打到数据库,数据库瞬间崩溃,进而导致整个系统不可用。

大量 key 同时过期:
  设置了相同的过期时间,如活动开始后 1 小时

  ┌──────────────────────────────────────────────────────┐
  │  T 时刻:1000 个 key 同时过期                          │
  │                                                      │
  │  请求洪水 → 缓存(全部 miss) → 数据库(击垮)          │
  └──────────────────────────────────────────────────────┘

Redis 宕机:
  所有请求 → 缓存不可用 → 数据库(瞬间击垮)

3.2 解决方案

方案一:过期时间加随机值

java
// 避免同一时间大量过期
int baseExpire = 3600;  // 基础 1 小时
int random = new Random().nextInt(600);  // 随机 0-10 分钟
redis.set(key, value, baseExpire + random);

方案二:多级缓存

客户端 → 本地缓存(Caffeine) → Redis → 数据库

  ┌──────────┐     ┌──────────┐     ┌──────────┐
  │ 本地缓存  │ ──→ │  Redis   │ ──→ │  数据库   │
  │ (1 分钟) │     │ (30 分钟)│     │ (永久)   │
  └──────────┘     └──────────┘     └──────────┘

  即使 Redis 挂了,本地缓存还能撑一会

方案三:Redis 高可用

哨兵模式:Master 宕机,自动故障转移
集群模式:多 Master 分担,部分节点挂了不影响整体

方案四:限流降级

java
// Sentinel 限流保护数据库
@SentinelResource(value = "getProduct", fallback = "getProductFallback")
public Product getProduct(Long id) {
    // 正常逻辑
}

public Product getProductFallback(Long id) {
    return Product.defaultProduct();  // 兜底数据
}

四、缓存一致性

4.1 问题场景

数据库更新了,但缓存还是旧数据:

  ┌──────────┐     ┌──────────┐
  │  数据库   │     │  Redis   │
  │  price=100│     │ price=100│
  └──────────┘     └──────────┘

        ▼  更新 price=200
  ┌──────────┐     ┌──────────┐
  │  数据库   │     │  Redis   │
  │  price=200│     │ price=100│  ← 不一致!
  └──────────┘     └──────────┘

4.2 更新策略

策略流程适用场景
先删缓存,再更新 DBDEL → UPDATE DB读写并发少
先更新 DB,再删缓存UPDATE DB → DEL推荐,失败率低
先更新 DB,再更新缓存UPDATE DB → SET不推荐,并发写容易覆盖
双删策略DEL → UPDATE DB → 延时 DEL强一致性要求

推荐:先更新 DB,再删缓存

java
@Transactional
public void updateProduct(Product product) {
    // 1. 更新数据库
    db.update(product);
    // 2. 删除缓存
    redis.del("product:" + product.getId());
}

4.3 删除失败怎么办?

方案一:MQ 重试

更新 DB → 删除缓存失败 → 发送 MQ 消息 → 消费者重试删除

方案二:Canal 订阅 binlog

MySQL 更新 → Canal 监听 binlog → 通知 Redis 删除缓存

4.4 最终一致性方案

允许短暂不一致,通过过期时间兜底:

  TTL = 5 分钟
  最坏情况:5 分钟内数据不一致,之后自动过期,下次查询刷新

  适用于:非核心业务、对一致性要求不高的场景

五、热 key 问题

5.1 什么是热 key?

  某个 key 被超高频率访问(如秒杀商品、明星微博),QPS 远超单节点承载能力。

热 key 问题:
  商品 A 秒杀,100W QPS 集中打在一个 key 上
  → 单节点 Redis 扛不住
  → 即使 Redis 扛住了,网络带宽也成瓶颈

5.2 解决方案

方案一:本地缓存

java
// Caffeine 本地缓存 + Redis 二级缓存
Cache<String, Product> localCache = Caffeine.newBuilder()
    .expireAfterWrite(1, TimeUnit.SECONDS)
    .maximumSize(1000)
    .build();

public Product getProduct(Long id) {
    String key = "product:" + id;
    // 一级:本地缓存
    Product product = localCache.getIfPresent(key);
    if (product != null) return product;
    // 二级:Redis
    product = redis.get(key);
    if (product != null) {
        localCache.put(key, product);
        return product;
    }
    // 三级:数据库
    product = db.query(id);
    redis.set(key, product, 3600);
    return product;
}

方案二:多副本

将热 key 复制多份,分散到不同 Redis 节点:

  key: product:1:0 → 节点 1
  key: product:1:1 → 节点 2
  key: product:1:2 → 节点 3

  客户端随机选择一个 key 读取,分散压力

六、大 key 问题

6.1 什么是大 key?

类型标准危害
String > 10KB大 value网络传输慢,阻塞 Redis
集合元素 > 1W大集合删除慢,阻塞 Redis
总数据量 > 100MB超大 key迁移困难,影响集群均衡

6.2 解决方案

bash
# 发现大 key
redis-cli --bigkeys
redis-cli MEMORY USAGE key

# 拆分大 key
# 原始:hash user:1001 → 1000 个字段
# 拆分:hash user:1001:0, user:1001:1, user:1001:2 ...

# 异步删除
UNLINK key  # Redis 4.0+,异步删除,不阻塞

七、面试要点

Q1:缓存穿透、击穿、雪崩的区别?

问题原因区别
穿透查不存在的数据数据库也没有,一直穿透
击穿热点 key 过期单个 key 过期,瞬间并发
雪崩大量 key 同时过期/Redis 宕机大批量 key 同时失效

Q2:如何保证缓存和数据库一致性?

  推荐"先更新 DB,再删除缓存"。更新 DB 成功但删除缓存失败时,通过 MQ 重试或 Canal 监听 binlog 补偿。对一致性要求不高的场景,可以靠 TTL 过期兜底。

Q3:布隆过滤器的原理?

  位数组 + 多个哈希函数。添加元素时将多个哈希位置置为 1,查询时检查所有位置是否都为 1。一定不存在(100% 准确),可能存在(有误判率)。