2026年11月7日,Datasette 创始人 Simon Willison 在检查 Firefox 网络面板时抓到了一个隐形性能黑洞。每次打开 Datasette 的数据表页面,Firefox 都会在后台自动发起一次 HTTP 预取请求,把下一页的表格数据提前拉下来,平白多出约 80 KB 的 HTML 传输量。绝大多数访客根本不会点击下一页,这意味着客户端白耗了带宽,服务端还要多跑一轮完整的数据库查询和页面渲染,最后把算出来的结果直接扔进垃圾桶。

更有戏剧性的是溯源过程。罪魁祸首是 Datasette 在 HTTP 响应头里附带的 link: <...>; rel="next"。Willison 顺着线索查到 Mozilla 的 Bugzilla 仓库,发现关于这一行为的 Bug 175418 已经开放了整整 24 年。而在该问题早期的讨论区里,赫然贴着 Willison 自己在 2002年10月17日 写的一篇博客链接——那篇文章正是吐槽 Mozilla 不该把 rel="next" 当成预加载指令。二十四年前扔出的回旋镖,精准砸中了二十四年后自己写的开源框架。

标题写着针对 Firefox,代码却切开了视图与 API 的混用

看一眼 Datasette 的 Issue #3046 标题,写的是停止向 Firefox 的 HTML 页面发送 link: rel=next 响应头。如果只看字面,很容易以为他写了一段嗅探 Firefox User-Agent 的补丁。

翻看实际提交的修复代码(Commit 77bc27bd17d89c1d7996df6905249eeecccf6452),手法要干脆得多。在 datasette/views/table.py 中,Willison 直接把判断逻辑改成了 if next_url and format_ != "html":,并同步更新了 tests/test_table_api.py 的测试用例。他没有去识别浏览器身份,而是对所有 HTML 响应一刀切移除了该响应头,只将其保留给 JSON 等 API 格式。

浏览器的分歧与 Datasette 的一刀切解耦 修复前:HTML 与 API 混用管道 • 统一返回 HTTP Link: rel=next • Gecko 引擎将其等同于 prefetch • 后台白跑数据库查询与整页渲染 • 单次无意义额外传输约 80 KB 修复后:按请求格式彻底分流 • 判定条件:format_ != html • HTML 视图:完全移除该 HTTP 头 • 数据 API:保留供客户端游标分页 • 免去 User-Agent 嗅探维护成本

这种改法暴露了现代 Web 框架常见的一个工程偷懒。很多后端系统为了省事,会让人类访问的 HTML 网页和供机器调用的 JSON API 共用同一套输出管道。API 客户端确实需要 HTTP 头里的 rel="next" 来做游标分页,但普通浏览器渲染 HTML 页面时,页面上已经有可视化的下一页按钮,HTTP 头里的分页链接纯属冗余。

直接按格式切开,既避开了脆弱的客户端嗅探,又顺手还清了架构上视图与接口混用的技术债。

为什么只有 Firefox 会触发这个隐形黑洞

同样是收到 Link: <...>; rel="next" 的 HTTP 响应头,为什么 Chrome 和 Safari 相安无事,唯独 Firefox 会在后台默默拉取数据?

翻看三大浏览器引擎的底层源码,分歧点一目了然:

  • Firefox (Gecko):在 nsContentSink.cpp 的实现中,引擎明确把 HTTP Link 头里的 next 和 prefetch 混为一谈,统统丢进预取队列。
  • Chrome (Chromium):在 HTTP 响应头层面,明确要求关系类型为 prefetch 才会触发预加载,直接忽略 rel="next"(不过其 link_loader.cc 对 HTML 标签里的 <link rel="next"> 仍保留了独立处理逻辑,不能简单混同)。
  • Safari (WebKit):同样不会把 HTTP 响应头里的 rel="next" 当作下一页预取的触发器。

我更在意的是,这个在 2002 年就被指出的语义混淆,为什么能在 Mozilla 的缺陷列表里躺满 24 年而不被修复。

这背后是两代 Web 架构的正面冲撞。在 2002 年的静态网页时代,网页大多是预先生成好的静态文件,挂在目录里等访问。Mozilla 早期的工程师做了一个极其乐观的启发式假设:既然页面标明了有下一页,用户大概率会点进去,不如趁着网络空闲先帮用户下载好,还能博一个翻页秒开的爽快感。

静态时代的激进优化,成了动态计算时代的算力刺客。

到了今天,像 Datasette 这样的动态 Web 应用,每一次 URL 请求背后都是实打实的实时数据库查询、权限校验和模板渲染。当年顺手省下的几百毫秒翻页等待,在今天直接异化成了服务端双倍的算力消耗。

更麻烦的是,这笔冤枉账存在严重的防御成本错位。很多后端开发者第一反应是加一行 Cache-Control: no-store 响应头来阻止预取。但这招在物理逻辑上根本走不通——Firefox 必须先向服务器发出请求、触发完后端的数据库查询和页面渲染,才能拿到目标页面的响应头。等浏览器看到 no-store 决定不缓存时,服务端的算力早就烧完了。

而在客户端一侧,Firefox 只在 about:config 里提供了一个名为 network.prefetch-next 的全局布尔开关。普通用户根本不知道它的存在,开发者也无法通过标准协议在单页面粒度上劝阻浏览器的自作主张。

《淮南子》有言,苟利于民,不必法古;苟周于事,不必循旧。Web 标准把描述文档先后顺序的元数据 rel="next",与明确下达预加载指令的 rel="prefetch" 拆成两个词,本意就是让职责各归其位。Gecko 引擎死守二十四年前的旧假设,把逻辑关系强行当成执行指令,最终逼得下游框架只能用砍掉 HTML 响应头的方式自保。

  • 提醒.如果你的后端框架同样复用了 API 分页逻辑,顺手往 HTML 响应头或页面 <link> 标签里塞了 rel="next",现在就可以去查一眼服务器日志了——来自 Firefox 的流量,可能正在让你的数据库白白承受双倍查询。