缓存更新策略实战:如何避免脏数据与提升系统性能
缓存更新搞死我了,最后靠加个时间戳搞定
上周上线一个新功能,用户反馈说改了配置后刷新页面还是旧数据。我第一反应:又是缓存问题。但这次不是浏览器缓存静态资源,而是 API 接口返回的数据被缓存了,而且是服务端配的强缓存(Cache-Control: max-age=3600),前端根本没法控制。
本来以为加个 no-cache 就完事,结果发现根本没用——因为请求压根没发出去,浏览器直接从 memory cache 里拿的。折腾了半天才发现,这项目用的是 fetch,而默认情况下,fetch 的缓存策略是 default,会尊重服务端的 Cache-Control。也就是说,只要服务端说“一小时内别来烦我”,浏览器就真的一小时不发请求。
试过几种方案,全踩坑了
第一个想法:在 fetch 里加 cache: 'no-store'。代码改成这样:
fetch('/api/user-config', {
cache: 'no-store'
})
本地测试 OK,但上线后发现某些老安卓机(比如微信内置浏览器)根本不支持 cache: 'no-store',照样走缓存。查了 MDN 才知道,这个选项在部分 WebView 里是被忽略的,尤其是国产手机的定制系统。白忙活。
第二个想法:加随机参数,比如 ?t=${Date.now()}。这招老掉牙但有效,对吧?于是改成了:
fetch(/api/user-config?t=${Date.now()})
结果 QA 同学立马找上门:接口日志爆炸了!同一个用户一分钟刷十几次,每次都是不同 URL,CDN 和后端都扛不住。而且运维说这样会让缓存完全失效,本来能缓存公共数据的,现在全变成私有请求了。确实,这招太粗暴,属于“用魔法打败魔法”,但代价太大。
第三个想法:用 ETag + If-None-Match。理论上最优雅,服务端返回 ETag,前端下次带 If-None-Match 头,如果没变就返回 304。但问题是,我们后端压根没实现 ETag!临时加又得协调后端排期,等不及上线。放弃。
最终方案:只在需要时“污染”URL
后来我冷静下来想:其实不是所有 API 都需要实时更新,只有那些“用户主动修改后立即要刷新”的接口才需要绕过缓存。比如保存配置后重新拉取,或者切换主题后获取新样式。其他像首页 banner、商品列表这些,该缓存就缓存,省流量还快。
所以,我搞了个小工具函数,只在特定场景下加时间戳,而且不是用 Date.now(),而是用秒级时间戳,避免毫秒级导致的过度刷新:
function createCacheBusterUrl(base, force = false) {
if (!force) return base;
// 用秒级时间戳,减少 URL 变化频率
const timestamp = Math.floor(Date.now() / 1000);
const separator = base.includes('?') ? '&' : '?';
return ${base}${separator}_t=${timestamp};
}
然后在业务代码里,只有明确知道“这次必须拿最新数据”时,才传 force: true:
// 保存配置后,强制刷新
async function saveAndReloadConfig(newConfig) {
await fetch('/api/save-config', { method: 'POST', body: JSON.stringify(newConfig) });
const freshData = await fetch(createCacheBusterUrl('/api/user-config', true));
return freshData.json();
}
而对于普通的初始化加载,就用原生 URL,让缓存正常工作:
// 页面加载时,走缓存
const initConfig = await fetch('/api/user-config');
这样既保证了关键操作的实时性,又不会让所有请求都变成“一次性 URL”。CDN 日志也恢复正常了。
这里有个小细节差点翻车
一开始我用的是 _t=${Date.now()},结果发现有些代理或 CDN 会对 query string 做长度限制,超长会被截断。虽然概率低,但还是改成了秒级时间戳,数字短很多,而且一秒钟内多次刷新其实也没必要——用户操作不可能那么快。另外,参数名用了 _t 而不是 t,是为了避免和业务参数冲突,毕竟谁也不知道后端有没有用 t 当 token 或 type。
还有一个小问题:如果用户手动刷新页面,还是会拿到旧缓存。但我们产品接受这个 trade-off,因为核心场景是“操作后立即看到结果”,而不是“刷新后看到结果”。如果真有人抱怨,再考虑加个全局的版本号机制,比如把构建时的 hash 带到所有 API 请求里。不过目前没这个需求,先不动。
顺便聊聊 fetch 的缓存策略
其实 fetch 的 cache 选项有好几个值,很多人只知道 no-store,但还有更细粒度的控制:
default:默认,走 HTTP 缓存规则no-store:完全不缓存,每次都发请求(但如前所述,部分环境不支持)reload:忽略本地缓存,但会缓存响应(相当于强制刷新一次)no-cache:发请求,但带上 If-None-Match/If-Modified-Since,适合配合 ETagforce-cache:即使过期也用缓存,除非没有only-if-cached:只用缓存,没缓存就报错(一般用于离线场景)
可惜,现实是很多移动端环境对这些选项的支持不一致。所以我现在更倾向于用 URL 参数这种“物理隔离”方式,虽然土,但兼容性最好。毕竟,能跑就行。
核心代码就这几行
总结一下,我的最终方案就是这个工具函数 + 调用时显式标记是否需要绕过缓存:
// utils/cache.js
export function withCacheBuster(url, shouldBust = false) {
if (!shouldBust) return url;
const ts = Math.floor(Date.now() / 1000);
const sep = url.includes('?') ? '&' : '?';
return ${url}${sep}_t=${ts};
}
// api/user.js
import { withCacheBuster } from '@/utils/cache';
export async function getUserConfig(forceFresh = false) {
const url = withCacheBuster('/api/user-config', forceFresh);
const res = await fetch(url);
return res.json();
}
// 在组件中
// 普通加载
const config = await getUserConfig();
// 保存后强制刷新
await saveConfig(newData);
const freshConfig = await getUserConfig(true);
就这么简单。没有 fancy 的设计,也没有复杂的中间件,但解决了实际问题。而且后续如果后端支持 ETag 了,随时可以替换成更优雅的方案,因为调用点已经集中管理了。
踩坑提醒:这三点一定注意
1. 别盲目用 Date.now() 做缓存 bust,秒级足够,还能减少 URL 变化频率。
2. 参数名别用常见字母,加个下划线前缀 _t 更安全。
3. 只在真正需要实时性的场景启用,别全局滥用,否则等于废掉缓存。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有人用 Service Worker 动态拦截并更新缓存?或者用 localStorage 存版本号?我挺好奇的。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论