改了三周的 Schema 线上还是旧的:CDN 缓存键与结构化数据发布链路的架构设计
适用读者:一个人扛运维又扛搜索投放的工程师;站点前面压了 CDN,页面 HTML 里内联了 JSON-LD,改模板的频率比改业务代码还高。
注塑设备客户的官网在做灰度 A/B 实验,变量是产品页结构化数据(Structured Data)里要不要补上 hasVariant 变体节点。
模板改完,CI 全绿,源站 curl 拿到的 JSON-LD 已经是新的。
可线上抓下来的 HTML 里,还是实验开启之前的旧响应——三周,一动没动。
三周没变的那段 JSON-LD
架构很朴素:一台云主机跑 ASP.NET Core 8,前面一层 Nginx 做反向代理和页面缓存,再前面套了云厂商的内容分发网络(Content Delivery Network, CDN)。运维就一个人,发布靠一条 CI 流水线。实验开启那天改的是 Razor 模板里的一个局部视图,预发环境渲染正确,生产日志也打印了新版本号。

问题就出在这几个字上。他核对的是源站响应,命令里带 Host 头直连 127.0.0.1,压根没经过 Nginx 和 CDN。线上真实流量走的是完整链路,命中了三周前缓存下来的那份 HTML。源站变了不等于线上变了,中间隔着一整套缓存系统,它对「什么算同一个请求」有自己的判断标准。
这类问题在生成式引擎优化(Generative Engine Optimization, GEO)里尤其扎手。AI 搜索的抓取器拿到的就是那份缓存 HTML,读到的 JSON-LD 是旧结构,你在服务端改得再勤快,喂给模型的还是三周前的字段。JSON-LD 内联在 HTML 里,不像 /schema.json 那样有独立 URL,改了内容但 URL 没变,缓存系统没有任何理由回源。
缓存有几层,每层认的键不一样
排查时先做的不是改配置,是把链路画清楚,然后逐层问一句:你的缓存键(Cache Key)是什么。客户的答案是三层各有各的算法,没有一层认识「实验分组」这个概念。
| 层级 | 位置 | 默认缓存键构成 | TTL 由谁决定 | 失效手段 |
|---|---|---|---|---|
| 云 CDN 边缘节点 | 厂商节点,全国多地 | Host + Path + Query(受控制台「过滤参数」开关影响) | 控制台规则优先,其次回源响应头 | 按 URL / 目录刷新(Purge) |
| Nginx proxy_cache | 客户云主机 | proxy_cache_key 显式定义,默认 $scheme$proxy_host$request_uri |
proxy_cache_valid 或上游 Cache-Control |
删缓存文件或 proxy_cache_purge |
| 源站输出缓存 | ASP.NET Core 8 进程内 | URL + 响应缓存中间件自定义策略 | [ResponseCache] 或 Cache-Control |
进程重启或显式失效接口 |
致命的是前两行的组合。云 CDN 那层勾了「忽略 URL 参数」以追求命中率,Nginx 的 proxy_cache_key 只放了 $host$uri,实验分组写在 Cookie 里。A 组和 B 组的访客在这两层看来是同一个请求,谁先来谁写缓存,后来的人全分到先来者那一版。实验开了三周,命中率报表漂亮,数据全是废的。
更隐蔽的一点是,Nginx 的 proxy_cache 对上游响应头里的 Vary 基本不认,只有 Vary: Accept-Encoding 会被处理成 gzip/非 gzip 两份副本,其余取值当作不存在。即使源站加了 Vary: X-Ab-Group,Nginx 也不会因此分裂缓存条目,它必须被显式写进 proxy_cache_key。
flowchart TD
U[访客浏览器] --> CDN[云 CDN 边缘节点]
CDN -->|未命中 回源| NG[Nginx proxy_cache]
NG -->|未命中 回源| APP[ASP.NET Core 8 源站]
APP --> TPL[Razor 模板 + JSON-LD 生成器]
TPL --> VER[写入 X-Schema-Version 响应头]
VER --> NG
NG --> CDN
CDN --> U
subgraph K[缓存键构成 逐层不同]
K1[CDN: Host + Path 忽略 query]
K2[Nginx: host + uri 无分组维度]
K3[源站: URL + 响应缓存策略]
end
subgraph P[实验分组的真实载体]
P1[Cookie ab_group]
P2[源站 Set-Cookie 种植]
end
P1 -.未被任何一层纳入键.-> K
原理剖析:命中判定、Vary 与「不可见变更」
这一节把现象拆到底。理解了这三层机制,后面的配置改动都是自然结论。
命中判定的顺序
缓存命中(Cache HIT)不是「是或否」的判断,而是一串按次序执行的前置检查,任何一步不过就转未命中(Cache MISS)回源:先算键查存储,再比对元信息——存活时间(Time To Live, TTL)有没有过期、Vary 点名的请求头跟缓存时记录的值是否一致、有没有 Cache-Control: no-cache 强制校验。
关键在于键的计算发生在最前面,键里没有的东西,后面所有检查都不会再看一眼。
Vary 是键的乘数,不是键本身
Vary 常被说成「按某请求头区分缓存」,这个说法容易误导。它不往主键里塞内容,而是声明这份响应额外依赖哪些请求头,因此缓存条目要附带一组「该请求头当时的取值」作为副键,请求来了先匹配主键,再用副键比对。
Vary: Accept-Encoding 生效,是因为 gzip 和 br 产生不同的字节流,不分开存就会把 gzip 响应发给只支持 br 的客户端。Vary: User-Agent 是反例,取值空间太大让缓存条目爆炸、命中率归零。所以 Vary 的取值必须是有限枚举:分组号、语言、设备大类这种。
分组写在 Cookie 里,而 Cookie 是请求头的一部分。理论上可以 Vary: Cookie,但 Cookie 里还塞着会话 ID 和埋点 ID,取值空间等于无穷大。正确做法不是 Vary: Cookie,而是把 Cookie 里的一个字段抽成变量,让这个变量进键。
为什么 Schema 更新对缓存是「不可见变更」
缓存系统判定内容有没有变,靠两样东西:URL 和新鲜度元数据(ETag、Last-Modified、Cache-Control: max-age)。它不看响应体,也不具备「我知道这个 HTML 里嵌了 JSON-LD,我解析一下」的能力。
于是结构化数据的更新天然落入盲区。你改的是 HTML 内部一段 <script type="application/ld+json">,URL 没变、文件名没变、没有版本号后缀,没有任何一个字节出现在缓存系统用来做判断的字段里。
所谓不可见变更,指的是变更没有映射到缓存系统可见的任何维度上。 前端构建产物不在盲区,内容变了文件名上的 hash 就变了;内联 JSON-LD 在盲区,它寄生在别人的 URL 上。
这也解释了为什么「等 TTL 过期」是个糟糕方案。那层 CDN 的 TTL 是 7 天且命中后续期,三周里只要有一次命中,旧响应就能一直活下去。
缓存键怎么改:三条可用路径
想让缓存系统看见「分组」这个维度,只有三条路:把分组写进 query string 并让 CDN 透传参数、用 Vary 声明一个自定义请求头、或者把 Cookie 里的分组字段抽成变量塞进键。第一条最省事,代价是 URL 变脏、条目数按分组数翻倍;第二条依赖请求方配合带头,抓取器不会理你;第三条只按分组数分裂条目,代价是要在 Nginx 里多做一次 map。
客户选了第三条。产品页 URL 要印在样本册和展会展板上,不能带 ?ab=A 这种尾巴;AI 搜索抓取器也不带自定义请求头,Vary: X-Ab-Group 对它们毫无意义。
# 环境:Nginx 1.24.0(Ubuntu 22.04 官方包,含 ngx_http_map / proxy 模块)
# 位置:http{} 块内,供各 server 的 location 复用
# 目标:把 Cookie 里的实验分组抽成变量,并纳入 proxy_cache_key
# 注意:map 只能写在 http{} 里,不能塞进 server 或 location
# 从 Cookie 中提取 ab_group 字段,只保留三个合法取值
# 没带分组 Cookie 的访客(含搜索引擎抓取器)统一归到 default
# 键的取值空间因此只有 3 个,不会把缓存条目打散
map $http_cookie $ab_group {
default "default";
"~*ab_group=A" "A";
"~*ab_group=B" "B";
}
# 缓存键:协议 + 主机 + 路径 + 实验分组
# $uri 已剔除 query string,需要的话用 $args 单独拼
# 分组放在末尾 便于人眼读日志
proxy_cache_key "$scheme://$host$uri$ab_group";
# 缓存路径与分区,keys_zone 给 256m 足够中小站点
proxy_cache_path /var/cache/nginx/pages levels=1:2 keys_zone=pages:256m
max_size=8g inactive=30m use_temp_path=off;
# 只缓存 GET / HEAD
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 301 302 10m;
server {
listen 443 ssl;
server_name example.com;
location / {
# 回源时透传分组,源站据此渲染不同 JSON-LD
proxy_set_header X-Ab-Group $ab_group;
# 让上游的 X-Schema-Version 原样透出,便于外层核对版本
proxy_pass_header X-Schema-Version;
# 暴露命中状态,排查时一眼看出是哪层返回的
add_header X-Cache-Status $upstream_cache_status always;
# 忽略上游 Cache-Control/Expires,统一由本层 TTL 说了算
# Set-Cookie 也必须忽略,否则带分组 Cookie 的响应会被拒绝缓存
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_cache pages;
proxy_pass http://origin_upstream;
}
}
map 的 default 分支值得一提。搜索引擎和 AI 搜索的抓取器不带分组 Cookie,会稳定落到 default 这一份缓存里,喂给模型的 JSON-LD 因此是确定的一版,不会随抽样漂移。
给结构化数据一个版本号
光改键解决不了「线上到底是哪一版」。你需要一个能一眼核对的标识,而不是让人去 diff 两段 JSON。做法是给渲染结果算一个稳定的哈希,塞进 X-Schema-Version。
# 环境:Python 3.11,仅用标准库(hashlib / json / re)
# 用途:给定渲染好的 JSON-LD 文本,算出稳定的短版本号
# 约定:键顺序无关、空白无关,只有结构或取值变化才会改变版本号
import hashlib
import json
import re
def schema_version(ld_json_text: str) -> str:
# 先按 JSON 解析再 dump,把键顺序和空白差异归一化掉
parsed = json.loads(ld_json_text)
# sort_keys 保证同一份结构在不同渲染顺序下哈希一致
normalized = json.dumps(parsed, sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
# 去掉时间戳、随机数这类每请求都变的噪声字段
normalized = re.sub(r'"(dateGenerated|requestId)":"[^"]*"', "", normalized)
# 取 12 位十六进制够用,冲突概率可忽略
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]
def render_page_ld(product: dict, with_variant: bool) -> str:
# 构造 Product 节点的骨架
node = {
"@context": "https://schema.org",
"@type": "Product",
"name": product["name"],
"sku": product["sku"],
"brand": {"@type": "Brand", "name": product["brand"]},
}
# 实验变量:是否输出 hasVariant 变体数组
if with_variant:
node["hasVariant"] = [
{"@type": "Product", "sku": v["sku"], "name": v["name"]}
for v in product.get("variants", [])
]
return json.dumps(node, ensure_ascii=False)
if __name__ == "__main__":
# 两组渲染结果分别算版本号,输出应当不同
demo = {"name": "注塑机 XJ-200", "sku": "XJ200", "brand": "示例品牌",
"variants": [{"sku": "XJ200-A", "name": "标准型"}]}
a = render_page_ld(demo, with_variant=False)
b = render_page_ld(demo, with_variant=True)
# 打印两版号,发布验收时拿去和线上响应头比对
print("A 组:", schema_version(a))
print("B 组:", schema_version(b))
服务端拿到版本号后写进响应头,ASP.NET Core 8 里就是中间件中给 Response.Headers 赋一行值。它是响应头,不需要进 Vary,Vary 描述的是请求维度,别搞混。有了它,判断线上是不是新版只需 curl -sI 看一行,还能接进监控。
发布流水线:Purge 与验收
改键、加版本号都只解决「以后别再犯」。已经缓存住的旧响应得主动清掉,清完还得验证。缓存清除(Purge)这一步很容易被漏掉,在没有 CI 的年代大家习惯了「等它自己过期」。
flowchart LR
A[提交 Schema 模板变更] --> B[CI 计算新版本号 写入构建产物]
B --> C[灰度发布到单台源站]
C --> D[直连源站取基准版本]
D --> E[按 URL 清单 Purge CDN]
E --> F[清 Nginx 缓存条目]
F --> G[抽样 curl 比对线上线下版本]
G -->|全部一致| H[全量放量 关闭旧分组]
G -->|存在不一致| I[回滚模板 触发告警]
I --> E
| 阶段 | 执行动作 | 成功判据 | 失败处置 |
|---|---|---|---|
| 构建 | 渲染模板样本,算 X-Schema-Version 写入产物元数据 |
版本号与上次构建不同 | 版本号未变说明模板没生效,阻断发布 |
| 灰度 | 发布到单台源站,直连取基准版本头 | 基准版本等于构建元数据 | 回滚该节点 |
| 预热 | 用代表性 URL 清单打一遍源站,填冷缓存 | 响应 200 且版本正确 | 记录失败 URL,跳过继续 |
| 刷新 | 调 CDN Purge 接口并清 Nginx 缓存条目 | 接口成功且清除条目数大于 0 | 重试两次,仍失败转人工 |
| 验收 | 抽样比对线上与源站的版本头 | 抽样一致率 100% | 不一致立即回滚并告警 |
| 放量 | 全量发布,旧实验分组 Cookie 过期 | 版本头分布收敛到单值 | 保留回滚镜像至少一天 |
流水线里最容易被砍掉的是「预热」。Purge 之后一段时间所有请求都会穿透到源站,QPS 不低的话这一波会把源站打满。预热就是在 Purge 前后用脚本按 URL 清单主动打一遍,让边缘节点先拿到新版本。预热和验收写成脚本塞进 CI,比写在 Wiki 里靠谱——写在 Wiki 里的步骤,第三次发布就会被跳过。
验收脚本:抽样比对版本号
下面这段跑在 CI 末尾。逻辑直白:拿一批 URL,分别打源站和打 CDN,比对 X-Schema-Version,不一致就退出非零码让流水线红掉。
#!/usr/bin/env bash
# 环境:bash 5.1 / curl 8.5.0(GitLab Runner 的 alpine 镜像)
# 用法:./verify_schema.sh urls.txt origin.example.com example.com
# 说明:抽样比对源站版本与线上版本,任何一条不一致就退出 1
set -euo pipefail
# 三个入参:URL 清单、源站域名、线上域名
URL_LIST="$1"
ORIGIN_HOST="$2"
EDGE_HOST="$3"
# 一致与不一致的计数
ok=0
bad=0
# 逐行读 URL 清单,跳过空行和 # 开头的注释行
while IFS= read -r path; do
[ -z "$path" ] && continue
case "$path" in \#*) continue ;; esac
# 直连源站取基准版本:--resolve 把域名指向源站 IP,绕过 CDN
origin_ver=$(curl -sS -o /dev/null -w '%header{X-Schema-Version}' \
--resolve "${ORIGIN_HOST}:443:127.0.0.1" \
"https://${ORIGIN_HOST}${path}" || echo "ERR")
# 打线上域名,走完整 CDN 链路
edge_ver=$(curl -sS -o /dev/null -w '%header{X-Schema-Version}' \
"https://${EDGE_HOST}${path}" || echo "ERR")
# 打印每条结果,流水线日志里直接可查
echo "path=${path} origin=${origin_ver} edge=${edge_ver}"
# 两边相等才计为通过,ERR 也算失败
if [ "$origin_ver" = "$edge_ver" ] && [ "$origin_ver" != "ERR" ]; then
ok=$((ok + 1))
else
bad=$((bad + 1))
fi
done < "$URL_LIST"
# 汇总输出
echo "一致 ${ok} 条,不一致 ${bad} 条"
# 只要有一条不一致就非零退出,让 CI 卡住
[ "$bad" -eq 0 ] || exit 1
--resolve 是关键,它让 curl 绕过 CDN 直取源站,拿到的版本号才是「应该有」的基准。少了它,你比的是 CDN 跟它自己,永远一致。另外 -w '%header{...}' 需要 curl 7.84 以上,老版本得退化成 -D - 再 grep。
一个人的运维,方案得扛得住偷懒
回头看,真正起作用的配置改动只有两行:proxy_cache_key 里加个分组变量,源站输出一个版本头。剩下的流水线、脚本、表格都是在补上「有人会真的去执行它」。把验收做成 CI 的红绿灯而不是发布清单里的一条待办,就是这个思路。客户现在的流水线跑完四分多钟,一分半耗在预热和验收上,没人抱怨过——三周的返工比这一分半贵太多。
在 GEO 和 AI 搜索的语境下,这类问题的权重还在上升。过去页面缓存错一版,影响的是用户看到的价格或库存;现在它同时决定了抓取器读到的结构化数据长什么样,喂给生成式引擎做答案组织。服务端改对了、缓存没改对,等于什么都没改。
参考与延伸
- Nginx
proxy_cache_key与缓存模块文档:https://nginx.org/en/docs/http/ngx_http_proxy_module.html - MDN
Vary响应头与缓存协商机制:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary - MDN
Cache-Control指令全集:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - Schema.org
Product与hasVariant定义:https://schema.org/Product
关键词:GEO、AI 搜索、CDN 缓存键、Cache-Control、Vary 头、JSON-LD 发布、缓存刷新