跳至正文

缓存与安全

GraphQL 把”灵活查询”当优势,但这直接导致了两件 REST 时代不用担心的事:HTTP 缓存失效(所有请求走 POST /graphql 单端点)和客户端可以构造任意查询(需要服务端防护)。本文讲这两件事的通用解法。

为什么 HTTP 缓存失效

REST 的缓存建立在 HTTP 语义之上:

  • GET /api/user/1 → 方法 + URL 作为缓存键
  • 浏览器、CDN、反向代理都能基于 Cache-ControlETagLast-Modified 自动缓存

GraphQL:

  • 所有请求都是 POST /graphql
  • URL 完全相同,请求体(query 字符串 + variables)不同
  • HTTP 层面无法区分,CDN/浏览器缓存全失效

Note

结论

GraphQL 必须自己实现缓存。最常见方案是客户端规范化缓存(Apollo Client、Relay、urql 都内置)。

客户端缓存

规范化(Normalization)

把嵌套的响应数据拆成扁平的 id→entity 映射,类似前端版的数据库:

// 服务端响应
{
  user: {
    id: "1",
    name: "Alice",
    posts: [
      { id: "10", title: "Hello" },
      { id: "11", title: "World" },
    ],
  }
}

// Apollo Cache 存储形态(Apollo Client 3+,参数用 JSON 序列化的 storeFieldName)
{
  "User:1": { id: "1", name: "Alice", posts: [Ref("Post:10"), Ref("Post:11")] },
  "Post:10": { id: "10", title: "Hello" },
  "Post:11": { id: "11", title: "World" },
  "ROOT_QUERY": { "user({\"id\":\"1\"})": Ref("User:1") },
}

优势

  • 同一实体(User:1)在不同 Query 里出现,缓存只存一份
  • 任何一处更新(如 Mutation 改了 User:1.name),所有引用该实体的 Query 自动重渲染
  • 支持精细的缓存失效、乐观更新

Apollo Cache 的核心概念

概念说明
cache.writeQuery手动写入缓存,常用于乐观更新
cache.readQuery读取缓存,不会触发网络请求
cache.modify精确修改某个字段,触发关联 Query 重渲染
fetchPolicy请求策略:cache-first / network-only / cache-and-network
typePolicies定义实体的 keyFields、字段合并策略等

fetchPolicy

策略行为适用场景
cache-first(默认)先读缓存,命中则不请求列表页、详情页
cache-and-network同时读缓存(立即返回)和发请求(更新缓存)需要显示旧数据 + 后台刷新
network-only跳过缓存,总是请求表单提交后刷新
no-cache请求后不写缓存一次性数据
cache-only只读缓存,不请求离线场景

缓存更新策略

Mutation 后如何让列表页看到新数据:

策略做法特点
refetchQueriesMutation 后主动重发某些 Query简单,但多一次网络请求
update 函数手动写入/修改缓存零额外请求,但代码量大
optimisticResponse预先写入乐观数据,服务端响应后修正UI 立即更新,体验好,失败要回滚

Note

实践建议

  • 默认用 refetchQueries,简单可靠
  • 列表 + 详情都受影响时考虑 update 精细控制
  • 交互敏感(点赞、收藏)用 optimisticResponse 提升体感

服务端安全限制

GraphQL 客户端可以构造任意深度任意关联任意别名的查询。如果不加限制,攻击者可以发起一个递归查询把服务器拖垮:

query {
  user(id: "1") {
    friends {
      friends {
        friends {
          friends {
            # ... 继续嵌套 50 层
            name
          }
        }
      }
    }
  }
}

需要在服务端做多层防护。

深度限制

限制查询的嵌套层级(通常 5~10 层足够):

import depthLimit from "graphql-depth-limit";

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(10)],
});

超过深度直接拒绝,在校验阶段就中止,不进入执行。

复杂度限制

深度限制不够——宽而浅的查询也能把数据库打爆(如一次查 10000 个 user)。按字段打分累加:

import createComplexityRule, {
  simpleEstimator,
} from "graphql-query-complexity";

const server = new ApolloServer({
  schema,
  validationRules: [
    createComplexityRule({
      maximumComplexity: 1000,
      estimators: [simpleEstimator({ defaultComplexity: 1 })],
      onComplete: (complexity) => {
        console.log("Query complexity:", complexity);
      },
    }),
  ],
});

Note

要点

  • 给每个字段/类型打分,累加后超过阈值拒绝
  • 通过 @complexity 指令或字段配置给数组字段按 first/limit 参数估算
  • 需要在 Schema 里强制分页参数(禁止无上限的 posts: [Post!]!
  • graphql-validation-complexity(老项目常见)已停止维护,新项目用仍活跃的 graphql-query-complexity

持久化查询(Persisted Queries)

思路:客户端不再发完整 Query 字符串,而是发预注册的 Query ID / Hash

普通:          POST /graphql   { query: "query { user { name } }", variables: {...} }
持久化:        POST /graphql   { queryId: "a1b2c3...", variables: {...} }

好处

  • 只允许白名单内的 Query 执行(天然防止任意查询攻击)
  • 请求体变小,可走 CDN / GET 缓存
  • Apollo 有 Automatic Persisted Queries (APQ) 方案自动协商

其他必做

措施说明
认证与授权Resolver 或指令层校验 ctx.user / 角色
频率限制按 IP / 用户限制请求频率,防刷
Query 超时单个请求执行超过 N 秒强制中止
禁用 Introspection(生产)关闭 Schema 反射,避免攻击者探测 API
错误信息最小化(生产)不把数据库错误栈直接返回客户端

部分成功

GraphQL 的响应格式允许部分字段成功、部分字段失败

{
  "data": { "user": { "name": "Alice", "email": null } },
  "errors": [{ "message": "Permission denied", "path": ["user", "email"] }]
}

客户端需要同时检查 dataerrors,不能只看 HTTP 状态码(始终 200)。

Note

陷阱

  • 权限错误只返回 email: null + errors 条目,不会让整个 Query 失败
  • 客户端代码要有健壮的 “data 存在但某字段缺失” 处理路径
  • 这也是 GraphQL 错误模型比 tRPC 错误模型 复杂的原因

回到 GraphQL 总览