缓存是提升系统性能的第一利器 ,但也是引发故障的第一杀手 。缓存穿透、缓存击穿、缓存雪崩——这三大问题几乎是每个后端工程师都会遇到的挑战。本文将系统性地剖析缓存系统的设计原则、经典问题及其解决方案,从单机缓存到分布式缓存,从理论到生产实践。
Part 1: 缓存的基本原理
为什么需要缓存
存储介质
访问延迟
吞吐量
成本
CPU L1 Cache
~1 ns
—
极高
CPU L3 Cache
~10 ns
—
高
内存(RAM)
~100 ns
~10 GB/s
中
SSD
~100 μs
~500 MB/s
低
HDD
~10 ms
~100 MB/s
极低
网络(同机房)
~500 μs
—
—
网络(跨机房)
~50 ms
—
—
内存访问比 SSD 快 1000 倍 ,比 HDD 快 100,000 倍 。缓存的本质就是用更快的存储介质 保存热点数据 的副本。
缓存的命中率
命中率 = 命中次数 / (命中次数 + 未命中次数)
命中率
效果
说明
< 50%
差
缓存几乎没有意义
50-80%
一般
有一定效果
80-95%
好
显著减轻数据库压力
> 95%
优秀
数据库几乎无压力
缓存的分类
类型
位置
代表
适用场景
客户端缓存
浏览器/App
HTTP Cache、LocalStorage
静态资源
CDN 缓存
边缘节点
CloudFlare、阿里云 CDN
静态文件、图片
反向代理缓存
Nginx
proxy_cache
页面缓存
本地缓存
应用进程内
Caffeine、Guava Cache
热点数据、配置
分布式缓存
独立集群
Redis、Memcached
共享数据、Session
Part 2: 缓存读写策略
Cache-Aside(旁路缓存)
最常用 的缓存策略:
1 2 3 4 5 6 7 8 读取流程:1. 先查缓存2. 缓存命中 → 直接返回3. 缓存未命中 → 查数据库 → 写入缓存 → 返回 写入流程:1. 更新数据库2. 删除缓存(而非更新缓存)
为什么是"删除缓存"而非"更新缓存"?
策略
问题
更新缓存
并发写入时可能导致缓存与数据库不一致(后写入数据库的先更新了缓存)
删除缓存
下次读取时重新加载,保证最终一致性
为什么是"先更新数据库,再删除缓存"?
顺序
问题
先删缓存,再更新数据库
删除缓存后、更新数据库前,另一个请求读到旧数据并写入缓存
先更新数据库,再删缓存
更新数据库后、删除缓存前,短暂的不一致(可接受)
Read-Through / Write-Through
缓存层作为数据库的代理,应用只与缓存交互:
1 2 3 4 5 Read - Through : 应用 → 缓存(未命中时自动从数据库加载)Write - Through : 应用 → 缓存 → 数据库(同步写入)
优势:应用代码简单,缓存逻辑封装在缓存层。
劣势:写入延迟高(同步写数据库)。
Write-Behind(异步写回)
1 应用 → 缓存(立即返回)→ 异步批量写入数据库
优势:写入延迟极低,可以合并多次写入。
劣势:数据可能丢失(缓存故障时未持久化的数据)。
适用场景:计数器、浏览量、点赞数等允许少量丢失的场景。
Refresh-Ahead(预刷新)
在缓存过期之前 ,异步刷新缓存:
1 2 3 4 5 缓存 TTL = 60 秒 刷新窗口 = TTL × 0.8 = 48 秒 当缓存存活时间 > 48 秒时,异步刷新缓存 用户始终读到缓存数据,不会触发缓存未命中
优势:避免缓存过期瞬间的请求穿透。
劣势:实现复杂,可能刷新不再被访问的数据。
Part 3: 缓存穿透
问题描述
缓存穿透 :查询一个不存在的数据 ,缓存中没有,数据库中也没有。每次请求都会穿透缓存直达数据库。
1 2 3 4 5 攻击者请求 id = -1 的用户 → 缓存未命中 → 数据库查询:SELECT * FROM users WHERE id = -1 → 空结果 → 不缓存空结果 → 下次请求仍然穿透
解决方案一:缓存空值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public User getUser (long userId) { String cacheKey = "user:" + userId; String cached = redis.get(cacheKey); if (cached != null ) { if ("NULL" .equals(cached)) { return null ; } return deserialize(cached); } User user = userDao.findById(userId); if (user != null ) { redis.setex(cacheKey, 3600 , serialize(user)); } else { redis.setex(cacheKey, 300 , "NULL" ); } return user; }
注意 :空值的 TTL 应当较短(如 5 分钟),避免数据创建后长时间无法查到。
解决方案二:布隆过滤器
在缓存之前加一层布隆过滤器,快速判断数据是否存在:
1 2 请求 → 布隆过滤器 → 不存在?直接返回 null → 可能存在?查缓存 → 查数据库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class UserService { private final BloomFilter<Long> userIdBloomFilter; public UserService () { this .userIdBloomFilter = BloomFilter.create( Funnels.longFunnel(), 10_000_000 , 0.01 ); userDao.findAllIds().forEach(userIdBloomFilter::put); } public User getUser (long userId) { if (!userIdBloomFilter.mightContain(userId)) { return null ; } return getFromCacheOrDb(userId); } }
解决方案三:参数校验
在入口处进行参数校验,拦截明显非法的请求:
1 2 3 4 5 6 public User getUser (long userId) { if (userId <= 0 ) { throw new IllegalArgumentException ("Invalid user ID: " + userId); } return getFromCacheOrDb(userId); }
三种方案对比
方案
优势
劣势
适用场景
缓存空值
实现简单
占用缓存空间
空值数量有限
布隆过滤器
空间效率高
有误判率,需要维护
数据集较大
参数校验
零成本
只能拦截明显非法请求
所有场景(基础防线)
Part 4: 缓存击穿
问题描述
缓存击穿 :一个热点 Key 在缓存过期的瞬间,大量并发请求同时穿透到数据库。
1 2 3 4 5 时间线: T0: 热点 Key 过期 T1: 1000 个并发请求同时发现缓存未命中 T2: 1000 个请求同时查询数据库 T3: 数据库被打垮
解决方案一:互斥锁(Mutex Lock)
只允许一个请求去加载数据,其他请求等待:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public User getUser (long userId) { String cacheKey = "user:" + userId; String cached = redis.get(cacheKey); if (cached != null ) { return deserialize(cached); } String lockKey = "lock:user:" + userId; boolean locked = redis.setnx(lockKey, "1" , 10 , TimeUnit.SECONDS); if (locked) { try { cached = redis.get(cacheKey); if (cached != null ) { return deserialize(cached); } User user = userDao.findById(userId); redis.setex(cacheKey, 3600 , serialize(user)); return user; } finally { redis.del(lockKey); } } else { Thread.sleep(50 ); return getUser(userId); } }
解决方案二:逻辑过期
缓存永不过期,但在值中存储一个逻辑过期时间 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class CacheValue <T> { private T data; private long expireTime; public boolean isExpired () { return System.currentTimeMillis() > expireTime; } }public User getUser (long userId) { String cacheKey = "user:" + userId; CacheValue<User> cacheValue = redis.get(cacheKey); if (cacheValue == null ) { return loadAndCache(userId); } if (!cacheValue.isExpired()) { return cacheValue.getData(); } String lockKey = "lock:refresh:" + userId; if (redis.setnx(lockKey, "1" , 10 , TimeUnit.SECONDS)) { executor.submit(() -> { try { loadAndCache(userId); } finally { redis.del(lockKey); } }); } return cacheValue.getData(); }
解决方案三:热点 Key 永不过期
对于已知的热点 Key,设置永不过期,通过后台任务定期刷新:
1 2 3 4 5 6 7 8 @Scheduled(fixedRate = 30000) public void refreshHotKeys () { List<String> hotKeys = getHotKeyList(); for (String key : hotKeys) { Object data = loadFromDb(key); redis.set(key, serialize(data)); } }
三种方案对比
方案
一致性
可用性
复杂度
互斥锁
强一致
可能阻塞
中
逻辑过期
最终一致(短暂返回旧数据)
高(不阻塞)
高
永不过期
最终一致
最高
低
Part 5: 缓存雪崩
问题描述
缓存雪崩 :大量缓存 Key 同时过期 ,或者缓存服务整体宕机 ,导致所有请求直达数据库。
1 2 3 4 5 6 7 8 场景一:大量 Key 同时过期 T0 : 系统启动时批量加载缓存,TTL 都设为 1 小时 T0 + 1h : 所有缓存同时过期,数据库瞬间承受全部流量 场景二:Redis 宕机 T0 : Redis 集群故障 T1 : 所有请求直达数据库 T2 : 数据库被打垮,整个系统不可用
解决方案一:随机 TTL
给 TTL 加上随机偏移,避免同时过期:
1 2 3 4 5 6 7 8 9 10 public void setWithRandomTtl (String key, String value, int baseTtlSeconds) { int randomOffset = ThreadLocalRandom.current().nextInt(baseTtlSeconds / 5 ); int actualTtl = baseTtlSeconds + randomOffset; redis.setex(key, actualTtl, value); } setWithRandomTtl("user:1001" , userData, 3600 );
解决方案二:多级缓存
1 请求 → L1 本地缓存(Caffeine)→ L2 分布式缓存(Redis)→ 数据库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class MultiLevelCache { private final Cache<String, Object> localCache; private final RedisTemplate<String, Object> redis; public MultiLevelCache () { this .localCache = Caffeine.newBuilder() .maximumSize(10_000 ) .expireAfterWrite(5 , TimeUnit.MINUTES) .build(); } public Object get (String key) { Object value = localCache.getIfPresent(key); if (value != null ) return value; value = redis.opsForValue().get(key); if (value != null ) { localCache.put(key, value); return value; } value = loadFromDb(key); if (value != null ) { redis.opsForValue().set(key, value, 1 , TimeUnit.HOURS); localCache.put(key, value); } return value; } }
多级缓存的一致性问题 :
当数据更新时,需要同时失效 L1 和 L2:
L2(Redis):直接 DEL
L1(本地缓存):通过 Redis Pub/Sub 或消息队列通知所有应用实例
Canal binlog 监听方案 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Component public class CanalCacheInvalidator { @KafkaListener(topics = "canal-binlog") public void handleBinlogEvent (BinlogEvent event) { if (event.getDatabase().equals("mydb" ) && event.getTable().equals("user" )) { Long userId = extractUserId(event.getData()); redis.del("user:" + userId); redis.publish("cache:invalidate" , "user:" + userId); } } }
Canal 工作原理 :
Canal 模拟 MySQL Slave 的交互协议
监听 MySQL Binlog,解析数据变更事件
将变更事件推送到消息队列(Kafka/RocketMQ)
消费者接收事件并执行缓存失效操作
优势 :
保证最终一致性,延迟通常在 100ms 以内
解耦业务代码和缓存逻辑
支持多级缓存、多服务实例的统一失效
解决方案三:熔断降级
当数据库压力过大时,启动熔断保护:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @HystrixCommand( fallbackMethod = "getUserFallback", commandProperties = { @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"), @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"), @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000") } ) public User getUser (long userId) { return userDao.findById(userId); }public User getUserFallback (long userId) { return User.defaultUser(); }
解决方案四:Redis 高可用
部署模式
说明
可用性
主从复制
一主多从,读写分离
中
Sentinel
自动故障转移
高
Cluster
分片 + 副本
最高
Part 6: 缓存一致性
最终一致性方案:延迟双删
1 2 3 4 5 6 7 8 9 10 11 12 public void updateUser (User user) { redis.del("user:" + user.getId()); userDao.update(user); scheduler.schedule(() -> { redis.del("user:" + user.getId()); }, 500 , TimeUnit.MILLISECONDS); }
延迟双删的必要性
1 2 3 4 5 6 7 8 9 10 11 12 13 时间线(不使用延迟双删时的问题):T1: 线程 A 删除缓存T2: 线程 B 读取缓存(未命中),从数据库读取旧值T3: 线程 A 更新数据库T4: 线程 B 将旧值写入缓存 → 缓存中是旧值,数据库中是新值,不一致! 使用延迟双删:T1: 线程 A 删除缓存T2: 线程 B 读取旧值并写入缓存T3: 线程 A 更新数据库T4: 线程 A 延迟 500 ms 后再次删除缓存 → 缓存被清除,下次读取会加载新值
强一致性方案:分布式锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public User getUser (long userId) { String lockKey = "lock:user:" + userId; RLock lock = redisson.getLock(lockKey); lock.lock(); try { String cached = redis.get("user:" + userId); if (cached != null ) return deserialize(cached); User user = userDao.findById(userId); redis.setex("user:" + userId, 3600 , serialize(user)); return user; } finally { lock.unlock(); } }public void updateUser (User user) { String lockKey = "lock:user:" + user.getId(); RLock lock = redisson.getLock(lockKey); lock.lock(); try { userDao.update(user); redis.del("user:" + user.getId()); } finally { lock.unlock(); } }
代价 :性能下降(所有读写都需要获取锁)。只适用于对一致性要求极高的场景。
基于 Binlog 的异步同步
1 2 3 4 5 6 7 8 9 应用 → 数据库(写入) ↓ Binlog(变更日志) ↓ Canal / Debezium(监听 Binlog) ↓ 消息队列(Kafka) ↓ 缓存更新服务 → Redis(更新/ 删除缓存)
优势:
劣势:
Part 7: 分布式锁
Redis 分布式锁的实现
基本实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public boolean tryLock (String lockKey, String requestId, int expireSeconds) { String result = redis.set(lockKey, requestId, "NX" , "EX" , expireSeconds); return "OK" .equals(result); }public boolean unlock (String lockKey, String requestId) { String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end" ; Long result = redis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId)); return result == 1L ; }
SET NX EX 的引入背景 :
在 Redis 2.6.12 版本之前,实现分布式锁需要使用 SETNX + EXPIRE 两条命令:
1 2 3 4 5 6 7 8 9 10 11 12 public boolean tryLockOld (String lockKey, String requestId, int expireSeconds) { Long result = redis.setnx(lockKey, requestId); if (result == 1L ) { redis.expire(lockKey, expireSeconds); return true ; } return false ; }
问题分析 :
SETNX 和 EXPIRE 是两条独立的 Redis 命令
如果在 SETNX 成功后、EXPIRE 执行前进程崩溃或网络中断,锁将永久存在
两个操作之间无法保证原子性
解决方案 :
Redis 2.6.12 引入了 SET 命令的扩展参数,支持原子性的加锁操作:
1 2 3 4 5 SET key value [NX|XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTTL] SET lock_key "request_id" NX EX 30
参数说明 :
NX:仅在 key 不存在时设置(等同于 SETNX)
EX:设置过期时间(秒)
整个操作是原子的,要么全部成功,要么全部失败
解锁使用 Lua 脚本的原因
如果用 GET + DEL 两步操作,在 GET 和 DEL 之间锁可能已经过期并被其他线程获取,DEL 会错误地删除别人的锁。Lua 脚本在 Redis 中是原子执行的。
锁续期(Watchdog)
如果业务执行时间超过锁的过期时间,锁会自动释放,导致并发问题。Redisson 的 Watchdog 续期机制实现细节如下:
1 2 3 4 5 6 7 8 9 10 1. 加锁时设置 TTL = 30 秒2. 启动一个后台线程(Watchdog),每 10 秒检查一次3. 如果锁仍然被持有,续期到 30 秒4. 如果持有锁的线程崩溃,Watchdog 也会停止,锁自然过期**实现细节** :- Watchdog 基于 Netty 的 HashedWheelTimer 定时器实现- 续期操作通过 Lua 脚本原子性执行:`pexpire key 30000` - 只有锁的持有者才能续期,通过比对 requestId 防止误续期- 续期失败时会重试,最多重试 3 次,失败后停止续期
RedLock 算法
单 Redis 实例的分布式锁在 Redis 故障时不可靠。RedLock 使用多个独立的 Redis 实例:
1 2 3 4 5 1. 获取当前时间 T12. 依次向 N 个 Redis 实例尝试加锁(超时时间很短)3. 如果在超过 N/2 + 1 个实例上加锁成功,且总耗时 < 锁的 TTL4. 则认为加锁成功,锁的有效时间 = TTL - 加锁耗时5. 否则,向所有实例发送解锁请求
争议 :Martin Kleppmann 指出 RedLock 在某些故障场景下仍然不安全(如时钟跳变)。对于强一致性需求,应使用 ZooKeeper 或 etcd。
Part 8: 热点 Key 问题
问题描述
某个 Key 被大量请求访问(如微博热搜、秒杀商品),单个 Redis 节点承受不住。
解决方案一:本地缓存
将热点 Key 缓存在应用本地,减少对 Redis 的访问:
1 2 3 4 5 6 7 8 private final Cache<String, Object> hotKeyCache = Caffeine.newBuilder() .maximumSize(1000 ) .expireAfterWrite(1 , TimeUnit.SECONDS) .build();public Object getHotKey (String key) { return hotKeyCache.get(key, k -> redis.get(k)); }
解决方案二:Key 分片
将一个热点 Key 拆分为多个 Key,分散到不同的 Redis 节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public Object getHotKey (String key) { int shard = ThreadLocalRandom.current().nextInt(10 ); String shardKey = key + ":shard:" + shard; Object value = redis.get(shardKey); if (value != null ) return value; value = loadFromDb(key); for (int i = 0 ; i < 10 ; i++) { redis.setex(key + ":shard:" + i, 3600 , serialize(value)); } return value; }
解决方案三:热点 Key 发现
自动发现热点 Key 并进行特殊处理:
方案
实现
优势
客户端统计
在应用中统计 Key 的访问频率
实时性好
Redis 监控
redis-cli --hotkeys(基于 LFU)
无侵入
代理层统计
在 Redis 代理(如 Twemproxy)中统计
全局视角
Part 9: 缓存预热与更新策略
缓存预热
系统启动时,主动加载热点数据到缓存。缓存预热的具体实施策略包括:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Component public class CacheWarmer implements ApplicationRunner { @Override public void run (ApplicationArguments args) { List<Long> hotProductIds = productDao.findTopHotIds(1000 ); for (Long productId : hotProductIds) { Product product = productDao.findById(productId); redis.setex("product:" + productId, 3600 , serialize(product)); } List<Config> configs = configDao.findAll(); for (Config config : configs) { redis.set("config:" + config.getKey(), serialize(config)); } } }
实施策略 :
数据源选择 :基于历史访问日志、业务规则或人工配置确定热点数据
分批加载 :避免启动时瞬时压力过大,使用线程池分批加载
失败重试 :预热失败时记录日志并异步重试,不影响系统启动
灰度预热 :对于大规模数据,可采用渐进式预热策略
预热验证 :预热完成后进行命中率验证,确保数据完整性
缓存更新的最佳实践
场景
推荐策略
读多写少
Cache-Aside + 延迟双删
读多写多
Write-Through + 短 TTL
写多读少
Write-Behind(异步写回)
强一致性
分布式锁 或 Binlog 同步
热点数据
本地缓存 + Key 分片
总结
问题
原因
核心解决方案
缓存穿透
查询不存在的数据
缓存空值 + 布隆过滤器
缓存击穿
热点 Key 过期
互斥锁 + 逻辑过期
缓存雪崩
大量 Key 同时过期 / Redis 宕机
随机 TTL + 多级缓存 + 熔断
缓存一致性
缓存与数据库数据不同步
延迟双删 + Binlog 同步
热点 Key
单 Key 访问量过大
本地缓存 + Key 分片
核心设计原则 :
缓存是加速手段,不是数据源 ——数据库才是 Source of Truth
接受最终一致性 ——追求强一致性的代价通常过高
防御性设计 ——假设缓存随时可能失效,系统仍能工作
监控先行 ——命中率、延迟、内存使用是核心指标
参考资料