<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>HTTP 103 · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/http-103/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Wed, 04 Nov 2026 13:36:11 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/http-103/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cloudflare 504 错误其实不是故障：Early Hints 与 HTTP 103 详解</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>网站性能</category>
      <category>Preload</category>
      <category>可观测性</category>
      <category>调试</category>
      <description>Cloudflare 上记录了几千个 504 错误，却没有一个到达真实用户：它们全是 Early Hints 的缓存未命中。本文讲清 HTTP 103 的工作方式、如何分辨假 504 和真 504，以及解决问题的那一个响应头。</description>
      <content:encoded><![CDATA[<p>9 月我写过一篇文章，讲<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >健康检查全绿、Cloudflare 面板上却有 22% 的请求在失败</a>。后来发现那些 504 大多来自 Cloudflare 的 Early Hints 功能，而不是我的用户，于是我关掉了 Early Hints，就此作罢。</p>
<p>11 月我再打开同一个 zone，一天之内又多出了 2,600 个 504。Early Hints 又被打开了。这一次我想弄清楚这些 504 到底是什么，以及正确的做法是再关一次这个功能，还是让它真正发挥作用。</p>
<p>这个 zone 前面挂着一个 Fly.io 源站，它同时提供三样东西：一个 Django API、一个 Expo Web 应用，以及一个用 Hugo 搭的营销网站。请记住这个 API。它自己有一段真实的容量问题，而这正是区分这两类 504 如此重要的原因。</p>

<h2 class="relative group">简短的答案
    <div id="简短的答案" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ae%80%e7%9f%ad%e7%9a%84%e7%ad%94%e6%a1%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>这些 504 是 <strong>Cloudflare 的 Early Hints 缓存</strong>写下的，不是你的访客。在 Cloudflare 日志里，它们的 <code>RequestSource = earlyHintsCache</code>。</li>
<li>这里的 <strong>504 表示缓存未命中</strong>：Cloudflare 没有为这个 URL 存下任何提示。命中则记录为 2xx。</li>
<li>它们<strong>根本不会到达你的源站</strong>，<code>OriginResponseStatus</code> 为 <code>0</code>。</li>
<li>Early Hints 依赖的是 <strong><code>Link</code> HTTP 响应头</strong>，HTML 里的 <code>&lt;link rel=&quot;preload&quot;&gt;</code> 标签喂不了它。</li>
<li>把这个响应头发出去，在那些有资源可预加载的页面上，这些未命中就会变成命中，并开始返回真正的 <code>103 Early Hints</code> 响应。</li>
</ul>

<h2 class="relative group">如何分辨假的 Cloudflare 504 和真的 504
    <div id="如何分辨假的-cloudflare-504-和真的-504" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a6%82%e4%bd%95%e5%88%86%e8%be%a8%e5%81%87%e7%9a%84-cloudflare-504-%e5%92%8c%e7%9c%9f%e7%9a%84-504" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你用 Logpush 或 Instant Logs，先看两个字段。<code>RequestSource</code> 对真实访客的请求是 <code>eyeball</code>，对 Early Hints 查询则是 <code>earlyHintsCache</code>。<code>OriginResponseStatus</code> 为 <code>0</code> 时，说明源站根本没有被联系过。</p>
<p>在 GraphQL Analytics API 里，同样的区分就是 <code>requestSource</code> 维度。现在每次 5xx 飙升，我在相信它之前都会先跑这个查询：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>按 User-Agent 分组，结论也一样。9 月时，Early Hints 查询显示为 <code>bastion early hints</code> 和 <code>nginx-ssl early hints</code>；11 月则是 <code>fl2 early hints</code> 和 <code>nginx-ssl early hints</code>（FL2 是 Cloudflare 新一代的代理）。24 小时内，这个 zone 的 2,631 个 504 全部来自这些 User-Agent，没有一个来自浏览器、应用、curl 或搜索引擎爬虫。</p>
<p>访客真正看到的 504，来自 Cloudflare 之外，也就是你这一侧：挡在应用前面的代理或负载均衡器等不及放弃了。可能是 nginx，可能是云负载均衡器，也可能是等待休眠机器启动的平台代理。Cloudflare 自己的超时用的是别的状态码：<strong>522</strong> 表示连不上源站，<strong>524</strong> 表示连上了，但在 100 秒内（Enterprise 以下套餐的默认值）没有收到响应。</p>
<p>所以，如果你是因为 504 想调大 Cloudflare 的超时时间而搜到这里：504 背后的那个超时几乎从来都不是 Cloudflare 的。去检查 nginx 的 <code>proxy_read_timeout</code>、负载均衡器的空闲超时，或者平台唤醒机器需要多久。有个很有用的线索：如果请求总在远低于 100 秒的整数时间点失败，比如 30 秒或 60 秒，那是你自己代理的上限，不是 Cloudflare 的。（只有 Enterprise 套餐能把 Cloudflare 的 100 秒上限提高到最多 6,000 秒，而 Cloudflare 自己也把这当作权宜之计。）</p>

<h2 class="relative group">同一周，API 出现了真正的 504
    <div id="同一周api-出现了真正的-504" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%90%8c%e4%b8%80%e5%91%a8api-%e5%87%ba%e7%8e%b0%e4%ba%86%e7%9c%9f%e6%ad%a3%e7%9a%84-504" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这就是区分它们的意义所在。几天前，正是这个 API 真的垮了。它跑在 Fly.io 的 <code>shared-cpu-1x</code> 机器上，这类机器靠突发积分（burst credit）运行：在新西兰的高峰流量下，突发积分用完后就被限流到每秒大约 20 到 30 个请求的持续上限。响应时间崩溃，访客在边缘收到了真正的 504。换成 <code>shared-cpu-2x</code> 之后问题解决了。</p>
<p>然后 504 图表又被填满了，这次是 Early Hints 的未命中。如果不按 <code>requestSource</code> 拆开看，这张图会告诉你：换了更大的机器也没用。实际上，同一周、同一个 zone 上有两批完全不同的 504，而只有其中一批真正落到了人身上。不要根据一张没有按 request source 拆分的 5xx 图表来做容量决策。</p>

<h2 class="relative group">这些 504 到底是什么
    <div id="这些-504-到底是什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%bf%99%e4%ba%9b-504-%e5%88%b0%e5%ba%95%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>在开启了 Early Hints 的 zone 上，每个请求都会附带一次内部查询：Cloudflare 查看自己的缓存里有没有可以发送的提示。这次查询会单独记成一行，<code>RequestSource = earlyHintsCache</code>。它不是终端用户的请求，也永远不会被发往你的源站。</p>
<p>那一行的状态码是缓存查询的结果，而不是错误：</p>
<ul>
<li><strong>2xx</strong> 是命中。Cloudflare 存有这个 URL 的 <code>Link</code> 头，并把它作为 <code>103</code> 发给了浏览器。在我的 zone 上，命中同时以 <code>200</code> 和 <code>204</code> 出现。</li>
<li><strong>504</strong> 是未命中。没有存下任何东西，所以没有什么可以提前发送。</li>
</ul>
<p>如果源站从不发送 Early Hints 所依赖的那个响应头，每次查询都会未命中，每个请求都会写下一行 504。在我做任何改动之前的三个小时里，这个 zone 记录了 173 次 Early Hints 查询，173 次全部未命中。自从这个功能打开以来，它一次提示都没有发出过。</p>
<p>再更正一下我 9 月那篇文章：我当时把它们描述为&quot;从 Fly 源站得不到响应&quot;的探测请求。这并不准确。它们根本不会去源站。这个 504 记录的是缓存未命中，而不是超时。</p>

<h2 class="relative group">HTTP 103 是错误吗？
    <div id="http-103-是错误吗" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#http-103-%e6%98%af%e9%94%99%e8%af%af%e5%90%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不是。1xx 范围的状态码属于信息性响应，是服务器在正式响应之前可以发送的临时响应。RFC 8297 定义的 <code>103 Early Hints</code> 携带 <code>Link</code> 头，让浏览器在服务器还在生成页面的时候，就开始获取关键的 CSS、JavaScript 和字体，或者提前与其他源建立连接。最终的 <code>200</code> 随后在同一个请求里到达。</p>
<p>各浏览器的支持程度不一，但很安全。基于 Chromium 的浏览器（Chrome 103 起，以及 Edge）和较新的 Firefox 同时支持 <code>preload</code> 和 <code>preconnect</code> 提示；Safari 只使用 <code>preconnect</code>。浏览器只在 HTTP/2 和 HTTP/3 上处理 <code>103</code>。不认识它的浏览器只会像往常一样等 <code>200</code>，所以 <code>103</code> 对大多数访客是抢先一步，对其余访客则毫无影响。</p>

<h2 class="relative group">我的为什么是空的：Link 响应头与 <code>&lt;link rel=preload&gt;</code>
    <div id="我的为什么是空的link-响应头与-link-relpreload" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e7%9a%84%e4%b8%ba%e4%bb%80%e4%b9%88%e6%98%af%e7%a9%ba%e7%9a%84link-%e5%93%8d%e5%ba%94%e5%a4%b4%e4%b8%8e-link-relpreload" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是我花时间最多的地方，因为它看起来本该已经生效了。</p>
<p>Early Hints 只缓存一样东西：带有 <code>rel=preload</code> 或 <code>rel=preconnect</code> 关系的 <strong><code>Link</code> HTTP 响应头</strong>。Cloudflare 从你平常的响应中（HTML 或无扩展名 URL 上的 <code>200</code>、<code>301</code> 或 <code>302</code>）读取它、存起来，然后在完整响应准备好之前，把它作为 <code>103</code> 重放给下一个访客。</p>
<p>我的页面其实已经有预加载了，只不过是写在 HTML <code>&lt;head&gt;</code> 里的标签：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>这些标签仍然有用，浏览器会按它们行事。但浏览器只有在收到 HTML 并开始解析之后才能看到它们，而这恰恰是 Early Hints 想要抢在前面的那一刻。Expo 导出和 Hugo 都会自动写这些标签，却都不设置这个响应头。于是功能开着，预加载也声明了，两者却从未碰面。</p>

<h2 class="relative group">解决办法：用你手上已有的数据发出这个响应头
    <div id="解决办法用你手上已有的数据发出这个响应头" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a7%a3%e5%86%b3%e5%8a%9e%e6%b3%95%e7%94%a8%e4%bd%a0%e6%89%8b%e4%b8%8a%e5%b7%b2%e6%9c%89%e7%9a%84%e6%95%b0%e6%8d%ae%e5%8f%91%e5%87%ba%e8%bf%99%e4%b8%aa%e5%93%8d%e5%ba%94%e5%a4%b4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最干净的办法，不是手工维护一份关键资源清单，每次打包哈希一变它就过时。每个页面已经用那些标签声明了自己想预加载什么。所以在返回页面时，把它们照抄进 <code>Link</code> 响应头就行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PRELOAD</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">rb</span><span class="s2">&#34;&#34;&#34;&lt;link\b[^&gt;]*?\brel=[&#34;&#39;]?preload[&#34;&#39;]?[^&gt;]*?&gt;&#34;&#34;&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">name</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A real attribute: preceded by whitespace, followed by =, space, / or &gt;.</span>
</span></span><span class="line"><span class="cl">    <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">search</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="sa">rb</span><span class="s2">&#34;(?&lt;=\s)&#34;</span> <span class="o">+</span> <span class="n">re</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">+</span> <span class="sa">rb</span><span class="s2">&#34;&#34;&#34;(?:\s*=\s*(?:&#34;([^&#34;]*)&#34;|&#39;([^&#39;]*)&#39;|([^\s&gt;]+)))?(?=[\s/&gt;])&#34;&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">tag</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">next</span><span class="p">((</span><span class="n">g</span> <span class="k">for</span> <span class="n">g</span> <span class="ow">in</span> <span class="n">m</span><span class="o">.</span><span class="n">groups</span><span class="p">()</span> <span class="k">if</span> <span class="n">g</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">),</span> <span class="sa">b</span><span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parts</span><span class="p">,</span> <span class="n">seen</span> <span class="o">=</span> <span class="p">[],</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">tag</span> <span class="ow">in</span> <span class="n">PRELOAD</span><span class="o">.</span><span class="n">findall</span><span class="p">(</span><span class="n">html</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">href</span> <span class="o">=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;href&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">href</span> <span class="ow">or</span> <span class="n">href</span> <span class="ow">in</span> <span class="n">seen</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">seg</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">href</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">as_</span> <span class="o">:=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">as_</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">as_</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;crossorigin&#34;</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">parts</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">seg</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">==</span> <span class="n">limit</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>然后在 Django 的 HTML 响应上加上它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>有两个细节坑过我，值得保留：</p>
<ul>
<li><strong>给属性解析器加上边界。</strong> 天真地搜索 <code>as</code>，会先匹配到 <code>href=&quot;/assets/...&quot;</code> 里的 &ldquo;as&rdquo;，于是字体预加载悄无声息地丢掉了 <code>as=font</code>。要求属性名以空白开头、以 <code>=</code>、空格、<code>/</code> 或 <code>&gt;</code> 结尾。还要同时处理带引号和不带引号的属性，因为 Hugo 这类静态站点生成器会输出 <code>href=https://jared.lynskey.co.nz/fonts/x.woff2 as=font crossorigin</code> 这种不带引号的写法。这个问题是单元测试帮我抓到的。</li>
<li><strong>字体要保留 <code>crossorigin</code>。</strong> 没有它的字体预加载会被当成另一个请求，字体会被下载两次，页面不但没变快，反而变慢了。</li>
</ul>
<p>你不需要自己写 <code>103</code>，也不用在 ASGI、Granian 或 uvicorn 里添加任何临时响应的代码。Cloudflare 会根据你的响应头生成 <code>103</code>，你唯一要做的就是把响应头发出去。</p>

<h2 class="relative group">在其他技术栈上
    <div id="在其他技术栈上" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8%e5%85%b6%e4%bb%96%e6%8a%80%e6%9c%af%e6%a0%88%e4%b8%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>规则在哪里都一样：给 HTML 响应加上 <code>Link</code> 头，剩下的交给 Cloudflare。</p>
<ul>
<li>
<p><strong>nginx</strong>，在返回 HTML 的 location 里：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p><strong>Laravel</strong> 配合 Vite 时已经有专门的中间件 <code>AddLinkHeadersForPreloadedAssets</code>，它会把 Vite 的预加载转换成 <code>Link</code> 头。针对它报告过的两个问题，正好就是上面那两个坑：它的 <code>modulepreload</code> 提示曾经不带 <code>crossorigin</code> 发出，导致浏览器可能丢弃它们并把脚本下载两次；在大页面上，这个响应头还曾超过 Apache 约 8 KB 的头部上限。请在你使用的版本上把这两点都确认一下。</p>
</li>
<li>
<p><strong>其他任何环境</strong>：用一个 <code>Link</code> 头以逗号分隔，或者发多个 <code>Link</code> 头，两种方式都行。</p>
</li>
</ul>
<p>大家最容易过度的，是提示哪些资源。只提示渲染关键的 CSS、入口脚本和首屏用到的一两个字体，不要把每个 chunk 都塞进去。清单太长，浏览器就会先去取暂时用不到的东西，反而耽误了真正需要的。我把上限设为 12 个，而且这还是因为 Expo 导出已经替我筛选过预加载清单。</p>
<p>如果你不在 Cloudflare 后面，或者你的 CDN 不支持 Early Hints，<code>Link</code> 头也能帮上一点忙：浏览器可以在响应头一到达、开始解析正文之前就开始获取这些资源。但除非你的服务器或 CDN 主动发送，否则不会有 <code>103</code>。</p>

<h2 class="relative group">如何确认它在生效
    <div id="如何确认它在生效" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a6%82%e4%bd%95%e7%a1%ae%e8%ae%a4%e5%ae%83%e5%9c%a8%e7%94%9f%e6%95%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>用 <code>curl</code> 可以直接看到 <code>103</code>。用 HTTP/2 请求，只保留状态行和 <code>Link</code> 头：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p>先到的是 <code>103</code>，然后才是 <code>200</code>。Cloudflare 会调整提示里属性的顺序，这没有影响。不要指望每个请求都能拿到 <code>103</code>，我最初的三次里有两次拿到了。改动之后的第一次访问只是把响应头存下来，而当最终响应的头部已经就绪时，Cloudflare 会跳过 <code>103</code>。</p>
<p>然后回到上面的分析查询，用 <code>requestSource: &quot;earlyHintsCache&quot;</code> 过滤，按状态码和路径分组。在你修好的页面上，命中应该会逐渐取代未命中。</p>

<h2 class="relative group">改变了什么，还剩下什么
    <div id="改变了什么还剩下什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%94%b9%e5%8f%98%e4%ba%86%e4%bb%80%e4%b9%88%e8%bf%98%e5%89%a9%e4%b8%8b%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>响应头上线几分钟后，这个 zone 第一次记录到了 Early Hints 命中，<code>curl</code> 也在网页上看到了真正的 <code>103</code> 响应。一个响应头同时解决了两个问题：功能开始生效，一部分&quot;504&quot;也随之消失。</p>
<p>剩下的问题是，JSON API 没有什么可预加载的。API 端点会继续未命中，继续写下 <code>504</code> 行；在以 API 为主的 zone 上，这些仍然占大多数。你有三个合理的选择：</p>
<ol>
<li><strong>在有收益的页面上发送响应头</strong>，接受 API 路径继续记录未命中。功能生效，图表会安静下来，但不会完全安静。</li>
<li><strong>把 <code>earlyHintsCache</code> 从 5xx 告警和仪表盘中排除。</strong> 无论你做什么决定，这一步都要做。内部的缓存查询永远不应该把人叫醒。</li>
<li><strong>关闭 Early Hints</strong>（在控制台的 Speed → Optimization，或者通过 API 修改 zone 的 <code>early_hints</code> 设置），前提是你今后都不会发送 <code>Link</code> 头。HTML 很小的单页应用可能本来就收益不大，关掉之后所有查询都会停止，噪音也会一起消失。</li>
</ol>
<p>我选择保留它。现在网页能抢先一步，API 的未命中也已经从所有会触发告警的地方过滤掉了。这正是 9 月时我本该做的事，而不是把功能关掉。</p>

<h2 class="relative group">检查清单
    <div id="检查清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a3%80%e6%9f%a5%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ol>
<li>在把 Cloudflare 的 5xx 飙升称为故障之前，先按 <code>requestSource</code>（或 User-Agent）拆开来看。</li>
<li><code>earlyHintsCache</code> 的 504 是 <code>OriginResponseStatus = 0</code> 的缓存未命中。它们无害，也不会到达你的源站。</li>
<li>真正的 504 来自你的代理或负载均衡器。Cloudflare 自己的超时是 522（无法连接）和 524（100 秒无响应）。</li>
<li>Early Hints 需要 <code>Link</code> <strong>响应头</strong>。HTML 里的 preload 标签不算数。</li>
<li>只提示渲染关键的 CSS、脚本和字体，并且字体要保留 <code>crossorigin</code>。</li>
<li>用 <code>curl -sv --http2</code> 验证：你应该在 <code>200</code> 之前看到 <code>103</code>。</li>
<li>把 <code>earlyHintsCache</code> 排除在告警之外。</li>
<li>如果你今后都不会发送这个响应头，就关掉 Early Hints，别用日志噪音为它买单。</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
