Appearance
缓存问题与解决方案
缓存是提升性能的利器,但引入缓存也带来了新的问题。缓存穿透、击穿、雪崩、一致性、热 key、大 key……每个问题都能让系统崩溃。本章逐一分析原因和解决方案。
一、缓存穿透
1.1 什么是缓存穿透?
查询一个根本不存在的数据,缓存和数据库都没有,每次请求都穿透到数据库。
请求流程:
客户端 → 查缓存(不存在) → 查数据库(不存在) → 返回 null
问题:大量请求查询不存在的数据,数据库压力巨大
典型场景:黑客用不存在的 ID 攻击,如 /product/-11.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 个请求同时打到数据库,可能直接打挂 DB2.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 更新策略
| 策略 | 流程 | 适用场景 |
|---|---|---|
| 先删缓存,再更新 DB | DEL → 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% 准确),可能存在(有误判率)。
