GraphQL 边缘缓存与 CDN:POST 缓存、边缘执行与缓存键设计

GraphQL 边缘缓存与 CDN 深度:GET 化与持久化查询、缓存键设计、Stellate 边缘执行、CDN 缓存失效策略、私有数据与个性化规避,帮助读多写少的 GraphQL API 实现边缘层加速。

GraphQL 的 POST 请求和「查询即正文」的特性,让它成为传统 CDN 缓存最难啃的骨头——HTTP 缓存代理默认只缓存 GET,而 GraphQL 的主流传输是 POST。但读多写少的 API(商品详情、文章列表、用户主页)恰恰最需要边缘缓存。本文将讲清楚三件事:如何把 GraphQL 请求「GET 化」以获得缓存资格、如何设计缓存键(Cache Key)实现精准失效、以及 Stellate 等边缘执行方案的取舍,帮助你为 GraphQL API 构建完整的 CDN 加速体系。

一、为什么 GraphQL 难以被 CDN 缓存

1.1 POST 与查询正文的双重障碍

传统 CDN 缓存的是「URL → 响应」。浏览器与代理对 GET 请求按 URL 缓存,而 GraphQL 的典型交互是:

curl -X POST https://api.example.com/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"query { products { id title } }","variables":{}}'

两条不同的查询(哪怕只差一个字段)都 POST 到同一个 /graphql URL。CDN 面对 POST 请求默认直通源站,因为 POST 被假定为「有副作用」。即使 CDN 缓存了 POST,缓存键也只有一个——所有查询共享同一键,缓存命中率趋近于零。

1.2 查询正文的多样性

即使把 POST 转成 GET,查询字符串中的 query 参数长度动辄几百字节,URL 会超过代理的缓存键上限;更关键的是,即席查询(ad-hoc query)无穷多样,缓存键爆炸,命中率极低。边缘缓存 GraphQL 的本质矛盾是:缓存键需要「稳定且有限」,而 GraphQL 查询天然「自由且多样」。

二、GET 化与持久化查询:获得缓存资格

2.1 查询转 GET

GraphQL 规范允许通过 GET 请求执行查询(mutation 除外)。把查询放进 URL 查询参数,CDN 就能按 URL 缓存:

curl -G https://api.example.com/graphql \
  --data-urlencode 'query=query { products { id title } }'

但这对即席查询仍不友好——URL 过长、缓存键过多。因此 GET 化必须与持久化查询结合。

2.2 APQ(Automatic Persisted Queries)协议

APQ 的核心:客户端先发送 extensions.persistedQuery 带上查询文本哈希,服务端若有注册则直接执行,否则返回「哈希未注册」并让客户端回退发送完整查询。最终理想状态是客户端只发哈希:

{
  "operationName": "HomeFeed",
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "sha256-3b07f5c7d9a1e0f4..."
    }
  }
}

哈希稳定的前提是「客户端代码固定查询文本」,因此生产环境常配合查询白名单(Operation Registry):只允许已注册哈希的查询执行。Apollo 的 operationRegistry 与 Relay 的持久化查询均为此设计。

2.3 哈希作为缓存键

当客户端只发哈希、服务端(或边缘)按哈希查注册表时,缓存键就从「完整查询文本」收敛为「稳定哈希」:

GET /graphql?hash=sha256-3b07f5c7d9a1e0f4...

这个 URL 是稳定的、有限的、可缓存的。CDN 对同一哈希的重复请求直接命中缓存,命中率从趋近于零提升到接近百分之百。

三、缓存键设计:精准失效的前提

3.1 缓存键的组成

即使走 GET + 哈希,缓存键仍要区分「同一查询的不同结果」。典型缓存键由四部分构成:

维度说明示例
查询标识稳定哈希sha256-3b07...
查询参数非缓存变量?id=42&first=20
身份维度登录/个性化匿名 vs user-42
版本维度Schema/部署版本v2026-10
# 完整缓存键示例
# /graphql?hash=...&id=42&first=20&visitor=anonymous

3.2 缓存变量 vs 非缓存变量

并非所有变量都应进缓存键。安全变量(商品 id、分页游标)进入缓存键,命中率高且结果可复用;身份变量(用户 id、token)导致键爆炸与数据串号,应排除出缓存键、改为按身份维度分区。

query ProductDetail($sku: String!) {   # sku 进缓存键
  product(sku: $sku) { sku title price }
}

query MyCart {                         # 按 user id 分区,绝不进公共缓存
  cart { items { sku qty } }
}

判断标准:结果是否对任意请求者都相同?相同 → 公共缓存;随身份变化 → 私有缓存或绕过缓存。

3.3 Cache-Control 与缓存方向

服务端通过 Cache-Control 头声明可缓存性与有效期,这是 CDN 与浏览器共同的契约:

Cache-Control: public, max-age=300, s-maxage=60, stale-while-revalidate=86400
  • public:公共缓存可用(适合匿名商品详情)。
  • max-age=300:浏览器缓存 5 分钟。
  • s-maxage=60:CDN 边缘缓存 60 秒。
  • stale-while-revalidate=86400:边缘可在后台回源期间继续提供旧数据 1 天。

服务端可在 resolver 或网关层按查询内容动态设置该头。Apollo Router 的 cache-control 插件支持基于指令的自动设置:

type Product @cacheControl(maxAge: 60) {
  id: ID!
  title: String!
  price: Float @cacheControl(maxAge: 300)
}

四、Stellate:专为 GraphQL 构建的边缘缓存

4.1 Stellate 的架构

Stellate 是面向 GraphQL 的托管边缘缓存/CDN 服务,核心组件包括:

  • 边缘层:拦截 GraphQL 请求,按查询解析结果并缓存。
  • Schema 感知:通过 introspection 理解类型与字段,能对查询做字段级缓存与失效。
  • 后端加速:以「GET + 持久化」或自定义传输回源 GraphQL 服务。

部署形态:GraphQL 源站地址指向 Stellate,客户端请求 Stellate 边缘节点。

客户端 ──> Stellate 边缘 ──> 源站 GraphQL
              │
              └── 命中缓存直接返回

4.2 Stellate 的缓存键:一个 Schema 感知的 Trick

Stellate 不需要客户端配合 APQ——它通过「后端加速」机制:每次回源时保存「查询文本 → 响应」的映射,把响应按查询哈希(服务端计算)缓存。客户端发送任意查询,边缘节点先尝试本地查找「该查询的已缓存响应」,未命中才回源。

# 边缘节点内部逻辑
response = cache.get(hash(normalize(query, variables)))
if response is None:
    response = upstream.execute(query, variables)
    cache.set(hash(...), response, ttl=60)

这种「透明缓存」无需改造客户端,非常适合存量 GraphQL API。代价是缓存键基于完整查询文本,即席查询命中率低于「查询白名单 + 哈希」方案。

4.3 Stellate 的失效机制

Stellate 支持两种失效方式:

  1. 按实体失效:通过 mutation 或管理 API 指定「某类对象已变更」,边缘层在缓存中查找所有包含该类型字段的查询并剔除。
curl -X POST https://api.stellate.sh/v1/purge \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"operationName":"ProductDetail","variables":{"sku":"SKU-1"}}'
  1. TTL 兜底:设置合理的 maxAge,即使无显式 purge,缓存也会按时过期,避免脏数据长期存在。

五、边缘执行与 Edge Functions

5.1 在边缘执行 GraphQL 查询

CDN 厂商(Cloudflare Workers、Vercel Edge、Fastly Compute)提供了在边缘运行代码的能力。把 GraphQL 查询的「执行」搬到边缘节点,可以带来两重收益:减少到源站的网络跳数、在边缘直接命中本地缓存。

// Cloudflare Worker:把 GET 查询参数还原为 GraphQL POST 回源,并设置缓存头
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname !== '/graphql') {
      return env.ASSETS.fetch(request);
    }

    // 尝试边缘缓存
    const cache = caches.default;
    const cacheKey = new Request(url.toString(), request);
    const cached = await cache.match(cacheKey);
    if (cached) return cached;

    // 回源执行
    const query = url.searchParams.get('query');
    const upstream = await fetch(env.ORIGIN + '/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables: {} }),
    });

    const response = new Response(upstream.body, upstream);
    response.headers.set('Cache-Control', 'public, s-maxage=60');
    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

5.2 边缘执行的边界

边缘执行并非银弹,有三条边界必须清楚:

  1. 计算资源有限:边缘函数 CPU 时间与内存受限,重计算(复杂聚合、图遍历)应在源站完成。
  2. 连接受限:边缘到源站的连接复用不如源站内部,回源仍需一次网络往返。
  3. 一致性风险:边缘多区域独立缓存,失效需要全局广播或较短 TTL。

推荐形态:边缘做「缓存层 + 请求转发」,源站做「执行层」。边缘负责命中缓存与设置缓存头,真正的 resolver 执行仍留在源站。

5.3 GET/POST 混合路由

生产网关常采用「GET 读 / POST 写」的混合路由:

操作类型传输方式缓存策略
公共读查询GET + 哈希CDN 公共缓存
登录/个性化读POST + 身份头私有缓存或绕过
所有 mutationPOST永不缓存(Source)
# 网关/路由器规则示例
graphql:
  routing:
    read:   # GET 请求走 CDN 缓存
      methods: [GET]
      cache: true
    write:  # POST 且是 mutation,直通源站
      methods: [POST]
      cache: false

六、私有数据与个性化规避

6.1 永不缓存的数据

以下数据必须绕过公共 CDN 缓存,否则将发生严重的数据串号事故:

  • 用户私有数据(订单、地址、收藏、聊天记录)。
  • 基于 Cookie 或认证头个性化渲染的字段。
  • 带 Authorization 头的请求(除非明确分区)。

第一原则:凡结果依赖请求者身份的响应,一律不进公共缓存;需要时按「匿名 / user-id」分区缓存,且 TTL 极短。

6.2 缓存污染防御

  • Vary 头:对按 Accept-Language、Accept-Encoding 变化的响应使用 Vary。
  • 统一规范化:缓存键里剔除 token、traceId 等易变参数,只保留影响结果的变量。
  • Schema 版本隔离:Schema 变更可能导致缓存结构与旧响应不一致,缓存键带上 Schema/部署版本号,发布后自动失效。
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, s-maxage=60

6.3 一致性与失效的权衡

缓存总在「新鲜度」与「一致性」之间取舍。GraphQL 边缘缓存推荐:

  • 读多写少的数据(商品、文章)用 60~300s TTL,可容忍短暂脏读。
  • 强一致数据(库存、余额)不缓存,或 TTL 短至秒级并配合显式 purge。
  • 写操作后 立即 purge 受影响查询,降低脏读窗口。

七、度量与治理

7.1 关键指标

边缘缓存上线后,用四组指标评估收益:

指标含义目标
缓存命中率命中次数 / 总请求> 70%(读多场景)
回源率回源次数 / 总请求越低越好
P50/P95 延迟端到端时延命中比回源快 3~10 倍
脏读率失效后仍命中旧数据趋近于 0

命中率低通常意味着缓存键设计过宽(变量进键过多)或查询即席化(未走白名单/APQ)。

7.2 灰度与回滚

边缘缓存变更影响面大,务必灰度:

  1. 先对「公共读查询」子集启用,观察命中率与错误率。
  2. 通过 Cache-Control: no-store 逐查询关闭缓存,实现精准回滚。
  3. 记录 purge 事件与回源延迟,建立缓存健康仪表盘。

7.3 常见失败模式

  • 键膨胀:把 token 放进缓存键 → 命中率暴跌。
  • 串号:个性化数据进了公共缓存 → 用户 A 看到用户 B 数据。
  • 脏读:mutation 后未 purge → 用户看到过期数据。
  • 穿透:热点查询无缓存且 TTL 极短 → 回源压力不降反升。

八、一句话总结

GraphQL 边缘缓存的关键是把「自由查询」收敛为「稳定缓存键」——通过 GET 化 + 持久化查询哈希获得缓存资格,用「安全变量进键、身份变量分区、版本号隔离」设计缓存键,再配合 Cache-Control、边缘执行与显式 purge,读多写少的 GraphQL API 就能享受 CDN 级加速。

FAQ

Q1: GraphQL 的 POST 请求真的不能被 CDN 缓存吗?

A: 传统 CDN 默认只缓存 GET,且 POST 被假定有副作用。但规范允许查询(非 mutation)走 GET,配合持久化查询哈希即可缓存。部分 GraphQL 专用 CDN(Stellate、Cloudflare 定制 worker)也能缓存 POST——但缓存键需基于查询内容,且实现较复杂。生产上更推荐 GET + APQ。

Q2: 为什么即席查询(ad-hoc query)不适合边缘缓存?

A: 即席查询文本无穷多样,缓存键(完整查询文本)数量爆炸,命中率趋近于零,缓存只会白白占用存储。解决路径是引入查询白名单(Operation Registry):只有注册的固定查询可执行,缓存键收敛为稳定哈希。

Q3: 如何防止用户 A 的个性化数据被缓存给用户 B?

A: 三重防线:第一,结果依赖身份的响应设置 Cache-Control: private 或 no-store,不进公共缓存;第二,若必须缓存,按身份维度分区(匿名 / user-id),并缩短 TTL;第三,网关/边缘层对带 Authorization 的请求默认绕过公共缓存。

Q4: mutation 之后缓存如何失效?

A: 三种方式组合:显式 purge(按查询哈希或按对象类型清除相关缓存项)、TTL 兜底(设置合理 maxAge 避免脏数据长期存在)、以及 stale-while-revalidate 机制(失效后在后台回源更新,用户无感知)。写后立即 purge + 秒级 TTL 是强一致场景的标准组合。

Q5: 边缘执行(Edge Functions)适合在边缘做 GraphQL 的什么工作?

A: 边缘适合做缓存命中、请求转发、鉴权前置、设置缓存头;不适合做重型计算(复杂聚合、深图遍历)与长连接管理。推荐形态是「边缘缓存 + 源站执行」,把 resolver 执行保留在源站,避免边缘资源瓶颈与多区域一致性问题。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. GraphQL 限流与成本控制:查询成本分析、复杂度限制与按量计费
  3. 移动端 GraphQL:Apollo iOS/Android、离线持久化与弱网优化