一、引言:被低估的“第二道防线”
在谈论浏览器缓存时,90%的文章都在讲强制缓存(max-age、immutable)。这没错,强制缓存是性能的天花板。但有一个残酷的现实:强制缓存不可能覆盖所有资源。
html入口文件不能强缓存,否则发版后用户永远看到旧页面;api响应不能强缓存,否则数据实时性无法保证;service worker脚本不能强缓存,否则应用无法自我更新;甚至某些不带内容哈希的遗留静态资源,也不敢贸然设置长过期时间。
在这些场景中,协商缓存(conditional caching)就是那道不可或缺的安全网。它通过一次轻量级的http往返(304 not modified),在“数据新鲜度”和“传输效率”之间取得了精确平衡。然而在实际项目中,协商缓存常常被错误配置:要么etag生成策略不合理导致验证永远失败,要么忽略了vary头导致cdn返回错误版本,要么误以为no-cache等于不缓存而放弃了本可复用的本地副本。
本文将从http协议底层出发,结合nginx实现机制与现代前端工程化实践,帮你彻底掌握协商缓存的正确用法。
二、核心原理:条件请求的完整生命周期
2.1 什么是协商缓存?
协商缓存是指浏览器在本地副本过期(或未设置强制缓存)后,向服务器发起条件请求,由服务器判断资源是否变更:未变更则返回304(无响应体),变更则返回200+新内容。
它与强制缓存的本质区别:协商缓存一定会产生一次网络往返,但这次往返的代价远低于完整传输。
2.2 两套验证机制
| 机制 | 响应头 | 请求头 | 精度 | 计算开销 | nginx默认行为 |
|---|---|---|---|---|---|
| etag | etag: "hash" | if-none-match | 字节级精确 | 中(需读取文件内容或元数据) | ✅ 自动开启 |
| last-modified | last-modified: gmt | if-modified-since | 秒级 | 低(仅文件系统stat) | ✅ 自动开启 |
📌 关键认知:浏览器优先使用etag验证。当两者同时存在时,if-none-match 优先级高于 if-modified-since。不要手动关闭任何一个,双保险是最安全的策略——etag提供精确性,last-modified作为降级兜底。
2.3 304响应的严格约束
根据rfc 7232,304响应必须不包含消息体,且必须包含以下头部(如果原始200响应中包含的话):
- cache-control / expires
- etag
- content-location
- vary
这意味着304不仅是“省带宽”,它还承担着刷新本地缓存元数据的职责。浏览器收到304后会更新本地副本的过期时间和验证器,为下一次缓存决策做准备。
2.4no-cache≠ 不缓存
这是协商缓存中最普遍的误解:
| 指令 | 真实含义 | 是否存储本地副本 | 是否复用本地副本 |
|---|---|---|---|
| no-cache | 每次使用前必须验证 | ✅ 是 | ✅ 是(304时复用) |
| no-store | 禁止任何形式存储 | ❌ 否 | ❌ 否 |
| must-revalidate | 过期后必须验证,不允许使用陈旧副本 | ✅ 是 | ✅ 是 |
⚠️ no-cache 是协商缓存的最佳搭档。它允许浏览器保留本地副本,只是要求每次使用前走一遍304验证流程。对于html等需要即时更新但又希望减少完整传输的资源,no-cache 比 no-store 高效得多。
三、nginx协商缓存的实现机制
3.1 etag的默认生成算法
nginx对静态文件默认生成弱etag,格式为:
w/"文件大小十六进制-修改时间十六进制"
例如:w/"1a2b3c-5f8e9d0a1b2c0"
这个算法的特点是:
- 零io开销:仅需 stat() 系统调用获取文件大小和mtime,不需要读取文件内容;
- 天然版本一致:只要文件内容和修改时间不变,etag就不变;
- ci/cd友好:如果部署流程保留了文件的原始mtime(如rsync -t、tar --preserve),etag在多实例间天然一致。
⚠️ 注意:如果你的部署工具会重置mtime(如scp、某些docker copy操作),etag将在每次部署后变化,导致304命中率骤降。此时应考虑改用基于文件内容的强etag,或在部署流程中保留mtime。
3.2 last-modified的精度限制
last-modified的精度为秒级。如果一个文件在同一秒内被修改了两次(极端场景),第二次修改不会被检测到。这在现代构建工具中几乎不会发生(构建产物都是全新文件),但在动态生成的资源中需要注意。
3.3 动态内容的协商缓存
对于反向代理的后端响应,nginx不会自动生成etag。你需要在后端应用中自行实现etag生成逻辑,或使用nginx的第三方模块(如ngx_http_etag_filter_module增强版)。
一个常见的后端etag策略是对响应体做md5/sha256哈希:
# python flask示例
import hashlib
from flask import make_response
@app.route('/api/config')
def get_config():
data = get_latest_config()
body = json.dumps(data)
etag = hashlib.md5(body.encode()).hexdigest()
response = make_response(body)
response.headers['etag'] = f'"{etag}"'
response.headers['cache-control'] = 'no-cache'
return response四、nginx协商缓存配置实战
4.1 html入口文件:协商缓存的经典场景
location ~* \.html?$ {
# 禁止强制缓存,但允许存储本地副本
add_header cache-control "no-cache, must-revalidate";
# 确保协商缓存验证器开启(nginx默认开启,显式声明防误关)
etag on;
last_modified on;
# 防止后端覆盖缓存头
proxy_hide_header cache-control;
}工作流程:
- 首次访问:200 + html内容 + etag + last-modified → 浏览器存储副本
- 再次访问:浏览器发送 if-none-match + if-modified-since
- 未发版:nginx返回304 → 浏览器复用本地html(仅一次轻量往返)
- 已发版:nginx返回200 + 新html → 浏览器更新副本,加载新版js/css
4.2 service worker脚本:必须协商缓存
location = /sw.js {
add_header cache-control "no-cache, must-revalidate";
etag on;
# sw文件通常很小,last-modified兜底即可
}⚠️ 绝对不能对sw.js设置强制缓存。浏览器发现新sw的唯一方式就是通过http协商缓存检测到文件变更。如果sw.js被强缓存,应用将永远无法更新。
4.3 api响应的协商缓存模板
location /api/ {
proxy_pass http://backend;
# 透传后端的etag和cache-control
proxy_pass_header etag;
proxy_pass_header cache-control;
# 如果后端未设置缓存头,提供兜底策略
# 注意:仅在proxy_hide_header清除后端头后才需要add_header兜底
}4.4 四个易踩坑的配置细节
① vary头决定协商缓存的正确性
如果同一url根据请求头返回不同内容(如gzip/brotli压缩、accept-language多语言),必须添加vary头:
gzip on; add_header vary "accept-encoding"; # ⭐ 开启压缩时必加
否则cdn可能将gzip版本的etag缓存后,用非gzip请求验证时得到304,却返回了gzip内容给不支持压缩的客户端→乱码。
②add_header继承陷阱再强调
# ❌ 子location中的add_header会导致父级cache-control丢失
server {
add_header cache-control "no-cache";
location /app/ {
add_header x-frame-options "deny"; # cache-control没了!
}
}
# ✅ 每个location显式设置所有需要的头
location /app/ {
add_header cache-control "no-cache, must-revalidate";
add_header x-frame-options "deny";
}③always参数确保错误响应也有缓存头
# 不加always:4xx/5xx响应不会携带cache-control add_header cache-control "no-cache" always; # ✅ 所有状态码都生效
这对于api接口尤为重要——401/403响应如果被浏览器意外缓存,会导致后续合法请求也失败。
④ 避免etag与content-encoding冲突
nginx在gzip压缩后会自动重新计算etag(变为弱etag)。如果你在后端生成了基于原始内容的强etag,经过nginx压缩后会变成不同的弱etag,导致浏览器验证失败。解决方案:
- 让后端生成弱etag(
w/"..."),nginx压缩后保持一致; - 或在nginx层统一生成etag,后端不设etag。
五、调试与验证工具箱
5.1 chrome devtools诊断
| 检查项 | 期望值 | 异常信号 |
|---|---|---|
| status列 | 304 not modified | 持续200 → 验证器不匹配或缺失 |
| request headers | 包含 if-none-match 和/或 if-modified-since | 缺失 → 本地无验证器,协商缓存未建立 |
| response headers (304) | 包含 etag、cache-control | 缺失 → 违反rfc,浏览器可能丢弃缓存 |
| size列 (304) | 显示极小字节数(仅头部) | 显示较大值 → 304响应体非空,配置错误 |
| network timing | ttfb < 50ms(同机房) | > 200ms → 后端etag计算过重 |
5.2 curl命令行验证
# 1. 获取初始etag
etag=$(curl -si https://example.com/index.html | grep -i etag | awk '{print $2}' | tr -d '\r')
echo "etag: $etag"
# 2. 模拟条件请求
curl -si -h "if-none-match: $etag" https://example.com/index.html
# 期望:http/1.1 304 not modified
# 3. 验证vary头
curl -si -h "accept-encoding: gzip" https://example.com/style.css | grep -i vary
# 期望:vary: accept-encoding
# 4. 检查304响应体是否为空
curl -s -o /dev/null -w "%{size_download}" -h "if-none-match: $etag" https://example.com/index.html
# 期望:0(或极小的头部字节数)5.3 监控304命中率
# 自定义日志格式,记录缓存状态 log_format cache_log '$remote_addr - $status - $upstream_cache_status - $request_uri'; # 分析304比例 awk '$3 == 304' access.log | wc -l
对于html和sw等资源,304占比应在60%~90%之间。过低说明验证器不稳定,过高(接近100%)可能意味着资源从未真正更新过。
六、常见踩坑速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 304命中率极低 | 部署工具重置了mtime | 使用rsync -t或tar --preserve保留mtime |
| cdn返回乱码内容 | 缺少 vary: accept-encoding | 开启gzip时必加vary |
| 401响应被缓存导致登录循环 | 错误响应缺少 no-store | 认证相关接口加 no-store + always |
| sw.js更新不生效 | 被设置了强制缓存 | 改为 no-cache |
| 子location协商缓存失效 | add_header 不继承 | 每个location显式设置 |
| 后端etag经nginx后验证失败 | 压缩改变了etag | 后端用弱etag或nginx统一生成 |
| safari协商缓存行为异常 | safari对vary处理有bug | 确保vary值规范,避免多余空格 |
| 304响应体非空 | nginx配置错误或后端问题 | 检查 etag on,确认后端304不返回body |
七、结语
到此这篇关于nginx协商缓存的实现的文章就介绍到这了,更多相关nginx协商缓存内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论