9 月我写过一篇文章,讲健康检查全绿、Cloudflare 面板上却有 22% 的请求在失败。后来发现那些 504 大多来自 Cloudflare 的 Early Hints 功能,而不是我的用户,于是我关掉了 Early Hints,就此作罢。
11 月我再打开同一个 zone,一天之内又多出了 2,600 个 504。Early Hints 又被打开了。这一次我想弄清楚这些 504 到底是什么,以及正确的做法是再关一次这个功能,还是让它真正发挥作用。
这个 zone 前面挂着一个 Fly.io 源站,它同时提供三样东西:一个 Django API、一个 Expo Web 应用,以及一个用 Hugo 搭的营销网站。请记住这个 API。它自己有一段真实的容量问题,而这正是区分这两类 504 如此重要的原因。
简短的答案#
- 这些 504 是 Cloudflare 的 Early Hints 缓存写下的,不是你的访客。在 Cloudflare 日志里,它们的
RequestSource = earlyHintsCache。 - 这里的 504 表示缓存未命中:Cloudflare 没有为这个 URL 存下任何提示。命中则记录为 2xx。
- 它们根本不会到达你的源站,
OriginResponseStatus为0。 - Early Hints 依赖的是
LinkHTTP 响应头,HTML 里的<link rel="preload">标签喂不了它。 - 把这个响应头发出去,在那些有资源可预加载的页面上,这些未命中就会变成命中,并开始返回真正的
103 Early Hints响应。
如何分辨假的 Cloudflare 504 和真的 504#
如果你用 Logpush 或 Instant Logs,先看两个字段。RequestSource 对真实访客的请求是 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 表示连上了,但在 100 秒内(Enterprise 以下套餐的默认值)没有收到响应。
所以,如果你是因为 504 想调大 Cloudflare 的超时时间而搜到这里:504 背后的那个超时几乎从来都不是 Cloudflare 的。去检查 nginx 的 proxy_read_timeout、负载均衡器的空闲超时,或者平台唤醒机器需要多久。有个很有用的线索:如果请求总在远低于 100 秒的整数时间点失败,比如 30 秒或 60 秒,那是你自己代理的上限,不是 Cloudflare 的。(只有 Enterprise 套餐能把 Cloudflare 的 100 秒上限提高到最多 6,000 秒,而 Cloudflare 自己也把这当作权宜之计。)
同一周,API 出现了真正的 504#
这就是区分它们的意义所在。几天前,正是这个 API 真的垮了。它跑在 Fly.io 的 shared-cpu-1x 机器上,这类机器靠突发积分(burst credit)运行:在新西兰的高峰流量下,突发积分用完后就被限流到每秒大约 20 到 30 个请求的持续上限。响应时间崩溃,访客在边缘收到了真正的 504。换成 shared-cpu-2x 之后问题解决了。
然后 504 图表又被填满了,这次是 Early Hints 的未命中。如果不按 requestSource 拆开看,这张图会告诉你:换了更大的机器也没用。实际上,同一周、同一个 zone 上有两批完全不同的 504,而只有其中一批真正落到了人身上。不要根据一张没有按 request source 拆分的 5xx 图表来做容量决策。
这些 504 到底是什么#
在开启了 Early Hints 的 zone 上,每个请求都会附带一次内部查询:Cloudflare 查看自己的缓存里有没有可以发送的提示。这次查询会单独记成一行,RequestSource = earlyHintsCache。它不是终端用户的请求,也永远不会被发往你的源站。
那一行的状态码是缓存查询的结果,而不是错误:
- 2xx 是命中。Cloudflare 存有这个 URL 的
Link头,并把它作为103发给了浏览器。在我的 zone 上,命中同时以200和204出现。 - 504 是未命中。没有存下任何东西,所以没有什么可以提前发送。
如果源站从不发送 Early Hints 所依赖的那个响应头,每次查询都会未命中,每个请求都会写下一行 504。在我做任何改动之前的三个小时里,这个 zone 记录了 173 次 Early Hints 查询,173 次全部未命中。自从这个功能打开以来,它一次提示都没有发出过。
再更正一下我 9 月那篇文章:我当时把它们描述为"从 Fly 源站得不到响应"的探测请求。这并不准确。它们根本不会去源站。这个 504 记录的是缓存未命中,而不是超时。
HTTP 103 是错误吗?#
不是。1xx 范围的状态码属于信息性响应,是服务器在正式响应之前可以发送的临时响应。RFC 8297 定义的 103 Early Hints 携带 Link 头,让浏览器在服务器还在生成页面的时候,就开始获取关键的 CSS、JavaScript 和字体,或者提前与其他源建立连接。最终的 200 随后在同一个请求里到达。
各浏览器的支持程度不一,但很安全。基于 Chromium 的浏览器(Chrome 103 起,以及 Edge)和较新的 Firefox 同时支持 preload 和 preconnect 提示;Safari 只使用 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 响应头就行:
import re
PRELOAD = re.compile(rb"""<link\b[^>]*?\brel=["']?preload["']?[^>]*?>""", re.I)
def attr(tag: bytes, name: bytes) -> bytes | None:
# A real attribute: preceded by whitespace, followed by =, space, / or >.
m = re.search(
rb"(?<=\s)" + re.escape(name)
+ rb"""(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?(?=[\s/>])""",
tag,
re.I,
)
if not m:
return None
return next((g for g in m.groups() if g is not None), b"")
def link_header(html: bytes, limit: int = 12) -> str | None:
parts, seen = [], set()
for tag in PRELOAD.findall(html):
href = attr(tag, b"href")
if not href or href in seen:
continue
seen.add(href)
seg = f"<{href.decode()}>; rel=preload"
if as_ := attr(tag, b"as"):
seg += f"; as={as_.decode()}"
if as_ == b"font" or attr(tag, b"crossorigin") is not None:
seg += "; crossorigin"
parts.append(seg)
if len(parts) == limit:
break
return ", ".join(parts) or None然后在 Django 的 HTML 响应上加上它:
response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
response["Link"] = link有两个细节坑过我,值得保留:
- 给属性解析器加上边界。 天真地搜索
as,会先匹配到href="/assets/..."里的 “as”,于是字体预加载悄无声息地丢掉了as=font。要求属性名以空白开头、以=、空格、/或>结尾。还要同时处理带引号和不带引号的属性,因为 Hugo 这类静态站点生成器会输出href=/fonts/x.woff2 as=font crossorigin这种不带引号的写法。这个问题是单元测试帮我抓到的。 - 字体要保留
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 告警和仪表盘中排除。 无论你做什么决定,这一步都要做。内部的缓存查询永远不应该把人叫醒。 - 关闭 Early Hints(在控制台的 Speed → Optimization,或者通过 API 修改 zone 的
early_hints设置),前提是你今后都不会发送Link头。HTML 很小的单页应用可能本来就收益不大,关掉之后所有查询都会停止,噪音也会一起消失。
我选择保留它。现在网页能抢先一步,API 的未命中也已经从所有会触发告警的地方过滤掉了。这正是 9 月时我本该做的事,而不是把功能关掉。
检查清单#
- 在把 Cloudflare 的 5xx 飙升称为故障之前,先按
requestSource(或 User-Agent)拆开来看。 earlyHintsCache的 504 是OriginResponseStatus = 0的缓存未命中。它们无害,也不会到达你的源站。- 真正的 504 来自你的代理或负载均衡器。Cloudflare 自己的超时是 522(无法连接)和 524(100 秒无响应)。
- Early Hints 需要
Link响应头。HTML 里的 preload 标签不算数。 - 只提示渲染关键的 CSS、脚本和字体,并且字体要保留
crossorigin。 - 用
curl -sv --http2验证:你应该在200之前看到103。 - 把
earlyHintsCache排除在告警之外。 - 如果你今后都不会发送这个响应头,就关掉 Early Hints,别用日志噪音为它买单。
