缓存是提升系统性能的第一利器,但也是引发故障的第一杀手。缓存穿透、缓存击穿、缓存雪崩——这三大问题几乎是每个后端工程师都会遇到的挑战。本文将系统性地剖析缓存系统的设计原则、经典问题及其解决方案,从单机缓存到分布式缓存,从理论到生产实践。


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)); // 正常数据缓存 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
20
21
22
23
public class UserService {
private final BloomFilter<Long> userIdBloomFilter;

public UserService() {
// 初始化时加载所有用户 ID 到布隆过滤器
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 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)  // 每 30 秒刷新一次
public void refreshHotKeys() {
List<String> hotKeys = getHotKeyList();
for (String key : hotKeys) {
Object data = loadFromDb(key);
redis.set(key, serialize(data)); // 不设置 TTL
}
}

三种方案对比

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

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) {
// 基础 TTL + 随机偏移(0 到 baseTtl 的 20%)
int randomOffset = ThreadLocalRandom.current().nextInt(baseTtlSeconds / 5);
int actualTtl = baseTtlSeconds + randomOffset;
redis.setex(key, actualTtl, value);
}

// 使用
setWithRandomTtl("user:1001", userData, 3600);
// 实际 TTL 在 3600 ~ 4320 秒之间随机

解决方案二:多级缓存

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) // 本地缓存 5 分钟
.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); // 写入 L2
localCache.put(key, value); // 写入 L1
}
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 缓存
redis.del("user:" + userId);

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

Canal 工作原理

  1. Canal 模拟 MySQL Slave 的交互协议
  2. 监听 MySQL Binlog,解析数据变更事件
  3. 将变更事件推送到消息队列(Kafka/RocketMQ)
  4. 消费者接收事件并执行缓存失效操作

优势

  • 保证最终一致性,延迟通常在 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) {
// 1. 删除缓存
redis.del("user:" + user.getId());

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

// 3. 延迟再次删除缓存(防止并发读写导致的不一致)
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 延迟 500ms 后再次删除缓存
→ 缓存被清除,下次读取会加载新值

强一致性方案:分布式锁

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(更新/删除缓存)

优势:

  • 应用代码无侵入
  • 保证最终一致性
  • 解耦缓存更新逻辑

劣势:

  • 架构复杂度高
  • 有一定延迟(通常 < 1 秒)

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

// 解锁(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 两条命令:

1
2
3
4
5
6
7
8
9
10
11
12
// 旧方案(存在原子性问题)
public boolean tryLockOld(String lockKey, String requestId, int expireSeconds) {
// 步骤 1: 设置锁
Long result = redis.setnx(lockKey, requestId);
if (result == 1L) {
// 步骤 2: 设置过期时间
// 如果在步骤 1 和步骤 2 之间进程崩溃,锁将永远无法释放
redis.expire(lockKey, expireSeconds);
return true;
}
return false;
}

问题分析

  • SETNXEXPIRE 是两条独立的 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. 获取当前时间 T1
2. 依次向 N 个 Redis 实例尝试加锁(超时时间很短)
3. 如果在超过 N/2 + 1 个实例上加锁成功,且总耗时 < 锁的 TTL
4. 则认为加锁成功,锁的有效时间 = 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) // 极短 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
15
public Object getHotKey(String key) {
// 将 key 分散到 10 个分片
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) {
// 预热 Top 1000 热门商品
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));
}
}
}

实施策略

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

缓存更新的最佳实践

场景 推荐策略
读多写少 Cache-Aside + 延迟双删
读多写多 Write-Through + 短 TTL
写多读少 Write-Behind(异步写回)
强一致性 分布式锁 或 Binlog 同步
热点数据 本地缓存 + Key 分片

总结

问题 原因 核心解决方案
缓存穿透 查询不存在的数据 缓存空值 + 布隆过滤器
缓存击穿 热点 Key 过期 互斥锁 + 逻辑过期
缓存雪崩 大量 Key 同时过期 / Redis 宕机 随机 TTL + 多级缓存 + 熔断
缓存一致性 缓存与数据库数据不同步 延迟双删 + Binlog 同步
热点 Key 单 Key 访问量过大 本地缓存 + Key 分片

核心设计原则

  1. 缓存是加速手段,不是数据源——数据库才是 Source of Truth
  2. 接受最终一致性——追求强一致性的代价通常过高
  3. 防御性设计——假设缓存随时可能失效,系统仍能工作
  4. 监控先行——命中率、延迟、内存使用是核心指标

参考资料