
版本发上去了,构建产物也换了,一部分用户反馈界面还是老样子,清缓存之后才正常。这类反馈最容易被归为「网络问题」,但它有一个稳定的特征:出问题的人不是全部,而且反复刷新没用。
下面按排查的顺序走一遍,全部在本机搭的等价场景里复现,服务端是真服务器,请求记录是它自己记的。
先排除掉的两个方向
第一个方向是构建产物没更新。这个可以直接排除:拿到出问题那台机器上加载的资源地址,文件名的哈希是旧的,而新版本发布后哈希已经变了。说明浏览器加载的整份 HTML 就是旧的。
第二个方向是服务端缓存或 CDN 没刷新。把请求直接打到源站,响应里的 HTML 是新版本,说明源站没问题。剩下的一种可能是浏览器自己决定不发请求,这种情况在响应头里能直接读出来。
响应头里多了一项
新版本上线时,静态资源那边为了减少请求,配了 Cache-Control: max-age=31536000。这套头是给文件名带内容哈希的资源准备的,问题是同一份配置被套到了 index.html 上。文件名不变的 HTML 配一年强缓存,浏览器在一年之内不会再去问服务器一句话。
在本机把这件事复现一遍
场景很简单:一个 index.html,里面引用一个带哈希的 js;一个本地服务器按两种不同的响应头发它;客户端按新鲜度规则决定这次要不要发请求。A 组是带强缓存的 HTML。
== A: index.html 带 max-age=31536000(强缓存) ==
第一次访问:条件请求或首次请求,服务端回 200,拿到「v1 <script src="/app.a1.js"></script>」
-- 这里发布新版本,index.html 内容换成了 v2 --
发布后再访问:本地新鲜,不发请求,页面里写的是「v1 <script src="/app.a1.js"></script>」
服务端这一轮真正收到 1 次请求:
第 1 次请求到达: GET /index.html | If-None-Match=(没带)
服务端回 200,正文 38 字节
结论:用户看到的还是 v1
整个过程中,服务端只收到一次请求。第一次访问拿走 HTML,之后发布了新版本,第二次访问浏览器直接用本地副本,请求根本没发出去。这也解释了为什么「刷新」常常没用,刷新按钮对新鲜期内的强缓存副本不产生新的网络请求。
B 组换成 no-cache,同一套流程:
== B: index.html 带 no-cache(每次都去问一句) ==
第一次访问:条件请求或首次请求,服务端回 200,拿到「v1 <script src="/app.a1.js"></script>」
-- 这里发布新版本,index.html 内容换成了 v2 --
发布后再访问:条件请求或首次请求,服务端回 200,页面里写的是「v2 <script src="/app.b2.js"></script>」
服务端这一轮真正收到 2 次请求:
第 1 次请求到达: GET /index.html | If-None-Match=(没带)
服务端回 200,正文 38 字节
第 2 次请求到达: GET /index.html | If-None-Match="8817828739984291896"
服务端回 200,正文 38 字节
结论:用户看到的还是 v2
服务端这次收到两次请求。第二次客户端带着上次拿到的 ETag 去问,服务器发现内容已经变了,回 200 加新正文,页面拿到的是新版本。这里能看出 no-cache 这个头名字起得不好,它并不禁止缓存,只要求每次使用本地副本之前向服务端确认一次,确认的方式就是带条件请求头。真正禁止存储的是 no-store。
每次都去问,代价是多大
== C: 内容没变时,no-cache 的第二次请求拿到什么 ==
再访问一次:条件请求,服务端回 304,继续用本地副本,页面还是「v3 <script src="/app.c3.js"></script>」
服务端这一轮真正收到 2 次请求:
第 1 次请求到达: GET /index.html | If-None-Match=(没带)
服务端回 200,正文 38 字节
第 2 次请求到达: GET /index.html | If-None-Match="7452973725044562857"
服务端比对 ETag 命中,回 304
内容没变化时服务器回 304,不带正文,省的是带宽不是往返。这是 no-cache 相对强缓存多出来的成本:一次网络往返。对 HTML 来说这次往返值得,因为它决定了后面加载哪些资源,页面上那一个字节的改动都可能换掉整份资源清单。
资源那边为什么可以配一年
== 资源文件的对照:文件名带内容哈希时,强缓存是安全的 ==
/app.a1.js 内容变了会改名成 /app.b2.js,旧地址永远不会指向新内容,
所以它可以配 max-age=31536000, immutable;index.html 的地址永远不变,不能。
区别在地址会不会变。/app.a1.js 里的哈希来自内容,内容一改地址就改,旧地址永远指向同一份内容,可以放心让浏览器存一年。index.html 的地址始终是 /,内容每次发布都可能变,一年强缓存等于把新版本挡在门外。
改完之后还有一件容易漏的事
把 HTML 换成 no-cache 之后,还有一个遗留问题值得顺手处理:那些一年强缓存、文件名也没带哈希的资源,如果曾经发出去过,用户手里那份副本在一年内不会更新。稳妥的收尾是给所有资源地址加上内容哈希,让文件名承担版本号,缓存头只负责「存多久」。
判断这类问题有没有落干净,有个直接的检查点:打开开发者工具的请求列表,看 HTML 那次请求是不是每次刷新都出现,资源那栏是不是大量来自缓存。HTML 也来自缓存时,就该回头看响应头了。
一条取舍:no-cache 换来的是一次额外的条件请求,每次访问多一个往返。站点如果是内网、延迟极低,这笔账无所谓;公网页面值得留着它,我在这类配置上宁可少省一次往返,也不接受「有的用户看到的还是上一版」这种反馈。