缓存是提升系统性能的第一利器,但也是引发故障的第一杀手。从缓存穿透、击穿、雪崩三大经典问题,到多级缓存架构、分布式锁、热点 Key 治理,缓存设计几乎贯穿后端工程师的整个职业生涯。

本文将系统性地剖析缓存系统的设计原则与生产实践,从单机进程内缓存到分布式 Redis 集群,从理论模型到可落地的代码方案,构建一套完整的缓存知识体系。


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); // 回源方法
}
});

// 获取(命中则返回,未命中则调用 load)
User user = userCache.get(userId);

CacheLoaderload 方法是原子性的,天然防止并发击穿。

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>
<!-- 堆外内存(避免 GC 压力) -->
<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);

// Key 使用 String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());

// Value 使用 JSON
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // JDK8 日期支持
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)); // 正常数据缓存 1 小时
} else {
redis.setex(cacheKey, 300, "NULL"); // 空值缓存 5 分钟(短 TTL)
}
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 // 误判率 1%
);
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)  // 每 30 秒刷新一次
public void refreshHotKeys() {
List<String> hotKeys = getHotKeyList();
for (String key : hotKeys) {
Object data = loadFromDb(key);
redis.set(key, serialize(data)); // 不设置 TTL
}
}

三种方案对比

方案 一致性 可用性 复杂度
互斥锁 强一致 可能阻塞
逻辑过期 最终一致(短暂返回旧数据) 高(不阻塞)
永不过期 最终一致 最高

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);
}

// 使用:实际 TTL 在 3600 ~ 4320 秒之间随机
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; // L1: Caffeine
private final RedisTemplate<String, Object> redis; // L2: Redis

public MultiLevelCache() {
this.localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
}

public Object get(String key) {
// L1: 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) return value;

// L2: Redis
value = redis.opsForValue().get(key);
if (value != null) {
localCache.put(key, value); // 回填 L1
return value;
}

// L3: 数据库
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) {
// 1. 删除缓存
redis.del("user:" + user.getId());

// 2. 更新数据库
userDao.update(user);

// 3. 延迟再次删除缓存(防止并发读写导致的不一致)
scheduler.schedule(() -> {
redis.del("user:" + user.getId());
}, 500, TimeUnit.MILLISECONDS);
}

时序分析

最佳实践

  • 延迟时间应大于数据库主从同步延迟 + 业务读请求平均耗时
  • 建议延迟时间设置为 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(更新/删除缓存)

架构图

Canal 工作原理

  1. Canal 模拟 MySQL Slave 的交互协议
  2. 监听 MySQL Binlog,解析数据变更事件
  3. 将变更事件推送到消息队列(Kafka/RocketMQ)
  4. 消费者接收事件并执行缓存失效操作
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 缓存
redis.del("user:" + userId);

// 通过 Pub/Sub 通知所有应用实例失效本地缓存
redis.publish("cache:invalidate", "user:" + userId);
}
}
}

优势:保证最终一致性(延迟通常 < 100ms),解耦业务代码和缓存逻辑,支持多级缓存统一失效。
劣势:架构复杂度高,有一定延迟(通常 < 1 秒)。

Facebook Memcache Lease 机制

Facebook 在其 Memcache 论文中提出了 Lease 机制,防止多个客户端同时在缓存未命中时给数据库施加压力:

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);
}

// 解锁(Lua 脚本保证原子性)
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. 获取当前时间 T1
2. 依次向 N 个 Redis 实例尝试加锁(超时时间很短)
3. 如果在超过 N/2 + 1 个实例上加锁成功,且总耗时 < 锁的 TTL
4. 则认为加锁成功,锁的有效时间 = 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 框架

热 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) // 极短 TTL
.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 4.0+ 提供 bigkeys 扫描
redis-cli --bigkeys

# 查看单个 key 的内存占用
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: 多级缓存架构实战

经典三级架构

每一层的职责是"挡住上一层的穿透",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);
}
}

// 各应用实例订阅失效消息,清除本地缓存

预热策略对比

策略 优点 缺点 适用场景
全量预热 启动后立即可用 耗时长,占用资源 数据量小,启动时间不敏感
增量预热 快速启动 可能缓存穿透 数据量大,可接受渐进式加载
懒加载 无需预热 首次访问慢 数据量大,访问模式不确定

预热实施要点:

  1. 数据源选择:基于历史访问日志、业务规则或人工配置确定热点数据
  2. 分批加载:避免启动时瞬时压力过大,使用线程池分批加载
  3. 失败重试:预热失败时记录日志并异步重试,不影响系统启动
  4. 灰度预热:对于大规模数据,可采用渐进式预热策略
  5. 预热验证:预热完成后进行命中率验证,确保数据完整性

Part 13: Redis 集群缓存设计

CRC16 + 16384 Slots 原理

Redis Cluster 使用 CRC16 算法对 key 进行哈希,计算结果对 16384 取模,确定 key 所在的 slot。每个节点负责一部分 slot,实现数据分片。

在 Redis Cluster 中,不支持跨 slot 的多 key 操作(如 MGETMSETSUNION 等)。

HashTag 使用

使用 HashTag 可以确保相关 key 路由到同一个 slot:

1
2
3
{user:1001}:profile  → 按 "user:1001" 计算 slot
{user:1001}:orders → 按 "user:1001" 计算 slot
→ 两个 key 在同一个 slot,可以使用 MGET

最佳实践

  • 只在需要多 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 × 600s × (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 频繁失效 互斥重建 分布式锁 / 逻辑过期 单线回源,排队等待

核心设计原则

  1. 缓存是加速手段,不是数据源——数据库才是 Source of Truth
  2. 接受最终一致性——追求强一致性的代价通常过高
  3. 防御性设计——假设缓存随时可能失效,系统仍能工作
  4. 监控先行——命中率、延迟、内存使用是核心指标
问题 原因 核心解决方案
缓存穿透 查询不存在的数据 缓存空值 + 布隆过滤器
缓存击穿 热点 Key 过期 互斥锁 + 逻辑过期
缓存雪崩 大量 Key 同时过期 / Redis 宕机 随机 TTL + 多级缓存 + 熔断
缓存一致性 缓存与数据库数据不同步 延迟双删 + Binlog 同步
热点 Key 单 Key 访问量过大 本地缓存 + Key 分片

参考文献

  1. 陈皓:缓存更新的套路
  2. 美团技术团队:缓存那些事
  3. Guava Cache 官方文档
  4. Caffeine Wiki
  5. Redis 官方文档
  6. Canal - 阿里巴巴 MySQL Binlog 增量订阅
  7. Martin Kleppmann - How to do distributed locking
  8. Designing Data-Intensive Applications - Martin Kleppmann
  9. Guava Cache (Baeldung)
  10. Caffeine vs EhCache
  11. Spring Cache SPEL 表达式