缓存策略实战:从浏览器到CDN的全面优化指南

宇文志燕 前端 阅读 2,999
赞 39 收藏
二维码
手机扫码查看
反馈

上线后用户反馈“数据没更新”,我差点以为是后端背锅了

上周五下午,产品突然冲过来:“线上用户说改了配置,页面还是老的,刷新也没用!”我第一反应是:不可能,我们明明加了版本号啊。结果自己一试——还真没变。缓存问题又来了。

缓存策略实战:从浏览器到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-ControlAge 字段,快速判断是不是被缓存了。线上问题也可以让运维查 CDN 日志,看请求是否 hit 缓存。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有办法用 ETag 实现更精细的缓存控制?我试过但效果一般,可能姿势不对。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论