HTTP协强制缓存与协商缓存详解
本文首发于个人技术博客,转载请注明出处
你是否遇到过这些问题:
- 前端更新了代码,用户刷新浏览器还是看到旧版本?
- 明明服务器资源没变,每次刷新还是要重新下载几十 MB 的 JS?
- F5 刷新和 Ctrl+F5 强制刷新到底有什么区别?
这些问题的答案,都藏在 HTTP 缓存机制里。而协商缓存作为 HTTP 缓存体系中最灵活、最常用的一环,既是面试高频考点,也是解决线上性能问题的关键。
一、先搞懂:HTTP 缓存的两层架构
HTTP 缓存分为强制缓存和协商缓存两层,它们是递进关系:先判断强制缓存是否命中,命中则直接使用本地缓存;未命中才会进入协商缓存流程。
1.1 强制缓存:浏览器自己说了算
强制缓存的核心是:只要浏览器判断缓存没过期,就直接使用本地资源,完全不发请求到服务器。
它通过两个响应头实现:
Cache-Control: max-age=86400:相对时间,从响应返回时刻算起,86400 秒(1 天)内有效Expires: Wed, 28 May 2026 10:00:00 GMT:绝对时间,在这个时间点之前有效
核心区别:Cache-Control不依赖客户端本地时间,通过计算「服务器响应时间 + max-age」得到过期时间,稳定性远高于Expires。同时存在时,Cache-Control优先级更高。
状态码:200 OK (from disk cache) 或 200 OK (from memory cache)
1.2 协商缓存:服务器最终说了算
强制缓存有一个致命问题:过期后不管资源有没有变化,都要重新下载完整资源。
协商缓存就是为了解决这个问题:
强制缓存过期后,浏览器不直接下载资源,而是携带缓存标识发请求给服务器。
服务器判断资源是否更新:
- 没更新:返回
304 Not Modified,告诉浏览器继续用本地缓存- 更新了:返回
200 OK和完整的新资源
这就是 "协商" 的含义:由服务器最终决定是否使用缓存。
二、协商缓存核心原理详解
协商缓存有两种独立的实现方式,可以单独使用,也可以同时使用。
2.1 基于时间的实现:Last-Modified / If-Modified-Since
这是最古老、最简单的协商缓存机制:
第一次请求资源时,服务器在响应头中返回:
Last-Modified: Wed, 27 May 2026 10:00:00 GMT表示这个资源的最后修改时间。
强制缓存过期后,浏览器再次请求时,会在请求头中携带:
If-Modified-Since: Wed, 27 May 2026 10:00:00 GMT把上次拿到的最后修改时间发给服务器。
服务器对比资源当前的最后修改时间和请求头中的值:
- 如果相等:资源没更新,返回
304 Not Modified - 如果不等:资源更新了,返回
200 OK和新资源,同时更新Last-Modified
- 如果相等:资源没更新,返回
缺点:
- 精度只有秒级,1 秒内多次修改无法识别
- 文件内容没变但修改时间变了(比如重新保存),会导致误判
- 部分服务器无法准确获取文件修改时间
2.2 基于唯一标识的实现:ETag / If-None-Match
为了解决Last-Modified的缺点,HTTP/1.1 引入了ETag机制:
第一次请求资源时,服务器根据资源内容计算出一个唯一哈希值,在响应头中返回:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"强制缓存过期后,浏览器再次请求时,会在请求头中携带:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"服务器重新计算当前资源的 ETag,和请求头中的值对比:
- 如果相等:返回
304 Not Modified - 如果不等:返回
200 OK和新资源,同时更新ETag
- 如果相等:返回
ETag 的两种类型:
| 类型 | 格式 | 对比精度 | 适用场景 |
|---|---|---|---|
| 强 ETag | ETag: "abc123" |
字节级精确对比 | 绝大多数场景 |
| 弱 ETag | ETag: W/"abc123" |
语义级对比(允许注释、空格等微小差异) | 对性能要求极高的场景 |
2.3 优先级与对比
当响应头中同时存在ETag和Last-Modified时:
ETag优先级绝对高于Last-Modified- 服务器会先判断 ETag 是否变化,只有 ETag 不变时,才会去判断 Last-Modified
2.4 你不知道的 304 响应细节
很多人以为 304 只是一个状态码,其实它有几个非常重要的特性:
- 304 响应只返回响应头,不返回响应体:这是它节省带宽的核心原因,一个 304 响应通常只有几百字节
- 304 响应会更新本地缓存的过期时间:如果服务器返回了新的
Cache-Control或Expires,浏览器会用新值更新缓存 - 304 响应不会更新 ETag 和 Last-Modified:只有返回 200 时才会更新这两个标识
2.5 不同刷新行为对缓存的影响(面试必问)
这是最容易被忽略但最实用的知识点:
| 操作 | 强制缓存 | 协商缓存 | 行为说明 |
|---|---|---|---|
| 地址栏回车 / 链接跳转 | ✅ 生效 | ❌ 不触发 | 优先使用强制缓存,过期才走协商 |
| F5 刷新 / 点击刷新按钮 | ❌ 失效 | ✅ 生效 | 跳过强制缓存,直接发起协商请求 |
| Ctrl+F5 强制刷新 | ❌ 失效 | ❌ 失效 | 完全跳过所有缓存,请求头不带任何缓存标识,强制服务器返回完整资源 |
三、一张图看懂完整 HTTP 缓存流程
下面是从浏览器发起请求到最终渲染的完整缓存流程图,涵盖了所有分支情况:

四、Spring Boot 缓存实战:零代码到自定义
很多人以为协商缓存需要写大量代码,其实 Spring Boot 已经为我们做了几乎所有的工作。
4.1 Spring Boot 默认缓存机制
Spring Boot 默认就为所有静态资源(JS、CSS、图片、字体等)开启了协商缓存:
- 自动生成
Last-Modified:基于文件的最后修改时间 - 自动生成
ETag:基于文件内容的 MD5 哈希值 - 默认没有开启强制缓存,也就是每次请求都会走协商缓存
这意味着:你什么都不用做,Spring Boot 项目已经在使用协商缓存了。
4.2 基础配置:application.yml
我们只需要通过简单的配置,就能开启强制缓存,进一步提升性能:
spring:
resources:
cache:
# 开启缓存
cachecontrol:
# 静态资源强制缓存1年(31536000秒)
max-age: 31536000
# 允许CDN和浏览器缓存
cache-public: true
# 资源过期后必须和服务器协商
must-revalidate: true
# 开启ETag生成(默认开启)
use-etag: true
# 开启Last-Modified生成(默认开启)
use-last-modified: true
配置说明:
max-age: 31536000:设置 1 年的强制缓存,这是静态资源的标准配置cache-public: true:允许中间代理(如 CDN)缓存资源must-revalidate: true:强制缓存过期后必须和服务器协商,不能使用过期缓存
4.3 进阶:自定义静态资源缓存规则
不同类型的静态资源,缓存策略应该不同。我们可以通过WebMvcConfigurer自定义规则:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 1. 打包后的前端资源(带哈希值):强制缓存1年
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS)
.cachePublic()
.mustRevalidate());
// 2. HTML文件:不使用强制缓存,每次都走协商缓存
registry.addResourceHandler("/*.html")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.noCache());
// 3. 接口文档:缓存1小时
registry.addResourceHandler("/doc.html")
.addResourceLocations("classpath:/META-INF/resources/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)
.cachePublic());
}
}
最佳实践:
- 带哈希值的静态资源(如
app.abc123.js):设置超长强制缓存,更新时改文件名即可 - HTML 文件:设置
no-cache,每次都走协商缓存,保证用户能看到最新版本 - 不经常变化的资源(如接口文档、图片):设置适中的强制缓存时间
4.4 高级:为动态接口生成自定义 ETag
对于不经常变化的动态接口,我们也可以手动实现协商缓存,大幅提升接口性能。
Spring Boot 提供了ETagResponseFilter和ShallowEtagHeaderFilter,可以自动为响应生成 ETag:
@Configuration
public class ETagConfig {
/**
* 自动为所有响应生成浅ETag
* 基于响应内容的MD5哈希值
*/
@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
FilterRegistrationBean<ShallowEtagHeaderFilter> filterBean = new FilterRegistrationBean<>();
filterBean.setFilter(new ShallowEtagHeaderFilter());
// 只对指定接口生效
filterBean.addUrlPatterns("/api/dict/*", "/api/config/*");
filterBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return filterBean;
}
}
效果:
- 访问
/api/dict/list接口时,服务器会自动生成 ETag - 再次请求时,如果接口返回的数据没变,服务器会直接返回 304
- 对于数据量大、变化频率低的接口,性能提升非常明显
4.5 实际开发场景案例
场景 1:前端工程化项目的缓存策略
这是目前互联网公司最常用的方案:
- 前端打包时,给所有 JS、CSS、图片文件名加上内容哈希值
- 这些带哈希值的资源,设置 1 年强制缓存
- HTML 文件不设置强制缓存,每次都走协商缓存
- 更新时,只有修改过的文件哈希值会变,用户只会下载变化的资源
场景 2:字典接口的缓存优化
系统中的字典接口(如性别、地区、状态)通常变化频率极低,但调用量极大。
- 配置
ShallowEtagHeaderFilter为字典接口生成 ETag - 设置
Cache-Control: max-age=3600,强制缓存 1 小时 - 这样既保证了数据的实时性,又能将接口 QPS 提升 10 倍以上
场景 3:CDN 配合的缓存策略
当使用 CDN 时,缓存流程变成:
- 用户请求 CDN 节点
- CDN 节点判断自己的缓存是否过期
- 未过期:直接返回给用户
- 过期:CDN 节点携带 ETag 和 Last-Modified 回源到服务器
- 服务器返回 304:CDN 更新自己的缓存过期时间,返回给用户
- 服务器返回 200:CDN 更新缓存,返回新资源给用户
五、灵魂拷问:协商缓存真的有人用吗?
这一开始也是是我最最易或的问题,也是很多人对缓存最大的误解。
5.1 为什么我在国企三年没用到?
这完全是场景差异导致的,和技术本身的实用性无关:
- 用户量和性能要求不同:国企内部系统通常只有几十到几百个用户,服务器压力极小,即使所有资源都不缓存也能跑通。而互联网公司的系统要面对几百万甚至几千万用户,每节省 1KB 带宽、每减少 100ms 延迟,都能带来巨大的成本节约。
- 技术栈和迭代频率不同:很多国企老项目还是 JSP、Thymeleaf 这种服务端渲染技术,静态资源极少,且迭代周期极长,几个月甚至几年才更新一次。而互联网公司的前端项目每周甚至每天都要更新,缓存策略直接影响用户体验。
- 缓存逻辑被自动处理了:没写过不代表它不存在。Nginx、Tomcat、Spring Boot 默认就会为静态资源生成 ETag 和 Last-Modified,CDN 的核心就是缓存。这些逻辑都在底层默默工作,后端开发完全感知不到。
5.2 哪些地方在大规模使用协商缓存?
- 所有互联网公司的前端项目:淘宝、京东、抖音这些网站,90% 以上的静态资源都在使用协商缓存
- 所有 CDN 和对象存储服务:阿里云 OSS、腾讯云 COS 默认就会为所有文件生成 ETag 和 Last-Modified
- 公共 API 和开放平台:GitHub API、高德地图 API 等都大量使用协商缓存来节省服务器资源和 API 调用次数
5.3 后端开发到底需要做什么?
绝大多数情况下,你不需要手动实现协商缓存的逻辑,框架和中间件已经帮你做好了。你只需要:
- 正确配置
Cache-Control头,明确不同资源的缓存策略 - 对于数据量大、变化频率低的接口,开启 ETag 生成
- 排查线上缓存问题时,能看懂缓存头,知道为什么用户看到的是旧资源
六、缓存最佳实践与避坑指南
- 优先使用强制缓存:强制缓存不需要发请求,性能远高于协商缓存
- 带哈希值的资源用超长强制缓存:更新时改文件名即可,永远不会有缓存问题
- HTML 文件永远用 no-cache:保证用户能看到最新版本
- 不要给 POST 接口设置缓存:POST 接口通常是写操作,缓存会导致数据不一致
- 更新资源时一定要改文件名:不要依赖协商缓存来更新资源,用户可能会看到旧版本
- 线上问题排查时,先看缓存头:90% 的缓存问题都是因为缓存头配置错误导致的
总结
协商缓存不是什么高大上的新技术,也不是只存在于面试中的八股文。它是 HTTP 协议的基础组件,是现代 Web 架构中无处不在的性能优化手段。
缓存确实在互联网的每一个角落默默工作着。理解它的原理,能让你在遇到缓存问题时不再抓瞎,也能让你设计出性能更好的系统。
最后记住一句话:缓存是计算机科学中最难的问题之一,也是收益最高的问题之一。