本文目录导读:
缓存更新是分布式系统和高并发场景下的核心难题,优化策略的核心目标通常是:在保证数据最终一致性的前提下,最大化缓存命中率,并最小化对数据库的压力(避免缓存雪崩、击穿、穿透)。
以下是一些经过实践检验的高效缓存的优化策略,从基础到进阶:
核心策略:基于业务场景选择更新模式
这是最基础也最重要的决策,选错模式会导致系统复杂度飙升。
-
Cache-Aside(旁路缓存)—— 最常用,推荐
- 读: 先读缓存;缓存 miss(未命中)则读 DB,写入缓存后返回。
- 写: 先更新数据库,再删除缓存(而不是更新缓存)。
- 为什么删除而不是更新? 因为更新缓存可能会导致缓存与数据库的写并发冲突(即A写DB,B写缓存,但顺序错乱导致数据不一致),删除缓存是懒惰的,等下一次读的时候再重建。
- 优化点: 结合“延迟双删”(下文详述)。
-
Read/Write Through(读写穿透)—— 适合缓存即存储的场景
- 缓存层与数据库同属一个抽象层,应用只操作缓存,缓存代理负责同步到DB。
- 优点: 应用代码简单。
- 缺点: 缓存中间件需要支持该能力(如 Redis Enterprise 或 Hazelcast 的部分特性),对现有Redis集群改造较大,不常用。
-
Write Behind(异步写回)—— 高吞吐、低一致性
- 数据只更新到缓存,缓存异步批量写回DB。
- 适用场景: 秒杀点击量、日志收集、非关键数据。
- 风险: 如果缓存宕机,未落盘的数据会丢失,需要配合消息队列或 WAL(Write-Ahead Logging,预写日志)来保证容灾。
保证最终一致性的“硬核”策略(针对 Cache-Aside)
在“先更新DB,再删除缓存”的模式下,依然存在两个典型的并发一致性问题:
- 问题1: A更新DB(旧值->新值),B读DB(旧值)并写入缓存,导致缓存长期为旧值。
- 问题2: 删除缓存失败(网络抖动、Redis超时)。
解决方案:
-
延迟双删(最常用且有效)
- 步骤:更新DB -> 先删缓存 -> 休眠几百ms(或1s) -> 再删一次缓存。
- 为什么有效? 第一次删除是为了让其他读请求(如上述B)重新从DB读最新数据;第二次删除是为了清除在“第一次删除后到休眠结束前”可能写入缓存的旧数据(极小概率,但能兜底)。
- 优化: 这个休眠时间可以基于“一次读请求的平均耗时 + 数据同步延迟”来动态调整。
-
异步监听 Binlog(最强一致性,推荐用于关键数据)
- 架构: 订阅数据库的 Binlog(如 MySQL 的 Canal,PostgreSQL 的 Debezium),当数据变更时,异步解析 Binlog 中的变化,将其应用到缓存。
- 为什么是最强? 不依赖业务代码的“删除”请求,数据库主从同步完成后,Binlog 一定会触发,避免了由于应用层面(如代码 Bug、网络超时)导致的缓存不一致。
- 代价: 引入消息中间件(Kafka/RocketMQ)和监听组件,架构复杂,运维成本高。
-
设置合理过期时间(这是最低保障)
- 所有缓存都必须设置 TTL(Time to Live,生存时间),即使数据一直更新,这是兜底策略,确保缓存最终会被淘汰,恢复一致性。
- 优化点: 根据业务容忍度来设定(如:用户基本信息可设为30分钟,商品库存设为1秒)。
针对高并发下缓存“三大坑”的优化
-
缓存穿透(查询一个一定不存在的数据)
- 策略1:布隆过滤器(Bloom Filter)
- 在缓存前加一层布隆过滤器(内存或 Redis Module)。
- 可以快速判断Key是否“一定不存在”,如果不存在,直接返回“空”,避免击穿DB。
- 注意: 布隆过滤器只能判断不存在,不能判断存在,需要定时全量重建。
- 策略2:缓存空对象
如果DB查询返回空,仍然将这个空结果(标记为 null 或特殊值)缓存起来,设置一个很短的 TTL(如1分钟),防止同一Key重复打库。
- 策略1:布隆过滤器(Bloom Filter)
-
缓存击穿(一个热 Key 在过期瞬间,大量请求打到 DB)
- 策略1:互斥锁(Mutex Key)
- 当缓存Miss时,先尝试获取分布式锁(如 Redis 的
SETNX),只有拿到锁的线程才去查DB并重建缓存,其他线程等待或重试。 - 缺点: 引入锁会增加延迟,极少数情况下可能死锁。
- 当缓存Miss时,先尝试获取分布式锁(如 Redis 的
- 策略2:逻辑永不过期(逻辑过期)
- 不设置物理过期时间,而是在 value 中存储一个逻辑过期时间。
- 当线程读取到数据,发现逻辑过期,单独启动一个异步线程去更新缓存(采用互斥锁),自己仍返回旧数据。
- 优点: 读操作永不阻塞,性能极高。
- 缺点: 短期内数据不一致(多了一个线程更新),且实现复杂(需要异步线程池)。
- 策略1:互斥锁(Mutex Key)
-
缓存雪崩(大量 Key 在同一时间过期,或缓存节点宕机)
- 策略1:过期时间加随机值
避免所有Key在同一时间过期,设置基础过期时间 + 一个随机数(如 5分钟 + 随机 [0, 300] 秒)。
- 策略2:多级缓存
- L1(本地缓存,如 Caffeine/Guava Cache) + L2(分布式缓存,如 Redis)。
- 即使 Redis 崩溃,本地缓存仍能扛住部分流量,为恢复争取时间。
- 策略3:高可用
使用 Redis 集群(如 Codis/Twemproxy/Redis Cluster)或 Redis Sentinel 哨兵机制实现自动故障转移。
- 策略1:过期时间加随机值
进阶优化:针对特定模式
| 业务场景 | 推荐策略 | 说明 |
|---|---|---|
| 热点数据频繁更新(如热门文章点赞数) | 写操作进 Queue,异步合并 | 先更新Redis(INCR),然后批量写回DB(如每10秒或每100次请求),降低写库压力。 |
| 分页列表/聚合数据(如文章列表) | 序列化 + 增量更新 | 不缓存整个分页结果,改缓存页面的 ID列表,当新增数据时,只需在列表头部追加一个ID,而不是重新生成整个页面。 |
| 大量冷门数据 | 惰性加载 + 小缓存池 | 使用 LRU(Least Recently Used,最近最少使用)淘汰策略,只保留最近访问过的20%数据,对于80%的冷数据,每次从DB查都不会影响性能。 |
| 大数据量(>100MB)的缓存 | 压缩 + 分片 | 使用 Snappy/LZ4 压缩 value;或者将大Key拆分为多个小Key分片存储,一次读取多个分片,避免单Key过大导致网络I/O瓶颈。 |
一个标准配置模板(以 Java 为例)
public class UserCacheService {
// 1. 基础模式
public User getById(Long userId) {
String key = "user:" + userId;
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
// 2. 逻辑过期检测
if (user.isExpired()) {
CompletableFuture.runAsync(() -> rebuildCache(key, userId)); // 异步更新
}
return user;
}
// 3. 防止穿透:布隆过滤
if (!bloomFilter.contains(key)) {
return null;
}
// 4. 防止击穿:互斥锁重建
String lockKey = "lock:user:" + userId;
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS)) {
try {
user = userMapper.getById(userId);
if (user == null) {
// 防止穿透:缓存空对象,TTL 30秒
redisTemplate.opsForValue().set(key, new User(), 30, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, user, 3600 + RandomUtils.nextInt(300), TimeUnit.SECONDS);
}
return user;
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
} else {
// 等待锁或直接返回旧数据的简易版
Thread.sleep(50);
return redisTemplate.opsForValue().get(key); // 重试
}
}
// 5. 更新操作:先更新DB,再删除缓存(延迟双删)
public void updateUser(User user) {
userMapper.updateById(user);
redisTemplate.delete("user:" + user.getId()); // 第一次删
// 延迟 1 秒
executorService.schedule(() -> {
redisTemplate.delete("user:" + user.getId()); // 第二次删
}, 1, TimeUnit.SECONDS);
}
}
最终建议:
- 业务容忍度: 高一致性需求(如交易、库存)用 Binlog 订阅;低一致性需求(如用户点击、页面PV)用异步写回 + 最终删除。
- 不要过度设计: 80% 的场景,“更新DB + 删除缓存 + 延迟双删”已经足够。
- 监控先行: 无论什么策略,一定要监控 缓存命中率、缓存过期驱逐数、DB慢查询,数据驱动决策。
标签: 缓存更新策略