API 缓存与性能优化:CDN、查询复杂度分析与持久化查询

现代 API 性能优化全链路:HTTP 缓存策略、GraphQL CDN 边缘缓存、查询复杂度计算与限流、持久化查询(Persisted Queries)、DataLoader 缓存、响应压缩与 HTTP/2 多路复用。

性能不是 GraphQL 的加分项,而是它的必答题。REST 天然契合 HTTP 缓存语义——一个 URL 对应一份资源,CDN 可直接将缓存 key 与 URL 绑定。但 GraphQL 的单端点、POST 优先、字段级查询语义,让传统缓存模型几乎失效。当 POST /graphql 成为唯一入口,当同一资源的数十种查询变体在请求体中流转,CDN 的缓存命中率会断崖式下跌。

本文从 HTTP 缓存基础出发,逐步深入到 GraphQL 特有的缓存困境与工程解法:Automatic Persisted Queries(APQ)、查询复杂度分析、深度/节点数限制、DataLoader 多级缓存、响应压缩与协议级优化。所有方案均附有可直接落地的代码示例。

一、HTTP 缓存基础:Cache-Control、ETag 与 CDN 原理

HTTP/1.1 的缓存语义由 RFC 9111 定义,核心机制分为**过期缓存(Expiration)验证缓存(Validation)**两类。

1.1 Cache-Control 指令全景

指令类型说明
max-age=<秒>强缓存响应在 N 秒内被视为新鲜
s-maxage=<秒>强缓存仅对共享缓存(CDN)生效,优先级高于 max-age
no-cache验证每次使用前必须向源站验证
no-store禁用完全禁止任何缓存
private作用域仅浏览器可缓存,CDN 不可缓存
public作用域明确允许 CDN 缓存
immutable强缓存max-age 内绝不改变,无需验证
stale-while-revalidate=<秒>异步更新过期后 N 秒内仍返回旧缓存,后台异步回源
# 公开 API,CDN 缓存 5 分钟,过期后 1 分钟内仍可用旧数据异步回源
Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=60

# 用户私有数据
Cache-Control: private, max-age=60

# 不可缓存
Cache-Control: no-store

1.2 ETag 与 Last-Modified 验证

响应头请求回传匹配成功匹配失败
ETag: "abc123"If-None-Match: "abc123"304 Not Modified200 + 新响应
Last-Modified: ...If-Modified-Since304200 + 新响应

ETag 优先级更高。GraphQL 响应可基于查询哈希 + 数据版本号生成强 ETag:

import crypto from 'crypto';

function generateETag(queryHash, dataVersion) {
  return crypto.createHash('sha256')
    .update(`${queryHash}:${dataVersion}`)
    .digest('hex').slice(0, 16);
}

res.setHeader('ETag', `"${generateETag(queryHash, data.version)}"`);

1.3 CDN 边缘缓存原理与 GraphQL 困境

CDN 默认以 METHOD + URL + QueryString + Host + Accept-Encoding 计算 Cache Key,仅缓存 GET,且 POST 请求体不参与 key。这意味着 GraphQL 的默认 POST 模式天然不可缓存。当 POST /graphql 携带不同查询体时,CDN 看到的都是同一个 /graphql 端点,无法区分缓存。

将查询放入 URL Query String 可让 CDN 按 URL 缓存:

GET /graphql?query={user(id:1){name email}}&variables={}

但存在 URL 长度上限(8-16KB)、查询暴露于日志、Mutation 仍需 POST 等限制。这催生了 Persisted Queries 方案。


二、GraphQL 缓存挑战

POST 请求的语义隐藏在请求体中,CDN 无法根据 URL 区分查询。即使解析请求体参与 Cache Key,也存在:

  • 查询变体爆炸:不同字段组合的查询底层数据源相同,产生冗余缓存
  • 字段别名name: fullNamename: displayName 文本不同,key 不一致
  • 内省查询:大型内省查询可达数百 KB,每次发送严重消耗带宽

Apollo 提出的 APQ 方案在不修改 GraphQL 规范的前提下解决该问题:客户端首次发送完整查询 + sha256 hash,服务端持久化存储映射,后续请求仅发送 hash,hash 放入 URL 后使用 GET,CDN 可直接缓存。


三、Persisted Queries:从 APQ 到安全白名单

3.1 APQ 通信流程

客户端            服务端              CDN
  |  POST /graphql   |                |
  |  query + hash    |  存储映射       |
  |----------------->|                |
  |<-----------------|  返回数据       |
  |                  |                |
  |  GET /graphql?hash=...  ----------->|
  |                         | 命中?   |
  |<-------------------------| 直接返回 |

服务端配置(Apollo Server):

import { ApolloServer } from '@apollo/server';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';

const server = new ApolloServer({
  typeDefs, resolvers,
  persistedQueries: {
    cache: new InMemoryLRUCache({ maxSize: 1000000, ttl: 86400 }),
  },
});

客户端配置(Apollo Client):

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-js';

const link = createPersistedQueryLink({
  sha256: async (query) => sha256(query).toString(),
  useGETForHashedQueries: true, // hash 确认后转 GET,启用 CDN 缓存
}).concat(new HttpLink({ uri: '/graphql' }));

const client = new ApolloClient({ cache: new InMemoryCache(), link });

useGETForHashedQueries: true 是关键配置——hash 确认后所有请求走 GET,URL 仅含 hash 与 variables,CDN 可完整缓存。

3.2 安全白名单模式

APQ 允许客户端注册任意查询,生产环境存在安全风险。白名单模式仅允许预注册查询执行。

构建时提取:

npx apollo client:extract \
  --endpoint=http://localhost:4000/graphql \
  --includes="src/**/*.{ts,tsx}" \
  persisted-queries.json

服务端加载白名单:

import fs from 'fs';

const queryMap = new Map(
  Object.entries(JSON.parse(fs.readFileSync('./persisted-queries.json', 'utf-8')))
);

const server = new ApolloServer({
  typeDefs, resolvers,
  persistedQueries: {
    cache: {
      async get(hash) { return queryMap.get(hash); },
      async set() { /* 白名单模式不存储新查询 */ },
    },
  },
});

Strict 模式可完全拒绝含 query 字段的 POST,仅接受 hash-only 请求。

3.3 带宽与性能收益

指标无 APQAPQ(后续)提升
请求体大小1.2KB64B hash94% 减少
CDN 命中率0%85%+从不可缓存到可缓存
边缘响应时间120ms15ms87% 降低
源站带宽100%15%85% 降低

四、查询复杂度分析:执行前预估与超限拒绝

即使启用 APQ,服务端仍需在执行前评估计算开销。一个恶意深层查询可在毫秒级拖垮数据库。

4.1 自定义成本模型

为 Schema 字段和类型分配成本权重,递归计算总成本,超限即拒绝。

维度说明示例
字段基础成本每个字段默认成本name: 1
类型权重涉及数据库表的类型额外增加User: 5
列表乘数列表字段成本 × 预期返回数量posts: 3 × 10 = 30
深度系数嵌套深度越高,每层额外乘数深度 3 以上每层 ×1.5

配置(graphql-validation-complexity):

import { createComplexityLimitRule } from 'graphql-validation-complexity';
import { GraphQLError } from 'graphql';

const complexityRule = createComplexityLimitRule(1000, {
  onComplete: (c) => console.log(`Complexity: ${c}`),
  createError: (max, actual) => new GraphQLError(
    `查询复杂度 ${actual} 超过限制 ${max}`
  ),
});

const server = new ApolloServer({
  typeDefs, resolvers,
  validationRules: [complexityRule],
});

4.2 @complexity 指令

directive @complexity(value: Int!, multipliers: [String!]) on FIELD_DEFINITION

type Query {
  user(id: ID!): User @complexity(value: 5)
  users(first: Int = 10): [User!]! @complexity(value: 5, multipliers: ["first"])
  allUsers: [User!]! @complexity(value: 500)
}

type User {
  id: ID!
  name: String! @complexity(value: 1)
  posts(first: Int = 10): [Post!]! @complexity(value: 3, multipliers: ["first"])
}

type Post {
  id: ID!
  title: String! @complexity(value: 1)
  content: String! @complexity(value: 2)
  author: User! @complexity(value: 5)
}

4.3 复杂度计算示例

query {
  users(first: 20) {      # 5 × 20 = 100
    name                   # 1 × 20 = 20
    email                  # 1 × 20 = 20
    posts(first: 10) {     # 3 × 10 × 20 = 600
      title                # 1 × 200 = 200
      content              # 2 × 200 = 400
      author { name }      # 5 × 200 + 1 × 200 = 1200
    }
  }
}
# 总计:2540,超过 1000 限制,执行前被拒绝

五、深度查询防护:maxDepth、maxNodes 与多层防御

5.1 最大深度与最大节点数

import depthLimit from 'graphql-depth-limit';

function maxNodesRule(maxNodes) {
  return (context) => ({
    Document(node) {
      let count = 0;
      visit(node, {
        enter(n) {
          if (n.kind === 'Field') count++;
          if (count > maxNodes) {
            context.reportError(new GraphQLError(
              `查询节点数 ${count} 超过限制 ${maxNodes}`
            ));
          }
        },
      });
    },
  });
}

const server = new ApolloServer({
  typeDefs, resolvers,
  validationRules: [depthLimit(7), maxNodesRule(500)],
});

5.2 三层防护策略

防护层限制对象执行时机工具
最大深度嵌套层级验证阶段graphql-depth-limit
最大节点数总字段数验证阶段自定义 validation rule
复杂度上限计算成本验证阶段graphql-validation-complexity

三者递进式防御:深度最快(常量时间),节点数次之,复杂度最精确。任一失败即拒绝,resolver 零开销。


六、DataLoader 缓存:请求级缓存与 Redis 二级缓存

6.1 DataLoader 基础

DataLoader 通过批处理记忆化解决 N+1 问题:

import DataLoader from 'dataloader';

async function batchUsers(ids) {
  const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
  const map = new Map(rows.map((r) => [r.id, r]));
  return ids.map((id) => map.get(id) || null);
}

const userLoader = new DataLoader(batchUsers);

// Resolver 中使用
const resolvers = {
  Post: {
    author: (post, _, { userLoader }) => userLoader.load(post.authorId),
  },
};

6.2 请求级缓存 vs Redis 二级缓存

DataLoader 默认 memoization 是请求级的:单个 HTTP 请求内同一 ID 被缓存,请求结束后即丢弃,内存安全但无法跨请求共享。

引入 Redis 作为二级缓存:

import Redis from 'ioredis';
const redis = new Redis();

function createCachedLoader(batchFn, prefix) {
  return new DataLoader(async (ids) => {
    const keys = ids.map((id) => `${prefix}:${id}`);
    const cached = await redis.mget(keys);

    const missing = [];
    const results = ids.map((id, i) => {
      if (cached[i]) return JSON.parse(cached[i]);
      missing.push(id);
      return null;
    });

    if (missing.length > 0) {
      const fetched = await batchFn(missing);
      const pipe = redis.pipeline();
      fetched.forEach((item, i) => {
        if (item) pipe.setex(`${prefix}:${missing[i]}`, 300, JSON.stringify(item));
      });
      await pipe.exec();

      let j = 0;
      for (let i = 0; i < results.length; i++) {
        if (results[i] === null) results[i] = fetched[j++] || null;
      }
    }
    return results;
  });
}

6.3 缓存失效策略

策略实现适用场景
TTL 自动过期setex可容忍短暂不一致(如用户资料)
写穿透更新 DB 时同步更新 Redis强一致性要求
写后删除更新 DB 后删缓存,下次读取回填写少读多
消息队列失效变更后发布事件,订阅者清除缓存分布式系统

七、响应压缩:Brotli vs Gzip

7.1 算法对比与配置

算法压缩率编码速度解码速度浏览器支持
Gzip中等100%
Brotli高(比 Gzip 小 20-30%)较慢现代浏览器

Express 配置:

import compression from 'compression';
app.use(compression({ brotli: { quality: 4 }, level: 6 }));

7.2 流式压缩

import zlib from 'zlib';

app.get('/export/large-dataset', (req, res) => {
  const enc = req.headers['accept-encoding'] || '';
  res.setHeader('Content-Type', 'application/json');

  if (enc.includes('br')) {
    res.setHeader('Content-Encoding', 'br');
    const s = zlib.createBrotliCompress({
      params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 },
    });
    s.pipe(res);
    writeLargeJson(s);
  } else if (enc.includes('gzip')) {
    res.setHeader('Content-Encoding', 'gzip');
    const s = zlib.createGzip({ level: 6 });
    s.pipe(res);
    writeLargeJson(s);
  } else {
    writeLargeJson(res);
  }
});

7.3 对订阅的影响

GraphQL Subscription(WebSocket)消息按需压缩:小消息跳过避免 overhead,大消息(>1KB)启用。

const wsServer = new WebSocketServer({
  port: 4000,
  perMessageDeflate: {
    zlibDeflateOptions: { level: 3 },
    clientNoContextTakeover: true,
    serverNoContextTakeover: true,
  },
});

八、HTTP/2 与 gRPC 多路复用

HTTP/2 通过二进制分帧多路复用,在单一 TCP 连接上并行传输多个流。GraphQL 通常使用单端点,前端同页面发起多个查询,HTTP/2 在底层共享连接,消除 HTTP/1.1 队头阻塞。

Nginx HTTP/2 配置:

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location /graphql {
        proxy_pass http://localhost:4000;
        proxy_http_version 1.1;
    }
}

GraphQL 网关到后端服务可使用 gRPC 替代 HTTP/1.1 + JSON:单一长连接复用所有 RPC,Protobuf 二进制编码体积更小。

syntax = "proto3";

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
}

message BatchGetUsersRequest { repeated string ids = 1; }
message BatchGetUsersResponse { repeated User users = 1; }
message User { string id = 1; string name = 2; string email = 3; }

九、性能测试基准:K6 与 Locust

9.1 K6 压测脚本

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 100 },
    { duration: '2m', target: 200 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<200'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const payload = JSON.stringify({
    query: `query GetUser($id:ID!){user(id:$id){name email posts(first:5){title}}}`,
    variables: { id: String(Math.floor(Math.random() * 10000) + 1) },
  });

  const res = http.post('https://api.example.com/graphql', payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'no errors': (r) => !JSON.parse(r.body).errors,
    'response time < 200ms': (r) => r.timings.duration < 200,
  });

  sleep(1);
}

9.2 关键性能指标

指标说明健康阈值
P50 延迟50% 请求响应时间< 50ms
P95 延迟95% 请求响应时间< 200ms
P99 延迟99% 请求响应时间< 500ms
RPS系统最大吞吐横向扩展无上限
内存占用单进程常驻内存< 512MB
缓存命中率CDN / Redis 命中比率> 85%
错误率5xx / 超时 / 校验失败< 0.1%

十、一句话总结

GraphQL 性能优化是从客户端查询规范(APQ 白名单)、服务端执行防护(复杂度分析 + 深度限制)、数据层缓存(DataLoader + Redis)到传输层协议(HTTP/2 + 压缩)的全链路协作体系。

FAQ

Q1:APQ 与白名单 Persisted Queries 的区别?
APQ 自动化存储映射,适合快速迭代;白名单仅允许预注册查询,适合生产安全。

Q2:复杂度权重如何设定?
基于底层数据源调用成本反推。JOIN 多的类型权重更高,建议定期根据 EXPLAIN 执行计划调整。

Q3:Redis 缓存如何解决穿透与雪崩?
穿透:空值缓存(null,TTL 60 秒);雪崩:TTL 加随机偏移(TTL + rand * 60);击穿:热点 key 永不过期,后台异步更新。

Q4:Brotli 是否增加显著 CPU 开销?
压缩阶段比 Gzip 慢,但解码速度相当。动态 API 建议 quality: 4 平衡压缩率与延迟;静态资源预压缩则无运行时开销。

Q5:Subscription 的优化要点?
使用 Redis Pub/Sub 或 NATS 替代内存广播;WebSocket 大消息按需压缩;限制单连接并发订阅数(如 ≤100)。


相关阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「API 工程」更多文章

  1. tRPC 端到端类型安全 API:从路由定义到 Next.js 全栈集成
  2. GraphQL vs REST vs gRPC vs tRPC:API 范式深度对比与选型
  3. GraphQL Schema 演进与版本控制:零破化变更策略