缓存设计分析
# 缓存设计分析
# 概述
缓存是提升系统性能、降低后端压力的核心手段。其本质是用空间换时间——将频繁访问的数据存储在更快的介质中,减少对慢速数据源的访问。
一句话总结:缓存不是银弹,用得好是加速器,用不好是定时炸弹。
# 为什么需要缓存?
| 痛点 | 缓存如何解决 |
|---|---|
| 数据库 QPS 过高 | 热点数据缓存后,读请求直接命中缓存,不查数据库 |
| 接口响应慢 | 缓存读取耗时 < 1ms,数据库读取耗时 10~100ms |
| 高并发下数据库扛不住 | 缓存可承受 10 万+ QPS,远超数据库 |
| 带宽瓶颈 | 静态资源缓存在 CDN / 浏览器,减少回源带宽 |
| 重复计算开销大 | 计算结果缓存,避免重复计算 |
# 缓存的代价
| 代价 | 说明 |
|---|---|
| 数据不一致 | 数据源更新后缓存未同步,读到旧数据 |
| 内存 / 存储成本 | 缓存数据占用额外内存或磁盘 |
| 运维复杂度 | 缓存集群维护、监控、故障排查 |
| 缓存问题 | 缓存穿透、缓存击穿、缓存雪崩 |
| 架构复杂度 | 多级缓存引入更多组件,排查链路变长 |
# 全链路缓存架构
从系统架构设计的角度,缓存不是某一层的技术,而是贯穿整个请求链路的设计理念。一个用户请求从浏览器发出到数据库返回,中间会经过多层缓存:
用户浏览器
│
▼
┌─────────────────────────────────────────────────────────┐
│ ① 浏览器缓存(HTTP 缓存) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ ② CDN 缓存(边缘节点) │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ ③ Nginx 缓存(反向代理缓存) │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ ④ 应用层缓存 │ │ │ │
│ │ │ │ ┌─────────────────────────────────┐ │ │ │ │
│ │ │ │ │ 本地缓存(Caffeine) │ │ │ │ │
│ │ │ │ │ ┌───────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ 分布式缓存(Redis) │ │ │ │ │ │
│ │ │ │ │ │ ┌─────────────────────┐ │ │ │ │ │ │
│ │ │ │ │ │ │ ⑤ 数据库缓存 │ │ │ │ │ │ │
│ │ │ │ │ │ │ (Buffer Pool) │ │ │ │ │ │ │
│ │ │ │ │ │ └─────────────────────┘ │ │ │ │ │ │
│ │ │ │ │ └───────────────────────────┘ │ │ │ │ │
│ │ │ │ └─────────────────────────────────┘ │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
HTTP 响应(逐层返回,每层可命中缓存直接返回)
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
# 各层缓存总览
| 层级 | 位置 | 缓存介质 | 典型耗时 | 缓存内容 | 失效方式 |
|---|---|---|---|---|---|
| ① 浏览器缓存 | 用户设备 | 磁盘 / 内存 | ~0ms | 静态资源(JS/CSS/图片) | Cache-Control / ETag |
| ② CDN 缓存 | 边缘节点 | CDN 节点内存 / 磁盘 | 5~20ms | 静态资源、页面片段 | TTL / 主动刷新 |
| ③ Nginx 缓存 | 反向代理服务器 | 磁盘 / 共享内存 | 1~5ms | 接口响应、静态文件 | TTL / purge |
| ④ 本地缓存 | 应用 JVM | 堆内存 | 纳秒级 | 热点数据、字典数据 | TTL / LRU 淘汰 |
| ④ 分布式缓存 | Redis 集群 | 内存 | 1~2ms | 业务数据、Session | TTL / 主动删除 |
| ⑤ 数据库缓存 | 数据库引擎 | 内存(Buffer Pool) | 微秒级 | 数据页、索引页 | LRU 淘汰 |
设计原则:请求从外到内逐层穿透,每一层能拦截的请求就不要放到下一层。最外层(浏览器)拦截 90% 的静态资源请求,CDN 拦截 80% 的回源请求,Nginx 拦截热点接口,应用层缓存拦截数据库查询。
# ① 浏览器缓存(HTTP 缓存)
# 基本概念
浏览器缓存是最外层的缓存,存储在用户设备的磁盘或内存中。对于静态资源(JS、CSS、图片、字体),合理配置浏览器缓存可以完全避免 HTTP 请求,是性能优化的第一道防线。
# 强缓存 vs 协商缓存
浏览器缓存分为两种机制:
| 类型 | 特点 | 命中时行为 | 相关 Header |
|---|---|---|---|
| 强缓存 | 不发请求,直接用本地缓存 | 200(from cache) | Cache-Control / Expires |
| 协商缓存 | 发请求询问服务器资源是否变更 | 304(Not Modified) | ETag / Last-Modified |
# 强缓存
# 响应头
Cache-Control: max-age=31536000, public, immutable
Expires: Wed, 14 Aug 2027 10:00:00 GMT
2
3
Cache-Control 值 | 说明 |
|---|---|
max-age=31536000 | 缓存有效期 1 年(秒) |
public | 允许 CDN 等中间代理缓存 |
private | 仅浏览器可缓存(如用户个人数据) |
no-cache | 不直接用缓存,每次需协商验证 |
no-store | 完全不缓存(敏感数据) |
immutable | 资源永不变,URL 变化时才重新请求 |
💡
Cache-Control优先级高于Expires,Expires是 HTTP/1.0 的产物,现在作为兜底。
# 协商缓存
# 第一次响应
ETag: "abc123"
Last-Modified: Wed, 14 Aug 2026 10:00:00 GMT
# 第二次请求(带上缓存标识)
If-None-Match: "abc123"
If-Modified-Since: Wed, 14 Aug 2026 10:00:00 GMT
# 服务器判断:
# 未变更 → 返回 304 Not Modified(不返回 body,省带宽)
# 已变更 → 返回 200 + 新资源 + 新 ETag
2
3
4
5
6
7
8
9
10
11
| 标识 | 说明 | 优先级 |
|---|---|---|
ETag / If-None-Match | 基于内容哈希,精确判断资源是否变化 | 高 |
Last-Modified / If-Modified-Since | 基于修改时间,精度为秒 | 低 |
# 缓存决策流程
请求资源
│
▼
浏览器有缓存?
│ 否 → 发请求,下载资源
│ 是
▼
强缓存有效(max-age 未过期)?
│ 是 → 200 from cache(不发请求)
│ 否
▼
发请求,带 If-None-Match / If-Modified-Since
│
▼
服务器判断资源是否变更?
│ 未变更 → 304 Not Modified(用本地缓存)
│ 已变更 → 200 + 新资源
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 前端工程化中的缓存策略
| 资源类型 | 缓存策略 | 说明 |
|---|---|---|
| HTML 文件 | no-cache | 每次协商,确保用户拿到最新页面 |
| 带 hash 的 JS/CSS | max-age=31536000, immutable | 文件名含 hash,内容变则 URL 变 |
| 图片 / 字体 | max-age=2592000 | 缓存 30 天 |
| API 响应 | no-store | 接口数据不缓存(由应用层缓存处理) |
💡 Webpack / Vite 的
contenthash是浏览器缓存的最佳实践:文件内容不变则 hash 不变,浏览器直接用缓存;内容变了则 hash 变,浏览器重新下载。
# ② CDN 缓存
# 基本概念
CDN(Content Delivery Network,内容分发网络)将静态资源缓存到离用户最近的边缘节点,用户请求时由最近的节点直接返回,无需回源到源站服务器。
用户(北京)──→ CDN 边缘节点(北京)──→ 命中缓存,直接返回
│ 未命中
▼
源站服务器(深圳)
│
▼
回源获取 + 缓存到边缘节点
2
3
4
5
6
7
# CDN 的价值
| 价值 | 说明 |
|---|---|
| 加速访问 | 就近返回,减少网络延迟 |
| 减轻源站压力 | 边缘节点拦截 80%+ 请求 |
| 抗DDoS | 流量分散到边缘节点 |
| 节省带宽 | 源站只需处理回源请求 |
# CDN 缓存控制
CDN 缓存遵循 HTTP 缓存头,也可通过 CDN 控制台自定义:
| 控制方式 | 说明 |
|---|---|
Cache-Control: max-age | 源站响应头控制 CDN 缓存时间 |
| CDN 控制台配置 | 覆盖源站头,按文件类型设置缓存时间 |
| 主动刷新 | 发布新版本后,调用 CDN API 清除缓存 |
| URL 版本号 | https://cdn.example.com/app.v1.2.0.js,版本号变更即新 URL |
# CDN 缓存策略建议
| 资源类型 | CDN 缓存时间 | 说明 |
|---|---|---|
| 带 hash 的静态资源 | 30 天 ~ 1 年 | 内容变则 URL 变,可长期缓存 |
| 不带 hash 的静态资源 | 10 分钟 ~ 1 小时 | 需定期回源检查 |
| HTML 页面 | 不缓存或短缓存 | 确保用户拿到最新页面 |
| API 接口 | 不缓存 | 由应用层处理 |
# CDN 回源策略
当 CDN 节点缓存未命中时,需要回源获取:
| 策略 | 说明 |
|---|---|
| 普通回源 | CDN 节点直接向源站请求 |
| CDN 间回源 | 先向其他 CDN 节点请求,再回源站 |
| 回源缓存 | 回源获取后缓存到当前节点,下次直接命中 |
⚠️ CDN 回源会产生额外延迟,应通过合理的缓存时间尽量减少回源率。一般 CDN 命中率应 > 90%。
# ③ Nginx 缓存
# 基本概念
Nginx 作为反向代理服务器,可以缓存后端应用的响应结果。当相同请求再次到达时,Nginx 直接返回缓存内容,无需转发到后端应用。
客户端 → Nginx → 命中缓存 → 直接返回
│ 未命中
▼
后端应用 → 返回响应 → Nginx 缓存 → 返回客户端
2
3
4
# Nginx 缓存配置
# http 块中定义缓存路径
http {
# 定义缓存区:路径 / 缓存目录层级 / 缓存键区大小 / 最大大小 / 不活跃时间
proxy_cache_path /data/nginx/cache
levels=1:2
keys_zone=api_cache:10m
max_size=1g
inactive=30m
use_temp_path=off;
server {
listen 80;
# 对 API 接口启用缓存
location /api/ {
proxy_cache api_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 10m; # 200 响应缓存 10 分钟
proxy_cache_valid 404 1m; # 404 缓存 1 分钟
proxy_cache_valid 500 0; # 500 不缓存
# 添加响应头标识是否命中缓存
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://backend;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
proxy_cache api_cache;
proxy_cache_valid 200 30d;
proxy_cache_key $uri;
proxy_pass http://backend;
}
}
}
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
# 关键配置说明
| 配置项 | 说明 |
|---|---|
proxy_cache_path | 定义缓存存储路径和参数 |
levels=1:2 | 缓存目录层级,避免单目录文件过多 |
keys_zone | 缓存键共享内存区大小(10m ≈ 8 万个 key) |
max_size | 缓存磁盘最大占用 |
inactive=30m | 30 分钟未被访问的缓存自动清除 |
proxy_cache_valid | 按状态码设置缓存时间 |
proxy_cache_key | 缓存键的组成(默认含 method + host + uri) |
# $upstream_cache_status 状态值
| 状态 | 说明 |
|---|---|
MISS | 未命中缓存,请求转发到后端 |
BYPASS | 绕过缓存(配置了 proxy_cache_bypass) |
EXPIRED | 缓存已过期,请求转发到后端 |
STALE | 缓存已过期,但使用了旧缓存(配置了 proxy_cache_use_stale) |
UPDATING | 缓存已过期,正在更新,返回旧缓存 |
REVALIDATED | 协商缓存验证通过 |
HIT | 命中缓存 |
# Nginx 缓存清除
# 允许通过特定请求清除缓存
location /purge/ {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
proxy_cache_purge api_cache $scheme$request_method$host$request_uri;
}
2
3
4
5
6
7
# 清除指定 URL 的缓存
curl -X GET http://nginx-host/purge/api/user/1
2
# Nginx 缓存适用场景
| 场景 | 缓存时间 | 说明 |
|---|---|---|
| 首页 / 列表页接口 | 1~5 分钟 | 短缓存,兼顾性能和实时性 |
| 商品详情接口 | 10~30 分钟 | 读多写少,可容忍短暂不一致 |
| 静态资源 | 30 天 | 带 hash 的静态文件 |
| 用户个人数据 | 不缓存 | 个性化数据,不可缓存 |
| 搜索接口 | 不缓存 | 查询条件多变,缓存命中率低 |
⚠️ Nginx 缓存是磁盘缓存,读取速度比 Redis 慢,但能承受极高并发,适合缓存大体积响应(如页面、图片)。
# ④ 应用层缓存 — 本地缓存
# 基本概念
本地缓存是运行在应用进程内的缓存,数据存储在 JVM 堆内存中。
- 优点:访问速度极快(纳秒级),无网络开销
- 缺点:多实例间数据不共享、受 JVM 内存限制、应用重启后丢失
# 常用实现方案
# 方案一:自己写 Map(不推荐)
private static final Map<String, String> CACHE = new ConcurrentHashMap<>();
public String get(String key) {
return CACHE.computeIfAbsent(key, k -> loadDataFromDB(k));
}
2
3
4
5
问题:无过期机制、无淘汰策略、无容量限制、无监控,生产环境不推荐。
# 方案二:Caffeine(推荐)
Caffeine 是高性能 Java 本地缓存库,Spring Boot 默认集成。
@Configuration
public class CaffeineConfig {
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(100) // 初始容量
.maximumSize(1000) // 最大容量
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后 5 分钟过期
.weakKeys() // 使用弱引用存储 key
.recordStats() // 开启统计
.build();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
淘汰策略对比:
| 策略 | 说明 | 适用场景 |
|---|---|---|
expireAfterWrite | 写入后固定时间过期 | 数据更新频率低 |
expireAfterAccess | 最后一次访问后过期 | 热点数据持续访问 |
maximumSize | 容量上限,LRU 淘汰 | 控制内存占用 |
weakKeys / weakValues | GC 时自动回收 | 防止内存泄漏 |
# 方案三:Spring Cache + Caffeine
@EnableCaching
@SpringBootApplication
public class Application { }
@Service
public class UserService {
@Cacheable(value = "user", key = "#id")
public User getUserById(Long id) {
return userMapper.selectById(id); // 只在缓存未命中时执行
}
@CachePut(value = "user", key = "#user.id")
public User updateUser(User user) {
userMapper.updateById(user);
return user; // 更新缓存
}
@CacheEvict(value = "user", key = "#id")
public void deleteUser(Long id) {
userMapper.deleteById(id); // 删除缓存
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Spring Cache 核心注解:
| 注解 | 说明 |
|---|---|
@Cacheable | 查询缓存,未命中则执行方法并缓存结果 |
@CachePut | 执行方法并更新缓存 |
@CacheEvict | 删除缓存 |
@Caching | 组合多个缓存操作 |
@CacheConfig | 类级别公共缓存配置 |
# 本地缓存适用场景
| 场景 | 说明 |
|---|---|
| 配置字典数据 | 数据量小、更新频率低、访问频繁 |
| 静态数据 | 省市区列表、枚举值 |
| 热点数据 | 短时间内高频访问的数据 |
| 计算结果缓存 | 复杂计算结果、报表数据 |
# ④ 应用层缓存 — 分布式缓存(Redis)
# 基本概念
Redis 是基于内存的分布式 Key-Value 存储,支持多种数据结构,是分布式缓存的事实标准。
- 优点:多实例共享、支持持久化、丰富的数据结构、高可用
- 缺点:网络开销(1~2ms)、需维护缓存集群
# Redis 常用数据结构选型
| 数据结构 | 适用场景 | 示例 |
|---|---|---|
| String | 简单 KV 缓存 | 缓存用户信息 JSON |
| Hash | 对象属性存储 | user:1 → {name, age, email} |
| List | 有序列表 | 消息队列、最新动态 |
| Set | 去重集合 | 标签、共同好友 |
| ZSet | 排行榜 | 热搜榜、积分排名 |
| Bitmap | 位操作 | 签到、布隆过滤器 |
| HyperLogLog | 基数统计 | UV 统计 |
# Spring Boot 集成 Redis
# 配置
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: xxx
database: 0
lettuce:
pool:
max-active: 8
max-idle: 4
min-idle: 0
max-wait: -1ms
2
3
4
5
6
7
8
9
10
11
12
13
# 使用 RedisTemplate
@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void set(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
public <T> T get(String key, Class<T> clazz) {
Object value = redisTemplate.opsForValue().get(key);
return value != null ? clazz.cast(value) : null;
}
public Boolean delete(String key) {
return redisTemplate.delete(key);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 使用 Spring Cache + Redis
@Service
public class ProductService {
@Cacheable(value = "product", key = "#id", unless = "#result == null")
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
@CacheEvict(value = "product", key = "#id")
public void deleteProduct(Long id) {
productMapper.deleteById(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# Redis 缓存适用场景
| 场景 | 说明 |
|---|---|
| 用户会话 | 分布式 Session、Token 存储 |
| 热点数据缓存 | 商品详情、文章内容 |
| 排行榜 | ZSet 实现实时排名 |
| 计数器 | 点赞数、浏览量 |
| 分布式锁 | SETNX 实现互斥锁 |
| 限流 | 滑动窗口限流 |
# ⑤ 数据库缓存
# MySQL Buffer Pool
MySQL InnoDB 引擎自带缓冲池(Buffer Pool),将频繁访问的数据页和索引页缓存在内存中,减少磁盘 I/O。
SQL 查询
│
▼
Buffer Pool 命中?
│ 是 → 直接返回(内存级速度)
│ 否 → 从磁盘读取数据页 → 存入 Buffer Pool → 返回
2
3
4
5
6
| 参数 | 说明 | 建议值 |
|---|---|---|
innodb_buffer_pool_size | Buffer Pool 大小 | 物理内存的 60%~80% |
innodb_buffer_pool_instances | Buffer Pool 实例数 | 每 1GB 一个实例 |
💡 数据库缓存是最后一道缓存,由数据库引擎自动管理,开发者通常无需干预,但需合理配置 Buffer Pool 大小。
# 各层缓存对比
| 对比项 | 本地缓存(Caffeine) | 分布式缓存(Redis) | 数据库缓存(Buffer Pool) |
|---|---|---|---|
| 访问速度 | 纳秒级 | 毫秒级(1~2ms) | 微秒级 |
| 容量 | 受 JVM 堆内存限制 | 受服务器内存限制 | 受物理内存限制 |
| 多实例共享 | ❌ 不共享 | ✅ 共享 | ❌ 不共享 |
| 数据一致性 | 各实例独立 | 统一存储 | 单实例独立 |
| 持久化 | ❌ 重启丢失 | ✅ 支持 RDB / AOF | ❌ 重启丢失 |
| 运维成本 | 低 | 高 | 中 |
| 适用场景 | 单机热点、静态数据 | 分布式共享数据 | 数据页、索引页 |
# 多级缓存架构
# 设计理念
生产环境通常采用多级缓存架构,每一层缓存拦截不同特征的请求:
请求 → 浏览器缓存(静态资源,0ms)
│ 未命中
▼
CDN 缓存(静态资源回源,5~20ms)
│ 未命中
▼
Nginx 缓存(接口响应,1~5ms)
│ 未命中
▼
本地缓存 Caffeine(热点数据,纳秒级)
│ 未命中
▼
分布式缓存 Redis(业务数据,1~2ms)
│ 未命中
▼
数据库 Buffer Pool(数据页,微秒级)
│ 未命中
▼
磁盘读取(10~100ms)
│
▼
逐层回写缓存
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 多级缓存代码实现
@Service
public class MultiLevelCacheService {
@Autowired
private Cache<String, Object> localCache; // Caffeine
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Object get(String key) {
// 1. 查本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 查 Redis
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value); // 回写本地缓存
return value;
}
// 3. 查数据库
value = loadFromDB(key);
if (value != null) {
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
localCache.put(key, value);
}
return value;
}
}
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
# 各层缓存的职责划分
| 缓存层 | 职责 | 缓存内容 | 典型 TTL |
|---|---|---|---|
| 浏览器 | 拦截静态资源请求 | JS/CSS/图片/字体 | 1 年(带 hash) |
| CDN | 就近返回静态资源 | 静态资源、页面 | 30 天 |
| Nginx | 拦截热点接口请求 | 接口响应体 | 1~30 分钟 |
| 本地缓存 | 拦截热点数据查询 | 字典、配置、热点数据 | 5~10 分钟 |
| Redis | 拦截数据库查询 | 业务数据 | 30 分钟 ~ 1 小时 |
| 数据库 | 减少磁盘 I/O | 数据页、索引页 | 自动管理 |
设计原则:越靠外的缓存,容量越大、速度越快、TTL 越长;越靠内的缓存,数据越精确、TTL 越短。
# 缓存更新策略
缓存和数据源是两个数据源,更新时需要保证一致性。常见策略:
# 策略对比
| 策略 | 说明 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Cache Aside(旁路缓存) | 先更新 DB,再删除 Cache | 简单、一致性好 | 短时间不一致 | 最常用,推荐 |
| Read/Write Through | 应用只操作缓存,缓存同步操作 DB | 应用逻辑简单 | 缓存组件复杂 | 少见 |
| Write Behind | 先更新缓存,异步更新 DB | 写性能极高 | 数据可能丢失 | 日志、统计 |
# Cache Aside 详解(推荐)
读:查缓存 → 命中则返回 → 未命中则查 DB → 回写缓存
写:更新 DB → 删除缓存
2
为什么是删除缓存而不是更新缓存?
| 操作 | 问题 |
|---|---|
| 更新缓存 | 并发写时,A 先更新 DB、B 后更新 DB,但 B 先更新缓存、A 后更新缓存 → 缓存是旧值 |
| 删除缓存 | 下次读时懒加载,天然避免并发更新顺序问题 |
为什么是先更新 DB 再删除缓存,而不是反过来?
| 顺序 | 并发问题 |
|---|---|
| 先删缓存再更新 DB | 线程 A 删缓存 → 线程 B 读缓存未命中,读 DB 旧值并回写缓存 → 线程 A 更新 DB → 缓存是旧值,不一致 |
| 先更新 DB 再删缓存 | 线程 A 更新 DB → 线程 B 读缓存命中旧值(短暂不一致)→ 线程 A 删缓存 → 下次读会加载新值 → 最终一致 |
⚠️ 先更新 DB 再删缓存也有极端不一致场景(读线程读到 DB 旧值、写线程更新 DB 并删缓存、读线程回写旧值),但概率极低。如需强一致,可用延迟双删。
# 延迟双删
public void updateData(Long id, Data data) {
// 1. 先删缓存
redisTemplate.delete("data:" + id);
// 2. 更新数据库
dataMapper.updateById(data);
// 3. 延迟一段时间后再删一次缓存
scheduledExecutor.schedule(() -> {
redisTemplate.delete("data:" + id);
}, 500, TimeUnit.MILLISECONDS);
}
2
3
4
5
6
7
8
9
10
延迟时间需大于一次读操作的耗时(读 DB + 回写缓存),通常 500ms~1s。
# 多级缓存的更新问题
多级缓存架构下,更新更复杂——需要同时清理 Nginx 缓存、本地缓存、Redis 缓存:
数据更新
│
▼
更新数据库
│
├── 删除 Redis 缓存
├── 删除 / 失效本地缓存(Caffeine invalidate)
└── 清除 Nginx 缓存(proxy_cache_purge 或 TTL 过期)
2
3
4
5
6
7
8
⚠️ 浏览器缓存和 CDN 缓存通常通过版本号 / hash 管理,不通过主动删除。发布新版本时 URL 变化,旧缓存自然失效。
# 缓存三大经典问题
# 缓存穿透
现象:查询一个不存在的数据,缓存和数据库都没有,每次请求都打到数据库。
场景:恶意攻击,如查询 id = -1 的用户。
解决方案:
| 方案 | 说明 | 优缺点 |
|---|---|---|
| 缓存空值 | 查不到也缓存 null,设短过期时间 | 简单,但浪费内存、可能短期不一致 |
| 布隆过滤器 | 请求前先过布隆过滤器,不存在则直接拒绝 | 内存高效,但有误判率 |
| 接口校验 | 对非法参数直接拦截(如 id < 0) | 简单,但无法覆盖所有场景 |
// 方案一:缓存空值
public User getUser(Long id) {
String key = "user:" + id;
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return "NULL".equals(value) ? null : (User) value;
}
User user = userMapper.selectById(id);
if (user == null) {
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 缓存击穿
现象:一个热点 Key 突然过期,大量并发请求同时打到数据库。
场景:秒杀商品缓存过期瞬间。
解决方案:
| 方案 | 说明 | 优缺点 |
|---|---|---|
| 互斥锁 | 缓存未命中时加锁,只有一个线程查 DB 并回写缓存 | 简单可靠,但会短暂阻塞 |
| 逻辑过期 | 不设 TTL,在 value 中存过期时间,后台异步刷新 | 不阻塞,但有短暂不一致 |
| 热点 Key 永不过期 | 热点数据不设过期时间,主动更新 | 一致性好,但需维护更新逻辑 |
// 方案一:互斥锁
public User getUserWithLock(Long id) {
String key = "user:" + id;
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return (User) value;
}
String lockKey = "lock:user:" + id;
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return (User) value;
}
User user = userMapper.selectById(id);
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
return user;
} else {
Thread.sleep(50);
return getUserWithLock(id);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
redisTemplate.delete(lockKey);
}
}
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
# 缓存雪崩
现象:大量缓存同时过期,或 Redis 宕机,所有请求打到数据库。
场景:缓存批量设置了相同过期时间,同时失效。
解决方案:
| 方案 | 说明 |
|---|---|
| 过期时间加随机值 | TTL = base + random(0~300s),避免同时过期 |
| 多级缓存 | 本地缓存兜底 |
| 熔断降级 | 数据库压力过大时,返回降级数据 |
| Redis 高可用 | 哨兵 / 集群模式,避免单点故障 |
// 过期时间加随机值
int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
2
3
# 三大问题对比
| 问题 | 本质 | 触发条件 | 核心方案 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 恶意攻击 / bug | 缓存空值 / 布隆过滤器 |
| 击穿 | 热点 Key 过期 | 热点数据过期 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量 Key 同时过期 | 过期时间相同 | 随机过期 / 多级缓存 |
# 缓存淘汰策略
当缓存空间不足时,需要淘汰部分数据。Redis 提供以下策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
noeviction | 不淘汰,内存满时写入报错 | 数据不能丢失 |
allkeys-lru | 淘汰最久未使用的 Key | 最常用 |
allkeys-lfu | 淘汰使用频率最低的 Key | 热点数据明显 |
allkeys-random | 随机淘汰 | 无差别数据 |
volatile-lru | 仅在设了过期的 Key 中 LRU 淘汰 | 混合使用场景 |
volatile-ttl | 优先淘汰即将过期的 Key | 优先清理短期数据 |
volatile-random | 在设了过期的 Key 中随机淘汰 | — |
💡 生产环境推荐
allkeys-lru,兼顾性能和命中率。
# 实际场景分析
# 场景一:电商商品详情页(全链路缓存)
特点:读多写少、热点数据、可容忍短暂不一致。
用户访问商品详情页
│
▼
浏览器缓存 HTML(no-cache,每次协商)
│
▼
CDN 缓存静态资源(JS/CSS/图片,30天)
│
▼
Nginx 缓存商品详情接口(10分钟)
│ 未命中
▼
本地缓存 Caffeine(5分钟)
│ 未命中
▼
Redis 缓存商品数据(30分钟)
│ 未命中
▼
数据库
│
▼
逐层回写
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class ProductService {
@Cacheable(value = "product:detail", key = "#id", unless = "#result == null")
public Product getProductDetail(Long id) {
return productMapper.selectById(id);
}
@CacheEvict(value = "product:detail", key = "#product.id")
public void updateProduct(Product product) {
productMapper.updateById(product);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
策略:全链路缓存 + Cache Aside + 空值缓存(防穿透)+ 过期时间加随机值(防雪崩)。
# 场景二:用户信息缓存
特点:读多写少、一致性要求较高。
@Service
public class UserService {
public User getUser(Long id) {
String key = "user:info:" + id;
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return (User) value;
}
User user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
} else {
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
}
return user;
}
public void updateUser(User user) {
userMapper.updateById(user);
redisTemplate.delete("user:info:" + user.getId());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 场景三:排行榜
特点:实时排序、高并发读。
@Service
public class RankService {
public void addScore(String userId, double score) {
redisTemplate.opsForZSet().add("rank:score", userId, score);
}
public Set<Object> getTop10() {
return redisTemplate.opsForZSet().reverseRange("rank:score", 0, 9);
}
public Long getUserRank(String userId) {
return redisTemplate.opsForZSet().reverseRank("rank:score", userId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
策略:ZSet 实时排序,不缓存到本地,直接读 Redis。
# 场景四:验证码 / Token
特点:短生命周期、自动过期。
@Service
public class CodeService {
public void sendCode(String phone) {
String code = String.valueOf((int) ((Math.random() * 9 + 1) * 100000));
redisTemplate.opsForValue().set("code:" + phone, code, 5, TimeUnit.MINUTES);
}
public boolean verifyCode(String phone, String code) {
String cached = (String) redisTemplate.opsForValue().get("code:" + phone);
if (code.equals(cached)) {
redisTemplate.delete("code:" + phone);
return true;
}
return false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 场景五:字典数据 / 配置缓存
特点:数据量小、更新极少、访问频繁。
@Service
public class DictService {
@Autowired
private Cache<String, List<Dict>> dictCache;
public List<Dict> getDictByType(String type) {
return dictCache.get(type, k -> dictMapper.selectByType(k));
}
public void refreshDict(String type) {
dictCache.invalidate(type);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
策略:本地缓存(Caffeine)即可,无需 Redis。
# 场景六:静态资源发布(前端 + CDN)
特点:前端构建产物,需全量缓存。
# Vite 构建配置
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
// 文件名带 contenthash
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# Nginx 静态资源缓存
location ~* \.(js|css|png|jpg|svg|woff2?)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
# CDN 会遵循此头缓存 1 年
}
2
3
4
5
策略:文件名带 hash + 浏览器 / CDN 缓存 1 年 + 发布新版本时 URL 变化自动失效。
# 缓存使用注意事项
# 1. 大 Key 问题
| 问题 | 说明 |
|---|---|
| 单个 Value 过大 | 如缓存一个 10MB 的列表,导致 Redis 阻塞、网络延迟 |
| Hash / Set / List 元素过多 | 如一个 Set 存 100 万个元素 |
解决:拆分 Key,如 user:1:info、user:1:profile,单 Key 控制在 10KB 以内。
# 2. 热 Key 问题
| 问题 | 说明 |
|---|---|
| 单个 Key 访问量过大 | 如热点商品,单节点 Redis 承受不住 |
解决:
- 本地缓存兜底(多级缓存)
- Key 分散:
hotkey:1、hotkey:2... 随机读其中一个
# 3. 缓存预热
系统启动时,提前加载热点数据到缓存,避免冷启动时大量请求打到数据库。
@PostConstruct
public void preloadCache() {
List<Product> hotProducts = productMapper.selectHotProducts();
for (Product product : hotProducts) {
redisTemplate.opsForValue().set("product:" + product.getId(), product, 30, TimeUnit.MINUTES);
}
}
2
3
4
5
6
7
# 4. 缓存监控
| 指标 | 说明 |
|---|---|
| 缓存命中率 | 低于 80% 需检查缓存策略 |
| 内存使用率 | 接近 maxmemory 需扩容或调整淘汰策略 |
| QPS | 监控缓存负载 |
| 大 Key / 热 Key | 定期扫描排查 |
| CDN 命中率 | 低于 90% 需检查缓存配置 |
| Nginx 缓存命中率 | 通过 $upstream_cache_status 统计 |
# 5. 序列化选择
| 序列化方式 | 优点 | 缺点 |
|---|---|---|
| JDK 序列化 | 兼容性好 | 体积大、不可读 |
| JSON(Jackson) | 体积小、可读 | 需要无参构造 |
| Protobuf | 体积最小、速度快 | 需定义 proto 文件 |
💡 推荐
GenericJackson2JsonRedisSerializer,可读且兼容性好。
# 总结
# 全链路缓存总览
| 层级 | 缓存介质 | 典型耗时 | 核心作用 | 失效方式 |
|---|---|---|---|---|
| 浏览器缓存 | 磁盘 / 内存 | ~0ms | 拦截静态资源请求 | Cache-Control / ETag |
| CDN 缓存 | 边缘节点 | 5~20ms | 就近返回静态资源 | TTL / 主动刷新 |
| Nginx 缓存 | 磁盘 | 1~5ms | 拦截热点接口请求 | TTL / purge |
| 本地缓存 | JVM 堆内存 | 纳秒级 | 拦截热点数据查询 | TTL / LRU |
| 分布式缓存 | Redis 内存 | 1~2ms | 拦截数据库查询 | TTL / 主动删除 |
| 数据库缓存 | Buffer Pool | 微秒级 | 减少磁盘 I/O | 自动管理 |
# 设计要点
| 要点 | 说明 |
|---|---|
| 全链路思维 | 缓存不只是 Redis,从浏览器到数据库每一层都有缓存 |
| 分层职责 | 浏览器/CDN 管静态资源,Nginx 管接口响应,应用层管业务数据 |
| 缓存选型 | 单机热点 → Caffeine;分布式共享 → Redis;高并发 → 多级缓存 |
| 更新策略 | Cache Aside(先更新 DB 再删缓存)最常用 |
| 穿透 | 缓存空值 / 布隆过滤器 |
| 击穿 | 互斥锁 / 逻辑过期 |
| 雪崩 | 随机过期时间 / 多级缓存 / 高可用 |
| 淘汰策略 | Redis 推荐 allkeys-lru |
| 大 Key | 拆分,单 Key < 10KB |
| 热 Key | 本地缓存兜底 / Key 分散 |
| 监控 | 各层命中率、内存、QPS |
记忆口诀:浏览器管静态,CDN 管就近,Nginx 管接口,Caffeine 管热点,Redis 管数据,数据库管磁盘。穿透查不到(缓存空值),击穿热点过期(加锁),雪崩大面积过期(随机 TTL)。更新先 DB 后删缓存,多级缓存逐层兜底。