缓存是提升系统性能的第一利器 ,但也是引发故障的第一杀手 。从缓存穿透、击穿、雪崩三大经典问题,到多级缓存架构、分布式锁、热点 Key 治理,缓存设计几乎贯穿后端工程师的整个职业生涯。
本文将系统性地剖析缓存系统的设计原则与生产实践,从单机进程内缓存到分布式 Redis 集群,从理论模型到可落地的代码方案,构建一套完整的缓存知识体系。
mindmap
root((缓存架构))
何时使用
读多写少
热点集中
可容忍最终一致性
缓存层次
近端缓存
Guava
Caffeine
EhCache
远端缓存
Redis
Memcached
核心挑战
更新策略
Cache Aside
Read Through
Write Through
Write Behind
一致性保障
故障防护
击穿防护
雪崩防护
穿透防护
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 倍 。缓存的本质就是用更快的存储介质 保存热点数据 的副本。
何时使用缓存
满足以下全部条件的场景适合引入缓存:
读多写少 :读操作占比显著高于写操作(典型比例 > 10:1)
热点集中 :小部分数据承担大部分访问流量(帕累托分布)
可容忍最终一致性 :业务逻辑能接受短暂的数据不一致窗口
计算代价高 :原始数据源响应慢或资源消耗大
不适用缓存的场景
场景
原因
实时性要求极高(如交易撮合)
毫秒级延迟无法接受任何缓存失效
写多读少
同步成本超过缓存收益
数据变化极快(如股票行情)
缓存命中率趋近于零
数据量极大但无热点
缓存无法有效缩减数据集
缓存命中率
命中率 = 命中次数 / (命中次数 + 未命中次数)
命中率
效果
说明
< 50%
差
缓存几乎没有意义
50-80%
一般
有一定效果
80-95%
好
显著减轻数据库压力
> 95%
优秀
数据库几乎无压力
缓存的分类
类型
位置
代表
适用场景
客户端缓存
浏览器/App
HTTP Cache、LocalStorage
静态资源
CDN 缓存
边缘节点
CloudFlare、阿里云 CDN
静态文件、图片
反向代理缓存
Nginx
proxy_cache
页面缓存
本地缓存
应用进程内
Caffeine、Guava Cache
热点数据、配置
分布式缓存
独立集群
Redis、Memcached
共享数据、Session
Part 2: 缓存的层次与选型
近端缓存 vs 远端缓存
缓存按部署位置分为两类:
维度
近端缓存 (In-Memory)
远端缓存 (Remote)
典型实现
Guava, Caffeine, EhCache
Redis, Memcached
访问延迟
微秒级(~100μs)
毫秒级(~1-5ms)
容量限制
受限于单节点内存
水平扩展
数据共享
进程私有
多进程共享
运维复杂度
低(内置)
高(独立集群)
近端缓存的优势 :接入简单,自己可以把控缓存使用逻辑。
近端缓存的劣势 :广播同步一致性难度大,通信成本高,占用应用内存,无法解决海量数据存储。
远端缓存的优势 :自带广播、同步和共识功能,独立集群有专业运维,适合存储海量数据。
远端缓存的劣势 :引入复杂依赖,配置和流程不易差异化。
关于多级缓存
多级缓存(本地 + 远端)的设计需谨慎评估:
可行场景
L1:本地缓存(Caffeine)——存储极热数据
L2:远端缓存(Redis)——存储热数据
差异化 TTL:L1 TTL << L2 TTL
风险点
一致性问题:更新时需要同时失效多个层级
复杂性倍增:每个层级都需要独立的容量规划、监控、故障预案
建议
多数应用从单一远端缓存起步即可
仅在 QPS > 10万且 p99 延迟 < 1ms 为硬性 SLA 时考虑多级缓存
Part 3: 缓存读写策略
缓存与数据源的一致性维护是核心挑战,业界形成了四种经典模式:
Cache-Aside(旁路缓存)
最常用 的缓存策略,由应用程序显式控制缓存与数据库的交互:
1 2 3 4 5 6 7 8 读取流程:1. 先查缓存2. 缓存命中 → 直接返回3. 缓存未命中 → 查数据库 → 写入缓存 → 返回 写入流程:1. 更新数据库2. 删除缓存(而非更新缓存)
为什么是"删除缓存"而非"更新缓存"?
Update 模式在并发场景下存在竞态条件:
1 2 3 T1: 更新数据库 A =1 T2: 更新数据库 A =2, 更新缓存 A =2 T1: 更新缓存 A =1 (覆盖了T2的正确值,导致不一致)
策略
问题
更新缓存
并发写入时可能导致缓存与数据库不一致(后写入数据库的先更新了缓存)
删除缓存
下次读取时重新加载,保证最终一致性
为什么是"先更新数据库,再删除缓存"?
顺序
问题
先删缓存,再更新数据库
删除缓存后、更新数据库前,另一个请求读到旧数据并写入缓存
先更新数据库,再删缓存
更新数据库后、删除缓存前,短暂的不一致(可接受)
Read-Through / Write-Through
缓存层作为数据库的代理,应用只与缓存交互:
1 2 3 4 5 Read - Through : 应用 → 缓存(未命中时自动从数据库加载)Write - Through : 应用 → 缓存 → 数据库(同步写入)
特点
简化应用逻辑,无需处理缓存细节
强耦合于特定缓存框架
Write-Through 写延迟较高(需等待双写完成)
Write-Behind(异步回写)
写入操作仅更新缓存,由后台线程异步批量刷盘到数据库:
1 应用 → 缓存(立即返回)→ 异步批量写入数据库
优势 :写入延迟极低,可以合并多次写入,降低 DB 压力。
劣势 :数据可能丢失(缓存故障时未持久化的数据),实现复杂。
适用场景:计数器、浏览量、点赞数等允许少量丢失的场景。
Refresh-Ahead(预刷新)
在缓存过期之前 ,异步刷新缓存:
1 2 3 4 5 缓存 TTL = 60 秒 刷新窗口 = TTL × 0.8 = 48 秒 当缓存存活时间 > 48 秒时,异步刷新缓存 用户始终读到缓存数据,不会触发缓存未命中
优势 :避免缓存过期瞬间的请求穿透。
劣势 :实现复杂,可能刷新不再被访问的数据。
策略对比
策略
一致性
读性能
写性能
实现复杂度
Cache Aside
最终一致
高
高
低
Read Through
最终一致
高
-
中
Write Through
强一致
-
低
中
Write Behind
弱一致
-
最高
高
Refresh-Ahead
最终一致
最高
-
高
Part 4: Java 进程内缓存详解
Guava Cache
Guava Cache 是 Google 提供的轻量级缓存库,设计上是对 ConcurrentHashMap 的增强。
核心特性
特性
说明
最大容量限制
基于数量或权重
过期策略
expireAfterWrite / expireAfterAccess
刷新机制
refreshAfterWrite(异步刷新,不阻塞读)
引用类型
支持 weakKeys / weakValues / softValues
统计信息
hit/miss/eviction 计数
基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 LoadingCache<String, User> userCache = CacheBuilder.newBuilder() .maximumSize(10000 ) .expireAfterWrite(10 , TimeUnit.MINUTES) .refreshAfterWrite(1 , TimeUnit.MINUTES) .recordStats() .build(new CacheLoader <String, User>() { @Override public User load (String key) throws Exception { return loadUserFromDatabase(key); } });User user = userCache.get(userId);
CacheLoader 的 load 方法是原子性的,天然防止并发击穿。
Guava Cache 不支持 null value
Guava Cache 的设计哲学将 null 视为"该 key 无对应数据",因此禁止存入 null。这导致一个实际问题:如何处理缓存穿透?
解决方案是使用 Optional 包装(空对象模式):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private static final User EMPTY_USER = new User (); LoadingCache<String, Optional<User>> userCache = CacheBuilder.newBuilder() .build(new CacheLoader <String, Optional<User>>() { @Override public Optional<User> load (String key) { User user = loadUserFromDatabase(key); return Optional.ofNullable(user != null ? user : EMPTY_USER); } }); Optional<User> result = userCache.get(userId);if (result.isPresent() && result.get() != EMPTY_USER) { return result.get(); }return null ;
允许使用 null 的缓存能够天然抵挡缓存穿透问题,Guava 的缺点就在这里被体现出来了。
Caffeine
Caffeine 是 Guava Cache 的高性能替代品,采用 Window-TinyLFU 淘汰算法,性能显著优于 Guava 的 LRU。
演进关系
1 2 3 4 5 concurrentlinkedhashmap (Ben Manes) ↓ Guava Cache (Google) ↓ Caffeine (Ben Manes, Java 8 )
Guava vs Caffeine 关键对比
维度
Guava
Caffeine
淘汰算法
Segmented LRU
W-TinyLFU
并发模型
分段锁
无锁 + RingBuffer
刷新机制
同步阻塞
全异步
统计精度
简单计数
频率素描
异步加载示例
1 2 3 4 5 6 7 8 9 10 11 AsyncLoadingCache<String, User> asyncCache = Caffeine.newBuilder() .maximumSize(10000 ) .expireAfterWrite(10 , TimeUnit.MINUTES) .executor(Executors.newFixedThreadPool(10 )) .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> loadUser(key), executor) ); CompletableFuture<User> future = asyncCache.get(userId); future.thenAccept(user -> System.out.println(user.getName()));
Caffeine 的 get 类操作具有原子性,保证 function 对每个 key 最多执行一次,可实现线性一致性。
EhCache 3
EhCache 是唯一支持持久化的本地缓存,适合大容量、可容忍磁盘访问延迟的场景。EhCache 3 是 Hibernate 中的默认缓存框架,基于 JSR-107 标准。
堆内外分层配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <config xmlns ="http://www.ehcache.org/v3" > <cache alias ="largeDataCache" > <key-type > java.lang.Long</key-type > <value-type > java.io.Serializable</value-type > <resources > <heap unit ="entries" > 1000</heap > <offheap unit ="MB" > 128</offheap > <disk unit ="GB" > 10</disk > </resources > <expiry > <ttl unit ="minutes" > 60</ttl > </expiry > </cache > </config >
与 Spring Boot 集成
引入依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-cache</artifactId > </dependency > <dependency > <groupId > javax.cache</groupId > <artifactId > cache-api</artifactId > <version > 1.1.1</version > </dependency > <dependency > <groupId > org.ehcache</groupId > <artifactId > ehcache</artifactId > </dependency >
配置类:
1 2 3 4 5 6 7 8 9 10 @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager () { CachingProvider provider = Caching.getCachingProvider(); CacheManager manager = provider.getCacheManager(); return new JCacheCacheManager (manager); } }
Part 5: 分布式缓存 Redis
Redis 是目前最流行的分布式缓存,本节聚焦 Java 客户端的最佳实践。
客户端选择
客户端
连接方式
特性
推荐场景
Jedis
直连
成熟稳定,API 丰富
单机或简单分片
Lettuce
Netty + 异步
响应式编程,集群友好
高并发、Reactive
Redisson
高级封装
分布式锁、对象映射
需要分布式协调
Spring Data Redis 配置
1 2 3 4 5 6 7 8 9 10 11 spring: redis: host: localhost port: 6379 lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms timeout: 2000ms
缓存序列化配置
默认 JDK 序列化存在性能和空间问题,推荐 JSON 序列化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Bean public RedisTemplate<String, Object> redisTemplate (RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate <>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer ()); template.setHashKeySerializer(new StringRedisSerializer ()); Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer <>(Object.class); ObjectMapper mapper = new ObjectMapper (); mapper.registerModule(new JavaTimeModule ()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); jsonSerializer.setObjectMapper(mapper); template.setValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }
Part 6: 缓存穿透
问题描述
缓存穿透 :查询一个不存在的数据 ,缓存中没有,数据库中也没有。每次请求都会穿透缓存直达数据库。
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 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 7: 缓存击穿
问题描述
缓存击穿 :一个热点 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 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 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 8: 缓存雪崩
问题描述
缓存雪崩 :大量缓存 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 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; } }
解决方案三:熔断降级
当数据库压力过大时,启动熔断保护:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @HystrixCommand( fallbackMethod = "getUserFallback", commandProperties = { @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"), @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50") } ) public User getUser (long userId) { return userDao.findById(userId); }public User getUserFallback (long userId) { return User.defaultUser(); }
解决方案四:Redis 高可用
部署模式
说明
可用性
主从复制
一主多从,读写分离
中
Sentinel
自动故障转移
高
Cluster
分片 + 副本
最高
Part 9: 缓存与数据库一致性
延迟双删
问题 :在 Cache Aside 模式下,先删除缓存再更新数据库,在更新完成之前,如果有读请求进来,会读取到旧数据并写入缓存,导致不一致。
解决方案 :延迟双删策略通过在数据库更新后再次删除缓存来解决这个问题。
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); }
时序分析 :
sequenceDiagram
participant Client
participant Cache
participant DB
participant AsyncDelete
Client->>Cache: 1. 删除缓存
Client->>DB: 2. 更新数据库
par 并发读请求
Client->>Cache: 3a. 读取缓存(miss)
Client->>DB: 3b. 读取数据库(旧值)
Client->>Cache: 3c. 写入缓存(旧值)
end
Client->>AsyncDelete: 4. 延迟N秒后删除缓存
AsyncDelete->>Cache: 5. 删除缓存
最佳实践 :
延迟时间应大于数据库主从同步延迟 + 业务读请求平均耗时
建议延迟时间设置为 500ms-2s
配合消息队列实现异步删除,避免线程阻塞
强一致性:分布式锁
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 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(); } }
代价 :性能下降(所有读写都需要获取锁)。只适用于对一致性要求极高的场景。
Canal/Binlog 方案
通过监听 MySQL Binlog,异步更新缓存,实现应用代码无侵入的最终一致性。
1 2 3 4 5 6 7 8 9 应用 → 数据库(写入) ↓ Binlog(变更日志) ↓ Canal / Debezium(监听 Binlog) ↓ 消息队列(Kafka) ↓ 缓存更新服务 → Redis(更新/ 删除缓存)
架构图 :
graph LR
A[业务应用] -->|写操作| B[(MySQL)]
B -->|Binlog| C[Canal Server]
C -->|订阅| D[Canal Client]
D -->|解析| E[缓存更新服务]
E -->|更新/删除| F[(Redis)]
A -->|读操作| F
Canal 工作原理 :
Canal 模拟 MySQL Slave 的交互协议
监听 MySQL Binlog,解析数据变更事件
将变更事件推送到消息队列(Kafka/RocketMQ)
消费者接收事件并执行缓存失效操作
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); } } }
优势 :保证最终一致性(延迟通常 < 100ms),解耦业务代码和缓存逻辑,支持多级缓存统一失效。
劣势 :架构复杂度高,有一定延迟(通常 < 1 秒)。
Facebook Memcache Lease 机制
Facebook 在其 Memcache 论文中提出了 Lease 机制,防止多个客户端同时在缓存未命中时给数据库施加压力:
sequenceDiagram
participant Client1
participant Client2
participant Cache
participant DB
Client1->>Cache: 1. Get(key) miss
Cache->>Client1: 2. 返回 lease (1秒有效)
Client1->>DB: 3. 从DB加载数据
Client2->>Cache: 4. Get(key) miss
Cache->>Client2: 5. 返回 lease (等待中)
Client1->>Cache: 6. Set(key, value) with lease
Cache->>Client2: 7. 通知数据已更新
Client2->>Cache: 8. Get(key) hit
Cache->>Client2: 9. 返回数据
Lease 机制的核心思想是:缓存服务器在返回 miss 时附带一个 lease token,只有持有有效 lease 的客户端才能写入缓存,其他客户端需要等待或重试。
Part 10: 分布式锁
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 两条命令,这两个操作之间无法保证原子性——如果 SETNX 成功后、EXPIRE 执行前进程崩溃或网络中断,锁将永久存在。
Redis 2.6.12 引入了 SET 命令的扩展参数,支持原子性加锁:
1 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 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 11: 热点 Key 与大 Key 治理
热点 Key 问题
定义 :某个 Key 被大量请求访问(如微博热搜、秒杀商品),单个 Redis 节点承受不住。
危害 :
单节点 CPU/内存资源耗尽
网络带宽瓶颈
影响其他 Key 的访问性能
可能导致整个缓存集群不可用
热点探测方案
方案
实现
优势
客户端统计
在应用中统计 Key 的访问频率
实时性好
Redis 监控
redis-cli --hotkeys(基于 LFU)
无侵入
代理层统计
在 Redis 代理(如 Twemproxy)中统计
全局视角
京东 HotKey 框架
graph TB
A[客户端应用] -->|上报访问| B[Worker节点]
A -->|查询热点| C[本地缓存]
B -->|聚合统计| D[Etcd集群]
D -->|推送热点| B
B -->|推送热点| A
D -->|持久化| E[(数据库)]
热 Key 治理方案
方案一:本地缓存兜底
将热点 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 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; }
方案对比 :
方案
优点
缺点
适用场景
本地缓存
响应快,减轻 Redis 压力
数据不一致,内存占用大
读多写少,一致性要求不高
Key 分散
负载均衡,无单点
读取复杂,可能读到旧数据
写多读少,可接受最终一致
大 Key 问题
大 Key 的定义
类型
大 Key 定义
String
值大小 > 10KB
Hash
字段数 > 5000
List
元素数 > 5000
Set
元素数 > 5000
Sorted Set
元素数 > 5000
危害 :网络带宽占用、内存碎片、慢查询阻塞、主从同步延迟、DEL 等命令可能阻塞 Redis。
发现手段
1 2 3 4 5 redis-cli --bigkeys redis-cli MEMORY USAGE <key>
治理方案
拆分大 Hash :将大 Hash 按字段哈希拆分为多个小 Hash:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public void splitBigHash (String bigKey) { Map<String, String> bigHash = jedis.hgetAll(bigKey); int shardIndex = 0 ; Map<String, String> shard = new HashMap <>(); for (Map.Entry<String, String> entry : bigHash.entrySet()) { shard.put(entry.getKey(), entry.getValue()); if (shard.size() >= MAX_HASH_SIZE) { String shardKey = bigKey + ":" + shardIndex; jedis.hmset(shardKey, shard); shard.clear(); shardIndex++; } } if (!shard.isEmpty()) { jedis.hmset(bigKey + ":" + shardIndex, shard); } jedis.del(bigKey); }
异步删除 :使用 UNLINK 替代 DEL,避免阻塞 Redis 主线程。
压缩存储 :对大 Value 使用 Deflater/Inflater 进行压缩后再存入 Redis,可显著减少网络传输和内存占用。
Part 12: 多级缓存架构实战
经典三级架构
graph TB
A[用户请求] --> B[Nginx Lua]
B -->|L1 Cache| C{命中?}
C -->|是| D[返回数据]
C -->|否| E[本地缓存 Caffeine]
E -->|L2 Cache| F{命中?}
F -->|是| D
F -->|否| G[Redis]
G -->|L3 Cache| H{命中?}
H -->|是| D
H -->|否| I[数据库]
I -->|回源| G
G -->|回源| E
E -->|回源| B
每一层的职责是"挡住上一层的穿透",TTL 必须逐层递增,否则失去分层意义。
JetCache 框架
JetCache 是阿里开源的多级缓存框架,内置 L1 + L2 支持:
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 @Service public class JetCacheUserService { @CreateCache(name = "userCache", expire = 3600, cacheType = CacheType.BOTH, localLimit = 1000) private Cache<Long, User> userCache; @Cached(name = "userCache:", key = "#userId", expire = 3600) public User getUserById (Long userId) { return userRepository.findById(userId); } @CacheInvalidate(name = "userCache:", key = "#userId") public void deleteUser (Long userId) { userRepository.deleteById(userId); } @Cached(name = "userCache:", key = "#userId", expire = 3600) @CacheRefresh(refreshInterval = 60, stopRefreshAfterLastAccess = 3600) public User getHotUserById (Long userId) { return userRepository.findById(userId); } }
缓存失效广播方案
多级缓存的一致性挑战在于:当数据更新时,需要同时失效 L1(本地缓存)和 L2(Redis)。L2 直接 DEL 即可,L1 则需要通过 Redis Pub/Sub 或消息队列通知所有应用实例。
1 2 3 4 5 6 7 8 9 10 public class CacheInvalidationService { private RedisTemplate<String, String> redisTemplate; public void publishInvalidation (String cacheKey) { redisTemplate.convertAndSend("cache:invalidation" , cacheKey); } }
预热策略对比
策略
优点
缺点
适用场景
全量预热
启动后立即可用
耗时长,占用资源
数据量小,启动时间不敏感
增量预热
快速启动
可能缓存穿透
数据量大,可接受渐进式加载
懒加载
无需预热
首次访问慢
数据量大,访问模式不确定
预热实施要点:
数据源选择 :基于历史访问日志、业务规则或人工配置确定热点数据
分批加载 :避免启动时瞬时压力过大,使用线程池分批加载
失败重试 :预热失败时记录日志并异步重试,不影响系统启动
灰度预热 :对于大规模数据,可采用渐进式预热策略
预热验证 :预热完成后进行命中率验证,确保数据完整性
Part 13: Redis 集群缓存设计
CRC16 + 16384 Slots 原理
Redis Cluster 使用 CRC16 算法对 key 进行哈希,计算结果对 16384 取模,确定 key 所在的 slot。每个节点负责一部分 slot,实现数据分片。
在 Redis Cluster 中,不支持跨 slot 的多 key 操作 (如 MGET、MSET、SUNION 等)。
HashTag 使用
使用 HashTag 可以确保相关 key 路由到同一个 slot:
1 2 3 {user:1001 }:profile → 按 "user:1001" 计算 slot {user:1001 }:orders → 按 "user:1001" 计算 slot → 两个 key 在同一个 slot,可以使用 MGET
graph LR
A[用户请求] --> B{HashTag?}
B -->|是| C[计算 hash tag 值]
B -->|否| D[计算 key 值]
C --> E[CRC16 哈希]
D --> E
E --> F[取模 16384]
F --> G[确定 slot]
G --> H[路由到对应节点]
最佳实践 :
只在需要多 key 原子操作时使用 HashTag
HashTag 会破坏数据分布均匀性,不要滥用
HashTag 应该是业务相关的标识,如用户 ID、订单 ID
Sentinel vs Cluster 选型
特性
Redis Sentinel
Redis Cluster
数据分片
否
是(16384 slots)
水平扩展
需要客户端分片
原生支持
最大内存
单节点限制
集群总内存
故障转移
自动
自动
复杂度
较低
较高
多 key 操作
支持
受限(需 HashTag)
适用场景
中小规模,简单架构
大规模,高性能需求
Part 14: 生产环境实践
监控指标
指标
健康阈值
报警条件
命中率
> 90%
< 80%
平均加载时间
< 100ms
> 500ms
驱逐率
视容量而定
突增 > 200%
错误率
0%
> 0.1%
容量规划
估算公式:
1 缓存条目数 ≈ 峰值 QPS × 平均访问间隔 / (命中率目标 / (1 - 命中率目标))
示例:QPS=10000,用户平均10分钟访问一次,目标命中率95%:
1 条目数 ≈ 10000 × 600 s × (0.05/0.95 ) ≈ 315,789
故障演练清单
[ ] 模拟 Redis 宕机,验证降级逻辑
[ ] 模拟大 Key 过期,观察数据库压力
[ ] 模拟网络分区,测试最终一致性收敛时间
[ ] 压测缓存满载时的驱逐行为
缓存更新的最佳实践
场景
推荐策略
读多写少
Cache-Aside + 延迟双删
读多写多
Write-Through + 短 TTL
写多读少
Write-Behind(异步写回)
强一致性
分布式锁 或 Binlog 同步
热点数据
本地缓存 + Key 分片
Part 15: 模式速查表
听到的需求关键词
对应模式
推荐方案
口诀
本地缓存要高性能
分层降级
Caffeine + Redis
本地兜底,远程扩展
冷启动加载慢
惰性填充
@PostConstruct 预加载
触发加载,按需扩容
数据更新后不一致
旁路同步
Cache Aside + 延迟双删
先库后删,读写互斥
恶意请求导致 DB 崩溃
空值防御
空对象 + 布隆过滤器
存空防击,短TTL控险
大量 key 同时过期
随机散列
TTL + Random Offset
过期分散,渐进刷新
热点 key 频繁失效
互斥重建
分布式锁 / 逻辑过期
单线回源,排队等待
核心设计原则 :
缓存是加速手段,不是数据源 ——数据库才是 Source of Truth
接受最终一致性 ——追求强一致性的代价通常过高
防御性设计 ——假设缓存随时可能失效,系统仍能工作
监控先行 ——命中率、延迟、内存使用是核心指标
问题
原因
核心解决方案
缓存穿透
查询不存在的数据
缓存空值 + 布隆过滤器
缓存击穿
热点 Key 过期
互斥锁 + 逻辑过期
缓存雪崩
大量 Key 同时过期 / Redis 宕机
随机 TTL + 多级缓存 + 熔断
缓存一致性
缓存与数据库数据不同步
延迟双删 + Binlog 同步
热点 Key
单 Key 访问量过大
本地缓存 + Key 分片
参考文献
陈皓:缓存更新的套路
美团技术团队:缓存那些事
Guava Cache 官方文档
Caffeine Wiki
Redis 官方文档
Canal - 阿里巴巴 MySQL Binlog 增量订阅
Martin Kleppmann - How to do distributed locking
Designing Data-Intensive Applications - Martin Kleppmann
Guava Cache (Baeldung)
Caffeine vs EhCache
Spring Cache SPEL 表达式