丹尼拉(Dayfing)
返回文章列表
4,212 字17 分钟

用 Nginx 和 Cloudflare 缓存静态网站:响应头与缓存清除

文件名带内容指纹的静态资源使用 Cache-Control: public, max-age=31536000, immutable;HTML 对浏览器发送 no-cache,同时为 Cloudflare 单独设置一个较短的边缘 TTL。用 Cache Rule 让 HTML 可被缓存,把 Browser Cache TTL 保持为 Respect Existing Headers,并在每次部署的最后一步清除 HTML 缓存。这样浏览器每次访问只需重新验证一个很小的文档,大多数验证由边缘节点直接应答,而清除请求一返回,新版本就立即可见。

两类文件,两种缓存策略

静态构建会产出两类文件。带指纹的文件,例如 /_astro/index.3f9c1a.js,在文件名中包含内容哈希。内容一变,URL 就变,因此旧 URL 的副本在任何缓存里存放一年都不会出问题。HTML、订阅源、站点地图和 robots.txt 的 URL 在各个版本之间保持不变,所以它们的任何缓存副本都可能过期。

因此,带哈希的文件获得最长的生命周期,永远不需要清除。URL 固定的文件则采用会重新验证的浏览器策略,以及可以清除的边缘策略。大部分速度收益来自第一类:回访用户只需下载一个很小的 HTML 文档,通常还是 304 响应,所有脚本、样式表和字体都直接从本地缓存复用。这也是为什么缓存是改善 Time to First Byte 和 Largest Contentful Paint 最省钱的手段之一,Core Web Vitals 指南对此有详细说明。

两条部署规则保证这套方案安全。先上传新的带哈希文件,再让 HTML 切换到新版本。只要引用旧文件的 HTML 还可能留在某个缓存中,就保留上一版本的带哈希文件,因为部署前打开的标签页仍会请求旧文件名。

浏览器 TTL 与边缘 TTL 是两只不同的时钟

浏览器缓存属于访客,响应发出之后,你做什么都无法删除其中的条目。边缘缓存属于 Cloudflare,清除 API 几秒内就能把它清空。所以,只有内容永不变化的 URL 才适合设置很长的浏览器 TTL。对于 HTML,no-cache 允许浏览器保存页面,但每次复用前都必须先发出条件请求。

边缘 TTL 可以来自 s-maxage,来自 Cloudflare-CDN-Cache-Control 或 CDN-Cache-Control,也可以来自 Cache Rule。由于它可以被清除,HTML 的边缘 TTL 只是安全网,而不是更新机制。如果清除失败,边缘上最坏情况下的过期时间等于新鲜期加上 stale-while-revalidate 窗口:对于 max-age=300, stale-while-revalidate=60,就是 360 秒。

先检查一个区域设置。所有套餐的 Browser Cache TTL 默认都是四小时,而 Cloudflare 会覆盖低于该值的源站有效期。这样一来,本应每次重新验证的 HTML 可能在浏览器里停留数小时,任何清除都够不到。请把 Browser Cache TTL 设为 Respect Existing Headers,具体见边缘与浏览器 TTL 文档。

Cloudflare 如何解读 s-maxage、stale-while-revalidate 和 stale-if-error

浏览器会忽略 s-maxage,所以 Cache-Control: public, max-age=0, s-maxage=300 看起来是 HTML 的理想响应头,Cloudflare 也确实把它当作边缘 TTL。问题出在 RFC 9111:s-maxage 同时带有 proxy-revalidate 的语义,也就是说共享缓存在重新验证之前不得提供过期响应。

在 Free、Pro 和 Business 套餐上,Origin Cache Control 始终开启,Cloudflare 会遵守这条规则。它的重新验证文档列出 s-maxage、must-revalidate、proxy-revalidate 和 no-cache 这几个会禁用过期副本服务的指令。它们与 stale-while-revalidate 同时出现时,UPDATING 会变成 EXPIRED,访客必须等待源站。同样的指令还会让 Cloudflare 忽略 stale-if-error。因此,像 max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 这样的响应头,只能得到 300 秒的边缘 TTL,别无其他。

当 stale-while-revalidate 生效时,重新验证是异步的:过期后的第一个请求会立即拿到带 cf-cache-status: UPDATING 的过期副本,Cloudflare 则在后台刷新。stale-if-error 只在源站返回 5xx 时起作用。开启 Always Online 会让这两个指令失效,而且 TTL 值必须是整数。

要在不使用 s-maxage 的前提下给浏览器和边缘设置不同策略,请使用定向响应头。Cloudflare 依次检查 Cloudflare-CDN-Cache-Control、CDN-Cache-Control 和 Cache-Control。只要存在 CDN 响应头,Cache-Control 就会原样到达浏览器,不影响边缘,而且 Cloudflare 不会向下游转发 Cloudflare-CDN-Cache-Control。HTML 的写法如下:

Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400

浏览器每次都会重新验证。Cloudflare 把页面缓存五分钟,刷新期间最多再提供一分钟的过期副本,如果源站出错,则在一天内继续提供最后一份正常副本。优先级规则见 CDN-Cache-Control 文档。

Cache Rules:遵循源站还是覆盖源站

默认情况下,Cloudflare 按文件扩展名判断是否缓存,并且不缓存 HTML 和 JSON。像 /writing/post/ 这样的 URL 没有扩展名,所以没有规则时,每个页面请求都是 DYNAMIC,直接回源。

设为 Eligible for cache 的 Cache Rule 提供三种 Edge TTL 模式。respect_origin 遵循你的响应头,缺少响应头时使用默认值,例如 200 响应缓存 120 分钟。bypass_by_default 同样遵循响应头,但缺少时不缓存。override_origin 忽略响应头并强制设定 TTL,其最小值取决于套餐:Free 为 2 小时,Pro 为 1 小时,Business 和 Enterprise 为 1 秒。Browser TTL 可以遵循源站、覆盖源站或跳过缓存。

如果源站发送的是经过设计的响应头,就遵循它们。下面这组用于 http_request_cache_settings 阶段的规则先让整个主机可被缓存,再排除动态路径。对于缓存设置,最后一条匹配的规则生效,所以绕过规则放在最后。

{
  "rules": [
    {
      "description": "Cache the site according to origin headers",
      "expression": "http.host eq \"example.com\"",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "respect_origin" },
        "browser_ttl": { "mode": "respect_origin" }
      }
    },
    {
      "description": "Never cache API routes and previews",
      "expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
      "action": "set_cache_settings",
      "action_parameters": { "cache": false }
    }
  ]
}

不要在缓存规则中加入 http.request.method eq "GET"。Cloudflare 提醒,如果规则只匹配 GET,按单个 URL 清除可能失效,因为清除请求在内部使用的是另一种方法。完整的选项列表见 Cache Rules 设置。

避开 add_header 继承陷阱的 Nginx 响应头

只有当前层级没有定义任何 add_header 时,Nginx 才会从上一层继承这些指令。一旦某个 location 添加了自己的 Cache-Control,server 层的所有响应头就会在这里悄无声息地消失,安全响应头也不例外。

server {
    add_header X-Content-Type-Options "nosniff" always;

    location ^~ /_astro/ {
        # X-Content-Type-Options is no longer sent for this location.
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

通用的解决办法是把公共响应头放进一个片段文件,在每个拥有自己 add_header 的块中用 include 引入。Nginx 1.29.3 及更高版本(包括 1.30 稳定分支)还提供 add_header_inherit merge,它会把继承来的响应头追加进来。使用 merge 时,不要在 server 块里放 Cache-Control,否则各个 location 会把它发送两次。

根据 headers 模块文档,不带 always 的 add_header 只作用于 200、201、204、206、301、302、303、304、307 和 308 响应。安全响应头和错误页上的 no-store 应该加 always,但长期缓存响应头绝不能加,否则资源名拼写错误导致的 404 会被标记为一年的 immutable。另外不要把 expires 和 add_header Cache-Control 混用,否则会产生两个响应头字段。下面是一个静态构建的 server 块,省略了 TLS 和日志配置:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /srv/example.com/current;
    index index.html;

    gzip on;
    gzip_vary on;
    gzip_static on;
    gzip_types text/css application/javascript application/json
               application/xml application/rss+xml image/svg+xml text/plain;

    location ^~ /_astro/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=86400";
        try_files $uri =404;
    }

    location ~* \.(?:xml|txt)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=300";
        add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri =404;
    }

    location / {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache";
        add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
    location = /404.html {
        internal;
        include snippets/security-headers.conf;
        add_header Cache-Control "no-store" always;
    }
}

gzip_static 需要编译时带上 --with-http_gzip_static_module,可以在 nginx -V 的输出中确认。源站应只接受来自 Cloudflare 的连接,这样没人能绕过缓存。相应的防火墙配置可参考 Linux VPS 加固指南。

ETag、Last-Modified 与重新验证

在默认的 etag on 下,Nginx 会为静态文件发送 ETag 和 Last-Modified,并对条件请求返回 304。它的 ETag 由文件修改时间和大小生成,而不是内容哈希。因此,一次重写全部文件的部署会改变所有 ETag,发布后的第一次重新验证会返回完整的 200。如果多台源站服务同一个版本,它们上面的文件时间必须一致,例如用 rsync -a 复制,否则重新验证的结果将取决于由哪台服务器应答。

只要自己的副本仍然新鲜,Cloudflare 就会直接应答浏览器的重新验证。过期后,它向 Nginx 发送条件请求,304 响应会在不传输正文的情况下续期 TTL。当 Cloudflare 改变编码时,会把强 ETag 降为弱 ETag W/"...",这并无害处,因为 If-None-Match 使用弱比较。只有当客户端确实需要逐字节一致的验证器时,才开启 Respect Strong ETags。

压缩:源站用 gzip,边缘用 Brotli 和 Zstandard

Cloudflare 向源站请求时发送 accept-encoding: br, gzip,并且可以对收到的内容重新编码。面向访客时,它根据 Accept-Encoding、套餐和 Compression Rules 提供 gzip、Brotli 或 Zstandard。默认情况下,Free 区域优先使用 Zstandard,Pro 和 Business 优先使用 Brotli,Enterprise 使用 gzip。只有 200、403 和 404 响应会被压缩,详见压缩文档。

对于位于 Cloudflare 之后的源站,gzip 就够了。可以在构建时预压缩文本文件供 gzip_static 使用,也可以让 gzip on 实时压缩。gzip_types 默认只包含 text/html,所以要列出其他文本类型,并保持 gzip_vary on。Brotli 和 Zstandard 不在 nginx.org 的模块集中,需要第三方模块。不要在文本响应上加 Cache-Control: no-transform,否则 Cloudflare 将不会压缩未压缩的响应。

Cloudflare 的默认缓存键包含协议、主机、路径和完整的查询字符串,以及 Origin 等少数请求头。Cookie 不在其中,所以被缓存的 URL 绝不能随 Cookie 而变化。登录后的页面、购物车、API 或智能体后端,应放在带 private 或 no-store 的绕过规则之后,或者放到单独的主机名上。生产环境 AI 智能体架构指南展示了这类动态请求路径通常包含哪些部分。

响应头 Set-Cookie 会破坏缓存。在 Eligible for cache 下,Cloudflare 会保留 Cookie 但不存储响应,于是每个请求都是 MISS。请确认没有任何模块或负载均衡器给静态文件加上 Cookie,或者用 Cache Response Rule 把它们去掉。

每个不同的查询字符串都会形成单独的缓存条目,所以带 ?utm_source= 的链接一开始都是未命中。这影响的是命中率,而不是正确性。Ignore Query String 缓存级别只对静态文件扩展名生效,而按查询参数定制缓存键的能力取决于套餐。Sort query string 在所有套餐上都可用。

清除策略与在 CI 中清除

自 2025 年 4 月起,所有套餐都能使用全部清除方式:

  • Single URL 精确删除指定 URL,每次请求最多 100 个(Enterprise 为 500 个),适合构建系统清楚知道哪些页面变化的情况。
  • Prefix 删除 example.com/writing/ 这类路径下的全部内容,包括带查询字符串的变体。
  • Tag 删除所有响应中带有匹配 Cache-Tag 响应头的对象,Cloudflare 会在访客看到之前去掉这个响应头。给 HTML、订阅源和站点地图打上 html 标签,一次调用就能清除所有固定 URL,而带哈希的资源继续留在缓存中。
  • Hostname 删除某个主机名下的全部内容。
  • Everything 清空整个区域,在缓存重新填满之前所有流量都会回源,因此只把它当作最后手段。

按 hostname、tag、prefix 清除以及 purge everything 共享账户级别的限额:Free 为每分钟 5 次请求,Pro 为每秒 5 次,Business 为每秒 10 次,Enterprise 为每秒 50 次,详见清除文档。请使用只拥有单个区域 Cache Purge 权限的 API 令牌,并把它保存为 CI 密钥:

#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"

# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"

# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"

# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags":["html"]}' </dev/null \
  | jq -e '.success == true' >/dev/null

规模更大的站点应把构建上传到独立的版本目录,再原子地切换符号链接。清除请求返回成功只表示请求已被接受,所以任务的最后一步应当是获取一个已修改的页面,检查其中的构建标记,例如提交哈希。

用 curl -I、cf-cache-status 和 Age 验证

curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
  | grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'

对于可缓存的请求,Cloudflare 会把 HEAD 转换为 GET,所以 curl -I 同样会填充缓存。第一次响应应显示 MISS,第二次应显示带 Age 的 HIT,Age 表示对象被缓存或重新验证以来经过的秒数。缓存响应文档定义了每种状态:

  • DYNAMIC:请求不符合缓存条件,通常是没有规则覆盖 HTML,或者开启了 Development Mode。
  • BYPASS:请求符合条件,但 no-store、private、Set-Cookie 或 Vary: * 使响应不可缓存。
  • UPDATING:后台重新验证期间提供了过期副本。
  • EXPIRED:过期副本被同步重新获取。如果你期望看到 UPDATING,请检查是否存在 s-maxage、must-revalidate 或 no-cache。
  • REVALIDATED:请求等待期间,源站以 304 确认了副本。
  • STALE:源站无法响应,于是提供了旧副本。

清除之后应看到 MISS;启用 Tiered Cache 时以及执行 purge everything 之后,也可能看到 EXPIRED。若只想测试 Nginx,可在允许访问源站的主机上执行 curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/,再加上 -H 'If-None-Match: "<etag>"',应得到 304。

按文件类型推荐的响应头

文件类型 示例 Cache-Control 边缘策略 部署之后
带指纹的 JS、CSS、字体和图片 /_astro/app.3f9c1a.js public, max-age=31536000, immutable 遵循源站 无需操作
HTML 页面 /writing/post/ no-cache max-age=300, stale-while-revalidate=60, stale-if-error=86400 清除 html 标签
订阅源、站点地图、robots.txt /rss.xml public, max-age=300 max-age=3600, stale-if-error=86400 清除 html 标签
不带哈希的图片和图标 /favicon.ico public, max-age=86400 遵循源站 清除该 URL 或改名
两次部署之间会变化的 JSON /data/stats.json public, max-age=60 遵循源站或绕过 清除该 URL
404 页面 任何不存在的 URL 带 always 的 no-store 不缓存 无需操作
API、预览、管理后台 /api/ private, no-store 绕过规则 无需操作

含 max-age 的边缘策略值写在 Cloudflare-CDN-Cache-Control 中。如果在边缘缓存 404 响应,请给它们打上 html 标签,否则之后发布的页面会一直被缓存的 404 挡住。

部署检查清单

  • 资源文件名包含内容哈希,URL 固定的文件都没有标记为 immutable。
  • 每个带 add_header 的 Nginx 块都引入了安全响应头,always 只用于安全响应头和错误响应。
  • HTML 同时发送 no-cache 和 Cloudflare-CDN-Cache-Control,stale-while-revalidate 旁边没有 s-maxage。
  • gzip_types 覆盖了文本类型,静态响应不带 Set-Cookie。
  • Browser Cache TTL 遵循现有响应头,绕过规则放在最后,且没有只匹配 GET 的条件。
  • CI 先上传资源再上传 HTML,保留旧的带哈希文件,清除 html 标签,并在 success 不为 true 时失败。
  • 页面依次显示 MISS、带 Age 的 HIT,以及边缘 TTL 到期后的 UPDATING。
  • 源站只接受来自 Cloudflare 的流量。

更多文章