缓存深入原理
缓存是性能优化的核心手段。核心原理:以空间换时间,把频繁访问的数据放在快速存储中,减少对慢速存储(数据库)的访问。
1. 缓存 vs 数据库
| 对比项 | 缓存(Redis/Memory) | 数据库(MySQL) |
|---|---|---|
| 响应时间 | 纳秒~微秒级 | 毫秒级 |
| 容量 | GB 级(内存) | TB 级(磁盘) |
| 持久化 | 可选(Redis AOF/RDB) | 默认持久化 |
| 数据结构 | KV、List、Set、Hash 等 | 关系型、行列存储 |
| 适用场景 | 热数据、高频访问 | 全量数据、事务、关联查询 |
经典读取模式:
查询请求
↓
先查缓存(Redis)
↓
命中(Cache Hit)→ 直接返回 ✅
↓
未命中(Cache Miss)→ 查数据库 → 写入缓存 → 返回
2. 缓存读写策略
2.1 Cache Aside(旁路缓存):最常用
读:
1. 读缓存,命中直接返回
2. 未命中,读数据库
3. 写回缓存
写:
1. 写数据库
2. 删除缓存(而不是更新缓存)
为什么要删除而不是更新?
// 如果用更新缓存:
updateDb(); // 更新数据库
updateCache(); // 更新缓存
// 问题:并发下可能脏写
线程A:updateDb(user=100) → user=100
线程A:updateCache(user=100) → 缓存=100
线程B:updateDb(user=200) → user=200
线程B:updateCache(user=200) → 缓存=200
// 看起来没问题,但实际执行顺序可能是:
线程A:updateDb(user=100)
线程B:updateDb(user=200) → 数据库=200
线程A:updateCache(user=100) → 缓存=100 ❌(数据库=200)
删除缓存不会出现这个问题,因为下次读会从数据库加载最新值。
2.2 Read Through(读穿透)
缓存作为代理,调用方不感知数据库:
调用方 → 缓存 → 缓存未命中 → 自动查数据库 → 写入缓存 → 返回
调用方只跟缓存打交道,不关心数据从哪来。
2.3 Write Through(写穿透)
写操作同时写缓存和数据库:
写请求 → 写数据库 → 写缓存 → 返回
强一致,但写入性能差,实际很少用。
2.4 Write Behind(异步写回)
写入时只写缓存,异步批量写回数据库:
写请求 → 写缓存 → 返回 ✅(异步写回数据库)
性能最高,但风险最大:缓存数据还没写回数据库时宕机,会丢数据。
3. 缓存三大经典问题
3.1 缓存击穿(Cache Break)
场景:某个热点 key 过期,大并发同时穿透到数据库
热门商品 Key 过期瞬间
↓
10000 并发请求同时发现缓存未命中
↓
全部穿透到数据库
↓
数据库瞬间被打爆 ❌
解决方案:互斥锁 / SETNX
String getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redis.get(cacheKey);
if (cached != null) return cached;
// SETNX 加互斥锁
String lockKey = "lock:product:" + productId;
if (redis.setNX(lockKey, "1", 10)) { // 10秒锁过期
try {
String product = db.getProduct(productId);
redis.setex(cacheKey, 3600, product);
return product;
} finally {
redis.del(lockKey);
}
} else {
// 拿不到锁,短暂等待后重试
Thread.sleep(50);
return getProduct(productId);
}
}
解决方案:热点数据永不过期
// 异步更新:缓存不过期,但定期刷新
String getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redis.get(cacheKey);
if (cached != null) return cached;
String product = db.getProduct(productId);
// 用旧值再设置一次,延长TTL
redis.setex(cacheKey, 3600, product);
// 异步更新缓存(后台线程)
schedule(() -> {
String fresh = db.getProduct(productId);
redis.setex(cacheKey, 3600, fresh);
});
return product;
}
3.2 缓存穿透(Cache Miss Attack)
场景:查询数据库根本不存在的数据,缓存无法命中,每次都查数据库
攻击者大量请求:productId = -1 或不存在的 ID
↓
缓存查不到
↓
数据库也查不到(正常返回空)
↓
缓存永不被写入
↓
每次请求都打数据库 ❌
解决方案1:布隆过滤器(Bloom Filter)
Redis 中存一个 Bloom Filter
↓
查询前先问 Bloom Filter:这个 key 可能存在吗?
↓
Bloom Filter 说"不存在" → 直接返回空 ✅(不查数据库)
Bloom Filter 说"可能存在" → 查 Redis → 未命中 → 查 DB
布隆过滤器原理:多个哈希函数映射到位数组,存在可能(可能有 false positive),不存在则一定不存在。
解决方案2:空值缓存
String getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redis.get(cacheKey);
if (cached != null) {
if (cached.equals("NULL")) return null; // 空值缓存
return cached;
}
Product product = db.getProduct(productId);
if (product == null) {
redis.setex(cacheKey, 60, "NULL"); // 空值缓存60秒
} else {
redis.setex(cacheKey, 3600, product);
}
return product;
}
注意:空值缓存的 TTL 不能太长,否则真实数据写入后还要等 TTL 过期。
3.3 缓存雪崩(Cache Avalanche)
场景:大量 key 同时过期,或者 Redis 宕机
大量 key 同时过期:午夜 0 点整
↓
大量请求穿透到数据库
↓
数据库被打爆 ❌
Redis 宕机
↓
所有请求直接打数据库
↓
应用全部报错 ❌
解决方案:过期时间加随机值
// 不要统一设置 TTL = 3600
// 随机偏移量
int ttl = 3600 + random.nextInt(600); // 3600~4200 秒
redis.setex(cacheKey, ttl, value);
解决方案:Redis 高可用
Redis Sentinel(哨兵)→ 主从自动切换
Redis Cluster → 数据分片 + 高可用
解决方案:本地缓存兜底
Redis 崩了 → 读取本地缓存(Guava Cache / Caffeine)
→ 服务仍可用(数据可能稍旧)
4. 多级缓存架构
4.1 二级缓存:本地 + 分布式缓存
请求 → Nginx(Lua 缓存)→ 应用内存(Local Cache)→ Redis → MySQL
L1 L2
| 级别 | 响应 | 容量 | 共享 | 适用场景 |
|---|---|---|---|---|
| L1 本地缓存 | 纳秒 | MB | 否(进程隔离) | 极高频、不变的数据 |
| L2 分布式缓存 | 微秒 | GB | 是 | 跨进程共享 |
| 数据库 | 毫秒 | TB | 是 | 最终数据源 |
4.2 Guava Cache / Caffeine 本地缓存
LoadingCache<String, Product> cache = Caffeine.newBuilder()
.maximumSize(1000) // 最大条目数
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.build(key -> db.getProduct(key)); // 未命中时加载
// 使用
Product product = cache.get(productId);
Guava Cache vs Caffeine:Caffeine 是 Guava Cache 的升级版,性能更好,支持异步加载。
5. 缓存与数据库的一致性
5.1 为什么很难保证强一致
时间线:
T1: 线程A 更新数据库(user=200)
T2: 线程B 查询缓存(返回旧值100)
T3: 线程A 删除缓存
T4: 线程B 查询数据库(user=200)→ 写入缓存(user=200)
结果:缓存=200 ✅(最终一致)
但在 T2~T3 期间,线程 B 返回了脏数据。
5.2 最终一致性方案
延迟双删(业界最常用):
public void updateUser(User user) {
// 第一步:删缓存
redis.del("user:" + user.getId());
// 第二步:更新数据库
db.updateUser(user);
// 第三步:延迟再删(等读请求把旧数据写回缓存)
Thread.sleep(100);
redis.del("user:" + user.getId());
}
订阅 Binlog 方案(更解耦):
数据库变更 → Binlog 日志
↓
Canal 订阅 Binlog
↓
解析变更内容 → 更新 Redis 缓存
阿里中间件团队用 Canal 监听 MySQL Binlog,解析后更新 Redis,业务代码完全不感知。
6. Redis 持久化与恢复
6.1 RDB(快照)
每隔 N 分钟/小时,fork 进程dump内存到磁盘
↓
生成 rdb 文件(压缩二进制)
↓
恢复快,但可能丢失最后一次快照之后的数据
优点:恢复快(直接加载 RDB 文件) 缺点:可能丢失最后一次快照后的数据
6.2 AOF(Append Only File)
每次写操作 → 追加到 AOF 文件
↓
Redis 重启时重放 AOF 命令恢复数据
↓
三种刷盘策略:
appendfsync=always 每次写都刷盘(最安全,性能差)
appendfsync=everysec 每秒刷盘(默认,推荐)
appendfsync=no 由系统决定(最快,可能丢一秒数据)
优点:数据安全(everysec 只丢一秒数据) 缺点:AOF 文件比 RDB 大,恢复慢
6.3 混合持久化(4.0+)
RDB + AOF 混合模式
↓
重写时生成 RDB 格式的 base + AOF 的增量
↓
恢复时先加载 RDB 再重放 AOF
↓
兼顾性能和数据安全
7. 缓存监控与优化
7.1 关键监控指标
| 指标 | 说明 | 健康值 |
|---|---|---|
| 内存使用率 | info memory |
< 70% |
| 缓存命中率 | info stats / keyspace_hits |
> 80% |
| 慢查询 | slowlog get 10 |
< 1ms |
| 连接数 | info clients |
< maxclients |
| AOF 文件大小 | ls -lh appendonly.aof |
监控增长率 |
7.2 缓存大 Key 问题
# 找出大 Key(超过 10MB)
redis-cli --bigkeys
# 扫描大 Key
redis-cli --scan --pattern * | xargs -I{} redis-cli --bigkeys {}
大 Key(String 类型超过 10MB,Set 超过 5 万元素)会导致:
- 读取慢(网络传输大)
- 过期删除时阻塞(Redis 单线程)
解决方案:拆分大 Key(String 改为 Hash,按字段拆分)
8. 缓存策略选择总结
| 数据特点 | 推荐策略 |
|---|---|
| 数据频繁变化 | 不用缓存 |
| 热数据、读多写少 | Cache Aside |
| 写多读少 | Write Through |
| 需要强一致 | 不用缓存,或延迟双删 |
| 极高一致性要求 | 数据库是唯一真相源 |
| 允许短暂不一致 | 缓存 + TTL |