改了三周的 Schema 线上还是旧的:CDN 缓存键与结构化数据发布链路的架构设计

2026-09-21 01:35:44 0 次浏览
GEOAI搜索CDN缓存JSON-LD.NET 8

适用读者:一个人扛运维又扛搜索投放的工程师;站点前面压了 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 模板里的一个局部视图,预发环境渲染正确,生产日志也打印了新版本号。 CDN 多级缓存中新旧文档交替

问题就出在这几个字上。他核对的是源站响应,命令里带 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 生效,是因为 gzipbr 产生不同的字节流,不分开存就会把 gzip 响应发给只支持 br 的客户端。Vary: User-Agent 是反例,取值空间太大让缓存条目爆炸、命中率归零。所以 Vary 的取值必须是有限枚举:分组号、语言、设备大类这种。

分组写在 Cookie 里,而 Cookie 是请求头的一部分。理论上可以 Vary: Cookie,但 Cookie 里还塞着会话 ID 和埋点 ID,取值空间等于无穷大。正确做法不是 Vary: Cookie,而是把 Cookie 里的一个字段抽成变量,让这个变量进键。

为什么 Schema 更新对缓存是「不可见变更」

缓存系统判定内容有没有变,靠两样东西:URL 和新鲜度元数据(ETag、Last-ModifiedCache-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;
    }
}

mapdefault 分支值得一提。搜索引擎和 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 赋一行值。它是响应头,不需要进 VaryVary 描述的是请求维度,别搞混。有了它,判断线上是不是新版只需 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 搜索的语境下,这类问题的权重还在上升。过去页面缓存错一版,影响的是用户看到的价格或库存;现在它同时决定了抓取器读到的结构化数据长什么样,喂给生成式引擎做答案组织。服务端改对了、缓存没改对,等于什么都没改。

参考与延伸

关键词:GEO、AI 搜索、CDN 缓存键、Cache-Control、Vary 头、JSON-LD 发布、缓存刷新

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。