•
阅读时长 3 分钟

接口返回旧数据,如何排查浏览器和代理缓存

目录

修改数据后重新请求接口,返回的内容却没有变化。刷新页面后恢复正常,或者打开开发者工具后又不出现了,这类问题可能与缓存有关。

但“有缓存”还不能说明数据停在哪一层。前端状态、浏览器 HTTP 缓存、代理缓存和服务端内部缓存,都可能让页面继续显示旧内容。排查时需要结合请求记录和服务端日志,确定这次读取经过了哪些位置。

浏览器允许怎样使用缓存

Cache-Control 中几个常见设置的区别:

设置浏览器怎样处理
private, max-age=60可以保存响应,仍然新鲜时可以直接使用
private, no-cache可以保存,使用前需要向服务端验证
no-store不应保存这次响应

no-cache 不是不保存缓存。它要求重新验证,服务端确认内容没有变化时可以返回 304,浏览器再使用已经保存的正文。

private 表示响应不应被共享缓存保存,并不禁止浏览器缓存。详细规则可以参考 HTTP 缓存文档。

一个可以复现旧数据的接口

下面服务用 revision 表示数据版本,POST 修改版本,GET 返回当前版本。保存为 server.mjs,执行 node server.mjs,在浏览器打开 http://127.0.0.1:3000。

import { createServer } from 'node:http';

let revision = 1;
let originHits = 0;

createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:3000');
  if (url.pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end('<h1>HTTP 缓存示例</h1><p>打开 Console 和 Network 执行请求。</p>');
    return;
  }
  if (url.pathname !== '/data') {
    res.writeHead(404);
    res.end();
    return;
  }
  if (req.method === 'POST') {
    revision++;
    res.writeHead(200, { 'Cache-Control': 'no-store' });
    res.end(JSON.stringify({ revision }));
    return;
  }
  if (req.method !== 'GET') {
    res.writeHead(405);
    res.end();
    return;
  }

  originHits++;
  const tag = `"revision-${revision}"`;
  const headers = {
    'Content-Type': 'application/json',
    'Cache-Control': url.searchParams.get('policy') === 'fresh'
      ? 'private, max-age=60' : 'private, no-cache',
    ETag: tag,
    'X-Origin-Hits': String(originHits),
  };
  console.log(req.url, '到达服务端,第', originHits, '次读取');
  if (req.headers['if-none-match'] === tag) {
    res.writeHead(304, headers);
    res.end();
    return;
  }
  res.writeHead(200, headers);
  res.end(JSON.stringify({ revision }));
}).listen(3000, '127.0.0.1');

打开 Network,不要勾选 Disable cache,在 Console 执行:

async function read(cache = 'default') {
  const response = await fetch('/data?policy=fresh', { cache });
  return {
    data: await response.json(),
    hits: response.headers.get('X-Origin-Hits'),
  };
}

await read();                           // revision 为 1
await fetch('/data', { method: 'POST' }); // 服务端改为 2
await read();                           // 有效期内可能仍为 1
await read('no-store');                 // 绕过浏览器 HTTP 缓存,得到 2

这里故意让写入地址是 /data,读取地址是 /data?policy=fresh。不能指望对一个 URI 的写入,自动让所有带不同查询参数的读取缓存失效。

第二次读取 fresh 地址时,如果复用了浏览器缓存,服务端读取日志不会增加,返回的 X-Origin-Hits 也是缓存响应里的旧值。它不是一份独立的实时监控数据,需要和终端日志一起看。

ETag 和 304

同一个页面中连续执行两次:

await (await fetch('/data')).json();
await (await fetch('/data')).json();

这个地址使用 no-cache。第一次得到正文和 ETag,之后浏览器可以带着 If-None-Match 请求服务端。版本没有变化时,服务端返回 304;版本变化时,返回新的正文和 ETag。

观察 Network,可以看到条件请求和 304。JavaScript fetch 通常拿到的是浏览器合并缓存后的可用响应,不一定直接暴露网络上的 304,所以不要只用 response.status 判断有没有发生重新验证。

同样,Network 显示一条 200,不代表正文一定重新通过网络传输。还要看是否标记 from memory cache 或 from disk cache,以及请求头、响应头和 Timing。

排查请求经过哪一层

可以按照下面几个位置检查:

  1. 页面是否发起请求。如果没有,检查组件状态、请求库已有数据、页面恢复逻辑。
  2. 浏览器是否使用了本地缓存。查看 Network 来源标记和 Cache-Control,使用 no-store 做对照。
  3. 请求是否到达源站。核对同一次请求的日志;有代理时,浏览器发请求不等于源站收到了请求。
  4. 源站实际返回什么。如果服务端已经返回新版本,而页面仍显示旧内容,继续查前端更新逻辑;源站也返回旧版本,则继续查服务端内部缓存和数据读取。

经过 CDN 或反向代理时,可以观察 Age、Via 以及产品提供的缓存状态头,但这些头是否存在、值是什么意思,取决于实际配置。更可靠的依据是代理日志、源站日志和同一条请求的关联信息。

本地示例没有运行代理,cache: 'no-store' 的对照也不能证明绕过了所有服务端内部缓存。定位到代理后,需要按代理自身的缓存规则检查。

后退恢复不一定是 HTTP 缓存

从其他页面后退,浏览器可能恢复整个页面,组件也可能继续保留已有数据。这种情况和“接口响应来自 HTTP 缓存”需要区分。

可以记录 pageshow:

window.addEventListener('pageshow', event => {
  console.log('是否从往返缓存恢复页面:', event.persisted);
});

persisted 为 true 表示页面从往返缓存恢复,并不能描述前端请求库是否又使用了自己的缓存。

DevTools 的 Disable cache 会改变复现条件。可以在关闭它时观察一次,再打开做对照。如果一打开开发者工具问题就消失,需要记录这个选项的状态,而不是直接认定接口已经恢复正常。

确认旧数据的位置后再修改对应策略。用户相关接口、静态资源和查询库的数据不一定适合相同的缓存方式,统一加时间戳或关闭全部缓存,容易暂时隐藏问题,也会丢掉缓存本来带来的收益。