Cloudflare 的面板说我有四分之一的请求是 504。但没有一个到达过用户:它们是 Cloudflare Early Hints 功能的缓存未命中,被记录成 ClientRequestSource = earlyHintsCache、OriginResponseStatus = 0 的 504。这篇讲怎么把它们和真正的 504 分开,以及把未命中变成命中的那一个响应头。
我第一次碰到它们是在 9 月,健康检查全绿、Cloudflare 面板上却有 22% 的请求在失败,我关掉了 Early Hints,就此作罢。11 月再看同一个 zone,Early Hints 又被打开了,一天之内又多出了 2,600 个 504。这一次我想弄清楚它们到底是什么,以及正确的做法是再关一次这个功能,还是让它真正发挥作用。这个 zone 前面挂着一个 Fly.io 源站,它同时提供一个 Django API、一个 Expo Web 应用和一个用 Hugo 搭的营销网站。
简短的答案#
- 这些 504 是 Cloudflare 的 Early Hints 缓存写下的,不是你的访客。在 Cloudflare 日志里,它们的
ClientRequestSource = earlyHintsCache。 - 这里的 504 表示缓存未命中:Cloudflare 没有为这个 URL 存下任何提示。命中则记录为 2xx。
- 它们根本不会到达你的源站,
OriginResponseStatus为0。 - Early Hints 依赖的是
LinkHTTP 响应头,HTML 里的<link rel="preload">标签喂不了它。 - 把这个响应头发出去,在那些有资源可预加载的页面上,这些未命中就会变成命中,并开始返回真正的
103 Early Hints响应。
还有三件事值得先知道。只提示渲染关键的 CSS、脚本和字体,并且字体要保留 crossorigin。用 curl -sv --http2 验证,你应该在 200 之前看到 103。无论你做什么决定,都把 earlyHintsCache 排除在告警之外;如果你今后都不会发送这个响应头,就关掉 Early Hints,别用日志噪音为它买单。
如何分辨假的 Cloudflare 504 和真的 504#
如果你用 Logpush 或 Instant Logs,先看两个字段。ClientRequestSource 对真实访客的请求是 eyeball,对 Early Hints 查询则是 earlyHintsCache。OriginResponseStatus 为 0 时,说明源站根本没有被联系过。
在 GraphQL Analytics API 里,同样的区分就是 requestSource 维度。现在每次 5xx 飙升,我在相信它之前都会先跑这个查询:
query ($zone: String!, $since: Time!) {
viewer {
zones(filter: { zoneTag: $zone }) {
httpRequestsAdaptiveGroups(
limit: 20
filter: { datetime_geq: $since, edgeResponseStatus: 504 }
orderBy: [count_DESC]
) {
count
dimensions { requestSource userAgent clientRequestPath }
}
}
}
}按 User-Agent 分组,结论也一样。9 月时,Early Hints 查询显示为 bastion early hints 和 nginx-ssl early hints;11 月则是 fl2 early hints 和 nginx-ssl early hints(FL2 是 Cloudflare 新一代的代理)。24 小时内,这个 zone 的 2,631 个 504 全部来自这些 User-Agent,没有一个来自浏览器、应用、curl 或搜索引擎爬虫。
访客真正看到的 504,几乎总是来自 Cloudflare 之外,也就是你这一侧:挡在应用前面的代理或负载均衡器等不及放弃了。可能是 nginx,可能是云负载均衡器,也可能是等待休眠机器启动的平台代理。Cloudflare 自己的超时用的是别的状态码:522 表示连不上源站,524 表示连上了,但在 125 秒内(Enterprise 以下套餐的默认值)没有收到响应。
所以,如果你是因为 504 想调大 Cloudflare 的超时时间而搜到这里:504 背后的那个超时几乎从来都不是 Cloudflare 的。去检查 nginx 的 proxy_read_timeout、负载均衡器的空闲超时,或者平台唤醒机器需要多久。有个很有用的线索:如果请求总在远低于 125 秒的整数时间点失败,比如 30 秒或 60 秒,那是你自己代理的上限,不是 Cloudflare 的。(只有 Enterprise 套餐能把 Cloudflare 的 125 秒上限提高到最多 6,000 秒,而 Cloudflare 自己也把这当作权宜之计。)
这些 504 到底是什么#
在开启了 Early Hints 的 zone 上,每个指向 HTML 页面或无扩展名 URL 的请求(在以 API 为主的 zone 上,大多数 API 调用都算在内)都会附带一次内部查询:Cloudflare 查看自己的缓存里有没有可以发送的提示。这次查询会单独记成一行,ClientRequestSource = earlyHintsCache。它不是终端用户的请求,也永远不会被发往你的源站。
那一行的状态码是缓存查询的结果,而不是错误:
- 2xx 是命中。Cloudflare 存有这个 URL 的
Link头,并把它作为103发给了浏览器。在我的 zone 上,命中同时以200和204出现。 - 504 是未命中。没有存下任何东西,所以没有什么可以提前发送。
如果源站从不发送 Early Hints 所依赖的那个响应头,每次查询都会未命中,每个请求都会写下一行 504。在我做任何改动之前的三个小时里,这个 zone 记录了 173 次 Early Hints 查询,173 次全部未命中。自从这个功能打开以来,它一次提示都没有发出过。
再更正一下我 9 月那篇文章:我当时把它们描述为"从 Fly 源站得不到响应"的探测请求。这并不准确。它们根本不会去源站。这个 504 记录的是缓存未命中,而不是超时。
Cloudflare 在它的 Early Hints 文档里简短地写明了这一点:记录为 ClientRequestSource: earlyHintsCache 的 Early Hints 查询是"expected and benign"(预期之内、无害的),200 是缓存命中,504 是未命中;在 Log Explorer 里可以用 ClientRequestSource != "earlyHintsCache" 把它们过滤掉。Cache Analytics 本来就不把它们算进去,但吓到我的那张 HTTP 流量图会。
假 504 让我白白升级了服务器#
这就是区分它们的意义所在。11 月 3 日,也就是我动笔写这篇文章的前一天,Cloudflare 记录了 3,292 个 504,而访客请求大约只有 13,300 个:表面错误率 25%。这个 API 跑在 Fly.io 的 shared-cpu-1x 机器上,这类机器靠突发积分(burst credit)运行:保证给它的 CPU 份额很小,想跑得更快就要消耗积分。8 月的一次压力测试显示,在持续负载下它会被限流到每秒大约 20 到 30 个请求。“高峰时四分之一的请求超时"看起来正是这种限流,于是我把 API 换成了 shared-cpu-2x。
可这些 504 全都是 Early Hints 缓存未命中。按 requestSource 拆开,再回头看源站自己的日志:
- 10 月 28 日到 11 月 4 日,Cloudflare 的所有 5xx 都是
earlyHintsCache查询,只有大约每天一个 525 例外(我追查的那个 525 来自一个安全扫描器)。 - 10 月 24 日到 11 月 4 日,源站的访问日志里一个 5xx 都没有,超过 10 秒的请求只有一个。
- Fly 的指标显示,最忙的时候也只有每秒约 7 个请求,机器用掉的 CPU 从未超过一个核心的五分之一,离限流上限还远得很。
换大机器什么也没修好,因为本来就没有坏。API 已经换回了 shared-cpu-1x。
不要根据一张没有按 request source 拆分的 5xx 图表来做容量决策。也要拿源站自己的日志对照一下:如果 Cloudflare 说四分之一的请求失败了,而你的服务器一条错误都没记,那在动服务器之前,先弄清楚 Cloudflare 到底在数什么。
2026 年 11 月 5 日更新:这一节的第一版说那一周 API 在 shared-cpu-1x 上真的垮了。其实并没有。上面的数字是我回头核查时发现的。
HTTP 103 是错误吗?#
不是。1xx 范围的状态码属于信息性响应,是服务器在正式响应之前可以发送的临时响应。RFC 8297 定义的 103 Early Hints 携带 Link 头,让浏览器在服务器还在生成页面的时候,就开始获取关键的 CSS、JavaScript 和字体,或者提前与其他源建立连接。最终的 200 随后在同一个请求里到达。
各浏览器的支持程度不一,但很安全。基于 Chromium 的浏览器(Chrome 103 起,以及 Edge)和较新的 Firefox 同时支持 preload 和 preconnect 提示;Safari(17 及以上)只使用 preconnect。浏览器只在 HTTP/2 和 HTTP/3 上处理 103。不认识它的浏览器只会像往常一样等 200,所以 103 对大多数访客是抢先一步,对其余访客则毫无影响。
我的为什么是空的:Link 响应头与 <link rel=preload>#
这是我花时间最多的地方,因为它看起来本该已经生效了。
Early Hints 只缓存一样东西:带有 rel=preload 或 rel=preconnect 关系的 Link HTTP 响应头。Cloudflare 从你平常的响应中(HTML 或无扩展名 URL 上的 200、301 或 302)读取它、存起来,然后在完整响应准备好之前,把它作为 103 重放给下一个访客。
我的页面其实已经有预加载了,只不过是写在 HTML <head> 里的标签:
<link rel="preload" href="/_expo/static/css/global-7d77bd.css" as="style">
<link rel="preload" href="/_expo/static/js/web/entry-fd7fa4.js" as="script">
<link rel="preload" href="/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin>这些标签仍然有用,浏览器会按它们行事。但浏览器只有在收到 HTML 并开始解析之后才能看到它们,而这恰恰是 Early Hints 想要抢在前面的那一刻。Expo 导出和我用的 Hugo 主题都会自动写这些标签,却都不设置这个响应头。于是功能开着,预加载也声明了,两者却从未碰面。
解决办法:用你手上已有的数据发出这个响应头#
最干净的办法,不是手工维护一份关键资源清单,每次打包哈希一变它就过时。每个页面已经用那些标签声明了自己想预加载什么。所以在返回页面时,把它们照抄进 Link 响应头就行。标准库的 html.parser 对带引号和不带引号的属性都能处理(Hugo 的压缩器会去掉引号),不用自己写正则:
from html.parser import HTMLParser
class PreloadLinks(HTMLParser):
def __init__(self):
super().__init__()
self.hints = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "link" or "preload" not in (a.get("rel") or "").split() or not a.get("href"):
return
hint = f"<{a['href']}>; rel=preload"
if a.get("as"):
hint += f"; as={a['as']}"
if a.get("as") == "font" or "crossorigin" in a:
hint += "; crossorigin"
self.hints.append(hint)
def link_header(html: str, limit: int = 12) -> str | None:
parser = PreloadLinks()
parser.feed(html)
return ", ".join(list(dict.fromkeys(parser.hints))[:limit]) or None然后在 Django 的 HTML 响应上加上它:
response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
response["Link"] = link有一个细节坑过我,值得记住:字体要保留 crossorigin。没有它的字体预加载会被当成另一个请求,字体会被下载两次,页面不但没变快,反而变慢了。
你不需要自己写 103,也不用在 ASGI、Granian 或 uvicorn 里添加任何临时响应的代码。Cloudflare 会根据你的响应头生成 103,你唯一要做的就是把响应头发出去。
在其他技术栈上#
规则在哪里都一样:给 HTML 响应加上 Link 头,剩下的交给 Cloudflare。
nginx,在返回 HTML 的 location 里:
location = / { add_header Link "</css/app.css>; rel=preload; as=style" always; add_header Link "</fonts/inter.woff2>; rel=preload; as=font; crossorigin" always; }Laravel 配合 Vite 时已经有专门的中间件
AddLinkHeadersForPreloadedAssets,它会把 Vite 的预加载转换成Link头。针对它报告过的两个问题,正好就是上面那两个坑:它的modulepreload提示曾经不带crossorigin发出,导致浏览器可能丢弃它们并把脚本下载两次;在大页面上,这个响应头还曾超过 Apache 约 8 KB 的头部上限。请在你使用的版本上把这两点都确认一下。其他任何环境:用一个
Link头以逗号分隔,或者发多个Link头,两种方式都行。
大家最容易过度的,是提示哪些资源。只提示渲染关键的 CSS、入口脚本和首屏用到的一两个字体,不要把每个 chunk 都塞进去。清单太长,浏览器就会先去取暂时用不到的东西,反而耽误了真正需要的。我把上限设为 12 个,而且这还是因为 Expo 导出已经替我筛选过预加载清单。
如果你不在 Cloudflare 后面,或者你的 CDN 不支持 Early Hints,Link 头也能帮上一点忙:浏览器可以在响应头一到达、开始解析正文之前就开始获取这些资源。但除非你的服务器或 CDN 主动发送,否则不会有 103。
如何确认它在生效#
用 curl 可以直接看到 103。用 HTTP/2 请求,只保留状态行和 Link 头:
$ curl -sv --http2 -o /dev/null https://example.com/register 2>&1 | grep -E '^< (HTTP|link)'
< HTTP/2 103
< link: </_expo/static/css/reset-f5d396.css>; as=style; rel=preload, …
< HTTP/2 200
< link: </_expo/static/css/reset-f5d396.css>; rel=preload; as=style, …先到的是 103,然后才是 200。Cloudflare 会调整提示里属性的顺序,这没有影响。不要指望每个请求都能拿到 103,我最初的三次里有两次拿到了。改动之后的第一次访问只是把响应头存下来,而当最终响应的头部已经就绪时,Cloudflare 会跳过 103。
然后回到上面的分析查询,用 requestSource: "earlyHintsCache" 过滤,按状态码和路径分组。在你修好的页面上,命中应该会逐渐取代未命中。
改变了什么,还剩下什么#
响应头上线几分钟后,这个 zone 第一次记录到了 Early Hints 命中,curl 也在网页上看到了真正的 103 响应。一个响应头同时解决了两个问题:功能开始生效,一部分"504"也随之消失。
剩下的问题是,JSON API 没有什么可预加载的。API 端点会继续未命中,继续写下 504 行;在以 API 为主的 zone 上,这些仍然占大多数。你有三个合理的选择。在有收益的页面上发送响应头,接受 API 路径继续记录未命中;功能生效,图表会安静下来,但不会完全安静。无论你做什么决定,都把 earlyHintsCache 从 5xx 告警和仪表盘中排除,因为内部的缓存查询永远不应该把人叫醒。或者,如果你今后都不会发送 Link 头,就关闭 Early Hints(在控制台的 Speed → Settings → Content Optimization,或者通过 API 修改 zone 的 early_hints 设置);HTML 很小的单页应用可能本来就收益不大,关掉之后所有查询都会停止,噪音也会一起消失。
我选择保留它。现在网页能抢先一步,API 的未命中也已经从所有会触发告警的地方过滤掉了。这正是 9 月时我本该做的事,而不是把功能关掉。
