缓存策略实战:从浏览器到CDN的全面优化指南
上线后用户反馈“数据没更新”,我差点以为是后端背锅了
上周五下午,产品突然冲过来:“线上用户说改了配置,页面还是老的,刷新也没用!”我第一反应是:不可能,我们明明加了版本号啊。结果自己一试——还真没变。缓存问题又来了。
其实之前就遇到过类似情况,但那次是静态资源(JS/CSS)没更新,加个 hash 就解决了。这次不一样,是接口返回的数据被缓存了,而且不是浏览器缓存,是 CDN 或代理层缓存的。用户改完配置,前端请求同一个 URL,CDN 直接返回了旧响应,根本没打到后端。
折腾了半天,发现根本不是前端能完全控制的事
一开始我以为是 fetch 没加 cache-control,赶紧翻代码:
fetch('/api/user-config')
.then(res => res.json())
确实没设任何缓存头。于是加上 cache: 'no-cache':
fetch('/api/user-config', {
cache: 'no-cache'
})
本地测试没问题,但线上还是不行。后来才反应过来:cache: 'no-cache' 只影响浏览器缓存,对中间代理(比如 Nginx、Cloudflare、阿里云 CDN)完全无效。这些中间层看到 GET 请求,URL 没变,就直接返回缓存了,压根不看你的 fetch 配置。
这里我踩了个大坑:以为前端能控制所有缓存行为,其实 HTTP 缓存是整条链路的事,从浏览器到 CDN 到源站,每一层都可能缓存。
试了三种方案,最后选了个“土但有效”的
方案一:强制加时间戳参数
最粗暴的办法,在 URL 后面加个随机数或时间戳:
const url = /api/user-config?t=${Date.now()}
fetch(url)
亲测有效,每次 URL 不一样,CDN 肯定不会缓存。但缺点很明显:完全绕过了缓存,每次都要走源站,性能差,流量也浪费。而且如果这个接口本来是可以缓存的(比如公开数据),那就更亏了。
方案二:让后端返回正确的 Cache-Control 头
理论上最规范的做法。比如对用户私有数据,后端返回:
Cache-Control: no-store
或者至少:
Cache-Control: no-cache, private
这样中间代理就不会缓存。但现实是:我们后端是 PHP 写的,历史包袱重,很多接口共用一套输出逻辑,改起来要动核心框架,排期至少两周。产品等不了。
方案三:用 POST 代替 GET
这个是我后来灵光一闪想到的。因为绝大多数 CDN 和代理默认只缓存 GET 请求,POST 请求一般不会缓存(除非显式配置)。所以把接口改成 POST,body 里传空对象,也能绕过缓存。
fetch('/api/user-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
后端那边只需要兼容一下 POST 方法就行,改动极小。实测上线后问题解决。但总觉得有点别扭——明明是获取数据,却用 POST,RESTful 原则被我扔地上踩了两脚。
最终方案:动态加版本号,按需缓存
纠结几天后,我决定折中:保留 GET,但根据数据是否可变,动态加参数。
思路是:如果这个接口的数据是用户私有的、会频繁变更的,就在 URL 里加一个基于用户 ID 或数据版本的参数;如果是公共的、稳定的,就不加,让它正常缓存。
比如用户配置接口,我们后端其实有个 config_version 字段。前端首次加载时记下这个版本号,后续请求带上它:
// 首次加载
let configVersion = null;
async function loadConfig() {
const res = await fetch('/api/user-config');
const data = await res.json();
configVersion = data.version; // 假设后端返回 { version: "v123", ... }
return data;
}
// 后续刷新(比如用户保存后)
async function reloadConfig() {
const url = configVersion
? /api/user-config?v=${configVersion}
: '/api/user-config';
const res = await fetch(url);
const data = await res.json();
configVersion = data.version;
return data;
}
但这样还不够,因为用户保存配置后,configVersion 会变,但前端还不知道。所以保存成功后,必须主动清除或更新这个版本号:
async function saveConfig(newData) {
await fetch('/api/user-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newData)
});
// 保存成功后,强制下次请求不带旧版本号(或直接清空)
configVersion = null;
// 或者更激进点:直接重新加载
return reloadConfig();
}
不过这里还有个小问题:如果多个标签页同时打开,一个标签页保存了配置,另一个标签页不知道 configVersion 已过期。这时候它还是会用旧的 v123 去请求,CDN 可能返回缓存。但实际业务中这种情况很少,就算有,用户手动刷新一下就行,无大碍。
核心代码就这几行,但缓存头才是关键
其实最稳妥的方式,还是前后端配合,由后端控制缓存策略。我在测试环境试了下,让后端对 /api/user-config 返回:
Cache-Control: private, no-cache
其中 private 表示只能在用户私有缓存(如浏览器)中存储,不能被共享缓存(如 CDN)存储;no-cache 表示每次使用前必须验证。这样即使前端什么也不做,CDN 也不会缓存。
于是我和后端商量,给几个关键接口加上这个头。代码改动很小,PHP 里加一行:
header('Cache-Control: private, no-cache, must-revalidate');
上线后完美解决。现在前端不用 hack URL,也不用改方法,干净利落。
所以最终方案其实是:**对敏感数据接口,后端返回 Cache-Control: private, no-cache;前端保持简单 GET 请求**。
踩坑提醒:这三点一定注意
- 别以为 fetch 的 cache 选项能控制 CDN:它只管浏览器,中间代理完全无视它。
- GET 请求默认会被缓存:尤其是用了 CDN 的项目,GET 接口一定要明确缓存策略。
- 时间戳参数是双刃剑:虽然简单,但会彻底禁用缓存,慎用在高频接口上。
另外,开发时可以用 Chrome DevTools 的 Network 面板看响应头里的 Cache-Control 和 Age 字段,快速判断是不是被缓存了。线上问题也可以让运维查 CDN 日志,看请求是否 hit 缓存。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有办法用 ETag 实现更精细的缓存控制?我试过但效果一般,可能姿势不对。

暂无评论