应用层缓存对照
这一课你将能做到
- 用一张表说清浏览器/CDN HTTP 缓存、Nginx proxy_cache、Redis 应用缓存各自的键、TTL 量级与失效方式
- 画出
GET /api/tracks 从浏览器到 MySQL 的逐层决策图,并写出 cache-aside(旁路缓存)的读路径与写后失效代码
- 遇到「后台改了课表但页面仍旧」时,按「浏览器 → Nginx → Redis → DB」的顺序定位该失效哪一层,而不是只让用户清浏览器
为什么要学
只学 HTTP 头,会以为「加了 max-age 就完事」;只加 Redis,浏览器还是每次都来打你。三层缓存职责完全不同:HTTP 缓存省的是重复传输,Nginx 挡的是打到应用前的流量,Redis 省的是数据库查询——而 MySQL 永远是权威数据源。
真实面试题「接口响应慢,你有哪些层次的优化手段」「如何保证缓存与数据库一致性」都建立在这张全景图上。本课是 HTTP 缓存课的延伸,也是 Redis/MySQL 学习路线的接口。
先建立图像
| 层级 | 谁决定 | 典型键 | TTL 量级 | 失效方式 |
|---|
| 浏览器 / CDN | Cache-Control、ETag | URL(+ Vary 指定头) | 秒 ~ 天 | 过期 / 304 校验 / 换 URL |
| Nginx | proxy_cache 配置 | $request_uri 等 | 几十秒常见 | TTL 过期 / 主动 purge |
| Redis | Java 业务代码 | tracks:all 等 | 分钟级 | 写时 DEL / 版本号 bump |
| MySQL | — | 行数据 | — | 权威源,无失效概念 |
一条 GET 的逐层决策:
GET /api/tracks
① 浏览器强缓存未过期? → 不发请求,直接用(disk/memory cache)
② 请求发出,CDN / Nginx proxy_cache 命中? → 不打 Java,返回 X-Cache-Status: HIT
③ 到了 Spring,Redis 有 tracks:all? → 不打 MySQL
④ MySQL 查询 → 回填 Redis → 设置 HTTP 缓存头 → 响应逐层返回
每命中一层,就替下一层省一次工作;每层失效策略独立,所以「改了数据不生效」要按层排查。
跟着做
步骤 1:cache-aside 读路径(真实代码)
@Service
public class TrackService {
private final StringRedisTemplate redis;
private final TrackRepository trackRepository; // → MySQL
private final ObjectMapper objectMapper;
public List<TrackDTO> listAll() throws Exception {
String cached = redis.opsForValue().get("tracks:all");
if (cached != null) {
return objectMapper.readValue(cached, new TypeReference<>() {});
}
List<TrackDTO> list = trackRepository.findAllDto(); // 权威:查库
redis.opsForValue().set("tracks:all", objectMapper.writeValueAsString(list),
Duration.ofMinutes(5)); // 回填 + TTL 兜底
return list;
}
}
要点:先读缓存,未命中才读库并回填。TTL 是最后的兜底防线——就算忘了主动失效,5 分钟后数据也会自然新鲜。
步骤 2:写后失效(比读路径更容易出错)
@Transactional
public void update(Long id, TrackForm form) {
trackRepository.update(id, form); // ① 先写权威源 MySQL
// ② 再删相关缓存键(失效,而非回写——回写易把旧值写回)
redis.delete("tracks:all");
redis.delete("tracks:by-slug:" + form.getSlug());
// ③ HTTP 层配合:公开列表的 max-age 要与业务容忍度匹配
}
只更新 DB 不删 Redis → 用户永远看旧列表,与浏览器无关;这是「加了缓存反而更不一致」的第一大来源。
步骤 3:给接口套上 HTTP 缓存头,形成完整三层
@GetMapping("/api/tracks")
public ResponseEntity<List<TrackDTO>> list() throws Exception {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofSeconds(30)).cachePublic())
.body(trackService.listAll());
}
# 第 1 次:Redis MISS → 打 MySQL;响应带 Cache-Control: max-age=30
curl -i http://localhost:8080/api/tracks
# 第 2 次(30 秒内、Redis 已热):Java 里 Redis 命中——但注意,浏览器强缓存命中时这次请求根本不会发出来
curl -i http://localhost:8080/api/tracks
DevTools 里 Size 列显示 (disk cache) 说明连 8080 都没碰到;X-Cache-Status: HIT(若配了 Nginx proxy_cache)说明流量被 Nginx 挡下;Redis 命中默认不在 HTTP 头暴露,除非你自己加 X-App-Cache 教学头。
步骤 4:Nginx proxy_cache 挡在应用前
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=100m inactive=60s;
server {
location /api/tracks {
proxy_pass http://127.0.0.1:8080;
proxy_cache api_cache;
proxy_cache_valid 200 60s; # 200 响应缓存 60 秒
add_header X-Cache-Status $upstream_cache_status; # HIT / MISS / EXPIRED
}
}
# 两次连续请求,第二次应显示 X-Cache-Status: HIT
curl -si http://localhost/api/tracks | findstr /i "x-cache cache-control"
curl -si http://localhost/api/tracks | findstr /i "x-cache cache-control"
Nginx 缓存期间 Java 日志安静——这是好事,但排错时要意识到「应用根本没收到请求」。
步骤 5:故障演习——「改了课表前台仍旧」按层定位
| 现象 | 定位 | 动作 |
|---|
| 无痕窗口/Disable cache 后变新 | 浏览器强缓存(或 Service Worker) | 调短 HTML/接口 max-age,静态资源换哈希 URL |
| 无痕仍旧,但 curl 直连 8080 已是新值 | Nginx proxy_cache 或响应头策略 | 缩短 proxy_cache_valid,或管理端写操作后 purge |
| curl 直连也旧,但 DB 已新 | Redis 键没删 | 检查 update 事务是否执行了 redis.delete;TTL 是否过长 |
| 三层全旁路仍旧 | 连错库(读写分离延迟)/ 多实例配置漂移 | 看应用实际连接串,核对从库延迟 |
把这张表存成团队的排错手册,比每次现场回忆快十倍。
步骤 6:写后失效清单模板
为 PUT /admin/tracks/{id} 列出必须失效的键(示例命名,团队要统一规范):
tracks:all ← 列表聚合键
tracks:by-slug:{slug} ← 单个详情键
tracks:{id}:lessons ← 子资源
tracks:{id}:lessons:lite ← 子资源瘦身版
再补 HTTP 层的思考题:若曾给列表设过较长 max-age,现在要不要改成「短 TTL + 写时删 Redis」组合?改库 → 删 Redis → 核对 HTTP 头策略,三步写成团队检查表,比再引入第四层缓存更值钱。
步骤 7:给每类数据选层(落到本站)
| 数据 | 推荐组合 |
|---|
| 哈希文件名 JS/CSS | 长 HTTP 强缓存,不带 Redis |
| 公开课表列表 | HTTP 短缓存 30s + Nginx 60s + Redis 5min(可选叠加) |
| 用户会话 | Redis(禁 CDN 缓存,HTTP 头 no-store) |
| 个人学习进度 | HTTP private, no-store + Redis 按用户键缓存 |
讲清楚要点
- 因为权威在数据库,所以一切缓存都是性能副本——一致性靠 TTL 与主动失效保证在「业务可容忍窗口」内,不存在绝对实时;产品讨论的不是「要不要缓存」而是「能容忍多久旧数据」。
- 因为 HTTP 缓存的键主要是 URL,所以个性化响应不能配
public——用户 A 的「我的进度」可能被共享缓存发给用户 B;个性化数据 private/no-store + 按用户键的应用缓存。
- 因为 Redis 键是业务命名,所以要有命名规范与批量失效策略(
tracks:* 前缀扫描或维护 key 清单)——散落的魔法字符串是缓存事故之源。
- 因为层数越多推新越难,所以公开列表宜「短 TTL + 写时删」组合,而不是指望一年
max-age 加祈祷;新内容要快,用版本化 URL 立即生效。
- 因为 Nginx 命中时应用日志是安静的,所以「没打到应用」可能是缓存正常工作——排错先看
X-Cache-Status,别急着断言「服务挂了」。
- 因为 cache-aside 的写路径最容易漏删键,所以代码评审要重点盯 update/delete 方法——「先写库、后删缓存」是默认范式;「写库后回写缓存」在并发下会把旧值写回,新手高危。
你可能会懵的地方
-
HTTP 缓存和 Redis 是二选一吗?
不是,可叠加且职责不同:浏览器 30s 强缓存省「重复传输」;Redis 5min 省「数据库查询」。同一接口两层都命中时各回各家——前者表现为请求没发出,后者表现为 Java 里没查库。
-
为什么加了 Redis,Chrome 还是频繁请求?
Redis 不代替浏览器缓存。接口没配 Cache-Control 时,每次刷新都会打到 Nginx/Java(只是 Java 里查 Redis 更快)。想让「浏览器别来」,必须配 HTTP 头。
-
缓存穿透/击穿/雪崩要在本课管吗?
点到为止:空结果也短缓存(或布隆过滤器)防穿透;热点 key 加互斥重建防击穿;TTL 加随机抖动防集体过期。细节留 Redis track,但要知道「DB 扛不住」时先想到这三个词。
-
「清一下缓存」这句产品需求到底指什么?
必须问清:清 CDN?清 Nginx proxy_cache?删 Redis 键模式?还是发新版本换 URL?四个动作完全不同——把模糊需求翻译成具体层操作,是后端的基本功。
-
单机内存缓存(Caffeine)放哪一层?
比 Redis 更快但进程独享。多实例 Spring 下「实例 A 已更新、实例 B 内存仍旧」是真实问题;常见做法是进程内短 TTL(秒级)+ Redis 共享(分钟级)两级,或只用 Redis 换取一致性。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 只加 Redis,从不设 HTTP 缓存头 | 浏览器仍每次来打,Redis 只是让 Java 快了一点 | 公开 GET 配短 max-age 或协商,多层各自生效 |
| 更新 DB 后不删 Redis | 用户长期看旧数据,且 TTL 越长越离谱 | 写路径强制 invalidate,评审盯紧 update/delete |
用户私有接口配 public, max-age=3600 | A 用户数据可能被 CDN 发给 B 用户 | private/no-store,应用层按 userId 键缓存 |
| 排障第一步教用户 Ctrl+F5 | 只治浏览器层的个别用户,其它层原样 | 按层定位表逐层排除,服务端主动失效 |
| 所有缓存键同一超长 TTL | 冷热数据一锅炖,推新困难 | 按易变程度分级 TTL,热点短、冷区长 |
卡点急救
- Redis 里全是旧值,不知道该删哪些键:开发期直接
redis-cli --scan --pattern "tracks:*" 列出再批量删;根治是给「每个写接口对应哪些键」建文档或用统一缓存管理类,禁止散落 redis.delete("xxx") 字符串。
- Nginx proxy_cache 缓存了不该缓存的接口:
proxy_cache_valid 按路径收窄;带 Set-Cookie/Authorization 的请求默认别进缓存(proxy_cache_bypass、proxy_no_cache 控制);上线前 curl 两次看 X-Cache-Status 与响应体是否含用户特征。
- 删了 Redis 值,下一秒又变旧:多实例下另一实例的写请求没走失效逻辑,或定时任务在回写旧快照。全局搜所有写该键的代码;失效逻辑收敛到一个组件,所有写路径必须经过它。
面试常问
-
接口响应慢,可以从哪些层次优化?
① HTTP 层:静态资源长缓存 + API 短缓存/协商,压测确认 TTFB 归属;② 网关层:Nginx proxy_cache 挡热点;③ 应用层:Redis 缓存热数据、接口合并、异步化;④ 数据层:索引、慢 SQL 优化、读写分离;⑤ 协议层:h2/h3、gzip/br。回答时强调「先用 Timing/日志定位瓶颈层,再选手段」。
-
如何保证缓存与数据库一致性?
常用 cache-aside:读时未命中查库回填;写时「先更新 DB,再删除缓存」。进阶讨论:删除失败用 TTL 兜底或消息队列重试;并发读写下的短暂不一致用「业务可容忍窗口 + 版本号」控制;强一致场景干脆不用缓存或加分布式锁(代价大)。核心表达:追求最终一致 + 窗口可控,不追求绝对实时。
-
浏览器缓存、Nginx 缓存、Redis 缓存各自的键和失效方式?
浏览器/CDN:键是 URL(+Vary 头),由 Cache-Control/ETag 控制,过期或换 URL 失效。Nginx:键默认 $request_uri,由 proxy_cache_valid 控制,TTL 或 purge 失效。Redis:键是业务字符串,由代码与 TTL 控制,写时 DEL 或过期失效。三者可叠加,MySQL 始终是权威。
自测三问
- 三层缓存各用一句话说明「它省下了谁的工作」。
- cache-aside 的读路径四步是什么?写成功后至少要做什么?
- 后台改了 title 但浏览器仍显示旧名,你的排查顺序与每一步的验证手段是什么?
小结一句话
HTTP 缓存省重复传输,Nginx 挡应用前流量,Redis 减数据库压力——MySQL 永远是权威;写后失效 + 分层排错,比再堆一层缓存更值钱。
延伸