压测与容量规划

GraphQL 压测与容量规划实战:从 REST 压测的差异出发,讲解 k6/Artillery/autocannon 脚本编写、查询组合与权重建模、延迟分位与饱和度指标、连接池与 DataLoader 瓶颈定位、容量估算公式、冒烟/负载/压力/浸泡/尖峰五类测试,以及接入 CI 的回归门禁。

给 REST 接口压测很直观:一个 URL、一组参数、固定响应体,ab 或 wrk 打几千个请求就能画出曲线。给 GraphQL 压测则完全不同——同一个端点 /graphql 可能承载几十种操作,有的只是取一个字段,有的嵌套五层并触发上百次数据库查询。如果用「一条固定查询打满 QPS」的方式压测,得到的容量数字在生产环境几乎必然失真:真实流量是混合的,而混合流量的成本远高于单条查询的简单平均。

本文的目标是把 GraphQL 压测从「跑个数字」变成「可解释的容量结论」。我们会讲清楚为什么 GraphQL 的负载模型必须按查询组合加权、如何用 k6 编写贴近真实流量的脚本、哪些指标能揭示瓶颈、以及如何把压测结果换算成可指导扩缩容的容量公式。最后给出一套可接入 CI 的回归门禁。

一、GraphQL 压测与 REST 压测的差异

1.1 五个本质差异

维度RESTGraphQL
端点多端点,各自成本不同单端点,成本由查询决定
请求体简单参数嵌套查询,形状差异巨大
成本模型近似常量随深度、宽度、别名数变化
缓存按 URL 缓存需按 operation + 变量缓存
失败模式单接口超时一个深查询拖垮整个事件循环

这些差异决定了三件事:不能只压一条查询、必须记录查询成本、必须关注尾延迟而非平均延迟。

1.2 成本不是线性的

考虑两个查询:

# 查询 A:成本约 1 个解析单位
query A { me { id nickname } }

# 查询 B:嵌套列表,成本可能上百个解析单位
query B {
  me {
    orders(first: 50) {
      edges { node { id total items(first: 20) { edges { node { sku price } } } } }
    }
  }
}

查询 B 的字段数远不止 A 的 50 倍——它会触发 DataLoader 批量取数、多层嵌套解析、大量 JSON 序列化。如果压测脚本里 90% 是查询 A、10% 是查询 B,平均响应时间看起来很好,但真实流量的比例可能反过来。负载模型失真,是 GraphQL 压测最常见的失败原因。

二、压测工具与脚本

2.1 k6:主流选择

k6 用 JavaScript 编写场景,原生支持阶段式负载、阈值断言、自定义指标,是 GraphQL 压测的首选。

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

const gqlLatency = new Trend('gql_latency', true);
const gqlErrors = new Counter('gql_errors');

const QUERIES = {
  light: {
    weight: 60,
    body: JSON.stringify({
      query: `query Light { me { id nickname } }`,
    }),
  },
  medium: {
    weight: 30,
    body: JSON.stringify({
      query: `query Medium($first: Int!) {
        me { orders(first: $first) { edges { node { id total status } } } }
      }`,
      variables: { first: 20 },
    }),
  },
  heavy: {
    weight: 10,
    body: JSON.stringify({
      query: `query Heavy($first: Int!) {
        me { orders(first: $first) { edges { node {
          id total items(first: 20) { edges { node { sku price } } }
        } } } }
      }`,
      variables: { first: 50 },
    }),
  },
};

export const options = {
  stages: [
    { duration: '1m', target: 50 },   // 爬坡
    { duration: '5m', target: 50 },   // 稳态
    { duration: '1m', target: 200 },  // 加压
    { duration: '3m', target: 200 },
    { duration: '1m', target: 0 },    // 收尾
  ],
  thresholds: {
    gql_latency: ['p(95)<300', 'p(99)<800'],
    gql_errors: ['count<10'],
  },
};

export default function () {
  const pick = pickWeighted(QUERIES);
  const res = http.post('https://api.example.com/graphql', pick.body, {
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${__ENV.TOKEN}`,
    },
  });
  gqlLatency.add(res.timings.duration);
  const body = res.json();
  if (body.errors) gqlErrors.add(1);
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(Math.random() * 0.5);
}

function pickWeighted(map) {
  const total = Object.values(map).reduce((s, q) => s + q.weight, 0);
  let r = Math.random() * total;
  for (const q of Object.values(map)) {
    if ((r -= q.weight) <= 0) return q;
  }
  return map.light;
}

注意三个关键点:按权重混合查询、用 Trend 记录 GraphQL 延迟(而非 HTTP 延迟,因为 GraphQL 错误可能返回 200)、用 Counter 统计 errors[]。

2.2 Artillery 与 autocannon

Artillery 用 YAML 描述场景,适合快速搭建与团队共享:

config:
  target: "https://api.example.com"
  phases:
    - duration: 60
      arrivalRate: 20
    - duration: 300
      arrivalRate: 50
  variables:
    query:
      - "query { me { id nickname } }"
      - "query { me { orders(first: 20) { edges { node { id total } } } } }"
scenarios:
  - name: "Mixed GraphQL"
    flow:
      - post:
          url: "/graphql"
          json:
            query: "{{ query }}"
          headers:
            authorization: "Bearer {{ token }}"

autocannon 更适合单查询极限压测(找单条查询的吞吐上限),它用 Node.js 写脚本、支持 pipelining:

autocannon -c 100 -d 30 -p 10 \
  -m POST \
  -H "content-type=application/json" \
  -H "authorization=Bearer $TOKEN" \
  -b '{"query":"query { me { id nickname } }"}' \
  https://api.example.com/graphql
工具优势适用
k6脚本灵活、指标丰富、可编程阈值混合负载、CI 门禁
ArtilleryYAML 易读、场景清晰团队协作、快速验证
autocannon极致吞吐、低开销单查询上限、基准对比
wrk/vegeta超低开销纯 HTTP 层压测(不解析 GraphQL 错误)

三、负载模型:查询组合与权重

3.1 从哪里拿到真实比例

真实查询比例有三个可靠来源,按可信度排序:

  1. APM / trace 数据:按 operation name 统计调用量占比,最准确;
  2. 服务端日志:记录每次请求的 operation name 与查询哈希;
  3. 持久化查询清单:如果启用了 APQ/白名单,清单本身就是流量分布的上界。

拿到比例后,还要拿到每个 operation 的平均与 P99 成本(字段数、下游调用数、耗时),两者结合才是完整的负载模型。

3.2 负载模型表

Operation流量占比平均字段数平均下游调用平均耗时
Light60%3112ms
Medium30%24385ms
Heavy10%18012420ms
Search5%405260ms
CreateOrder(mutation)2%104150ms

这个表可以直接驱动压测脚本的权重,也能用来估算「每 100 QPS 混合流量」对应的下游负载。mutation 必须纳入模型——写操作涉及事务与锁,其成本与读操作不可比,忽略它会导致容量高估。

3.3 变量与数据分布

除了查询形状,变量的分布同样影响负载。例如 first: 10 与 first: 100 的下游成本差 10 倍。压测脚本的变量应当从真实分布中采样,而不是固定一个中间值:

function sampleFirst() {
  // 按真实分布采样:多数用户看 10 条,少数翻到底
  const r = Math.random();
  if (r < 0.7) return 10;
  if (r < 0.95) return 20;
  return 50;
}

同样重要的是数据规模:在只有 1000 条记录的测试库里压测分页查询,得到的数字毫无意义。压测库的数据量应与生产同量级(或至少同数量级),否则索引与查询计划的差异会让结论完全跑偏。

四、关键指标与基线

4.1 必须采集的指标

指标含义健康基线(示例)
请求 P50 / P95 / P99延迟分位P95 < 300ms,P99 < 800ms
GraphQL 错误率errors[] 非空比例< 0.1%
每请求下游调用数N+1 是否复发与字段数同量级
CPU 饱和度事件循环延迟event loop lag P99 < 50ms
连接池等待取连接耗时等待 < 5ms
内存 RSS是否有泄漏浸泡测试中平稳
GC 暂停停顿对尾延迟的影响P99 暂停 < 50ms

4.2 为什么看 P99 而不是平均

GraphQL 的尾延迟尤其重要,因为一次页面加载可能触发多个 operation,只要其中一个慢,用户就感知到卡顿。若单请求 P99 是 800ms,10 个并发 operation 中至少一个超过 800ms 的概率约为 10%。平均延迟 50ms 但 P99 是 2 秒的服务,用户体验等同于「经常卡」。

4.3 事件循环延迟

Node.js 服务的一个隐蔽瓶颈是事件循环阻塞:一个同步的深查询解析、一个巨大的 JSON 序列化,都会阻塞整个进程,让所有并发请求排队。监控 event loop lag 能直接暴露这类问题:

import { monitorEventLoopDelay } from 'node:perf_hooks';

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
// 定期读取 h.percentile(99) 并上报,超过阈值告警

如果压测中 P99 延迟陡增而 CPU 未饱和,先看事件循环延迟——它通常指向同步代码或超大的序列化操作。

五、容量规划

5.1 从压测结果到容量公式

压测的目标是得到「单个实例在目标延迟下能承载多少 QPS」。假设单实例在 P95 < 300ms 的前提下承载 400 QPS,则:

所需实例数 = 峰值 QPS / 单实例容量 × 安全系数
           = (日常 QPS × 峰值倍数) / 400 × 1.5

例如日常 1000 QPS、峰值倍数 3(大促)、安全系数 1.5:

所需实例数 = (1000 × 3) / 400 × 1.5 ≈ 11.25 → 12 个实例

安全系数不是拍脑袋:它要覆盖「单实例故障时的重分配」与「容量估算误差」。通常 1.3~2.0,取决于系统的容错能力与流量可预测性。

5.2 下游容量同样要算

GraphQL 网关的容量往往不是瓶颈,下游数据库才是。按负载模型表,每 100 QPS 混合流量可能产生:

下游调用数/秒 = Σ(占比 × 每请求下游调用) × QPS
             = (0.6×1 + 0.3×3 + 0.1×12 + 0.05×5 + 0.02×4) × QPS
             = (0.6 + 0.9 + 1.2 + 0.25 + 0.08) × QPS
             = 3.03 × QPS

即 1000 QPS 对应约 3000 次下游调用/秒。数据库的连接池大小、慢查询、索引都要按这个量级核算。只算网关不算下游,是容量规划最常见的疏漏。

5.3 自动扩缩容的指标选择

扩缩容指标优点缺点
CPU 使用率通用、易得GraphQL 的瓶颈可能在 IO 而非 CPU
请求并发数贴近真实负载需精确采集
队列等待时间直接反映拥塞实现复杂
自定义 QPS 指标业务语义清晰需自建指标管道

对 GraphQL 服务,推荐以并发请求数或队列等待为主、CPU 为辅。因为深查询的 CPU 使用率可能不高,但事件循环已排队;单纯看 CPU 会滞后扩缩容。Kubernetes 的 HPA 自定义指标接入方式参见 Kubernetes 自动扩缩容 。

六、五类压测与执行流程

6.1 测试类型

类型目的负载形态通过标准
冒烟(Smoke)脚本正确、接口通1~5 VU,1 分钟无错误
负载(Load)验证目标容量预期峰值,稳态 30 分钟P95/P99 达标
压力(Stress)找崩溃点持续加压至失败记录崩溃阈值
浸泡(Soak)找泄漏与退化70% 峰值,数小时内存平稳、延迟不漂移
尖峰(Spike)抗突发能力秒级从 0 冲到峰值快速恢复、无级联失败

6.2 执行顺序

先冒烟(确保脚本对),再负载(拿到基线容量),再压力(知道余量),再浸泡(暴露长时间问题),最后尖峰(验证弹性)。跳过冒烟直接跑负载,最常见的后果是「压出来的错误全是脚本参数错」。

6.3 压测环境

  • 不要在生产压(除非是只读的影子流量),也不要在共享的预发环境压——邻居的流量会污染结论;
  • 压测环境的数据量、索引、下游配置必须与生产一致;
  • 压测前清理缓存,避免「第二次压测更快」的假象;若测缓存效果,则要明确区分冷/热两种场景。

七、常见瓶颈与定位

7.1 定位路径

现象可能原因定位手段
P99 陡增,CPU 未满事件循环阻塞 / 慢下游event loop lag、下游 span 耗时
下游调用数暴涨N+1 复发、DataLoader 未生效统计每请求下游调用数
连接池等待升高池太小或查询太慢池等待直方图、慢查询日志
内存持续增长缓存无上限、事件监听泄漏堆快照对比
延迟随并发线性上升单线程串行、锁竞争并发-延迟曲线

7.2 一个典型的压测发现

某团队压测发现:50 并发时 P95 是 120ms,150 并发时 P95 跳到 900ms,但 CPU 只有 40%。排查顺序:

  1. 看下游调用数——每请求 3 次,正常,排除 N+1;
  2. 看事件循环延迟——P99 达到 300ms,指向阻塞;
  3. 定位到 resolver 里有一段同步的 JSON 深拷贝,在大对象上耗时 80ms;
  4. 换成结构化共享或惰性拷贝后,150 并发的 P95 回落到 150ms。

这个案例说明:CPU 未饱和 ≠ 无瓶颈。GraphQL 的单线程特性让「同步耗时」被放大成「全局排队」,这正是 event loop lag 指标的价值所在。

八、接入 CI 的性能门禁

8.1 门禁设计

把压测中「最稳定、最能反映回归」的指标固化进 CI,每次 PR 或每晚跑一次:

# .github/workflows/perf.yml
- name: Run GraphQL load test
  run: k6 run --out json=result.json tests/load/mixed.js
- name: Check thresholds
  run: node scripts/check-thresholds.js result.json

check-thresholds.js 对比基线并设置容忍带(如 P95 上升不超过 10%),超出即失败。容忍带的意义是过滤噪声——压测本身有波动,卡死绝对值会导致误报。

8.2 回归门禁的注意事项

  • 固定环境:CI 的压测机规格要固定,否则不同机器的结果不可比;
  • 固定负载模型:查询组合与变量分布版本化,模型变了要显式说明;
  • 对比基线:与上一次「已知良好」的结果比,而非与绝对值比;
  • 只拦明显回归:性能门禁的目标是拦住「N+1 复发」「缓存失效」这类明显退化,不是追求毫秒级精确。

FAQ

Q1:能用 wrk/ab 压 GraphQL 吗?

能发请求,但不建议作为主要手段。它们不理解 GraphQL 语义,无法统计 errors[],也无法按 operation 区分延迟。作为纯 HTTP 层的吞吐基线可以,但容量结论要靠 k6 这类语义感知的工具。

Q2:压测时要不要关掉缓存?

要分场景测两次。冷缓存场景反映最坏情况(缓存刚失效、流量刚到来),热缓存场景反映稳态。容量规划应基于冷缓存,因为扩容决策要覆盖最坏情况;但也要知道热缓存的容量,用于评估缓存带来的弹性。

Q3:多大规模的压测才有意义?

至少覆盖预期峰值的 1.5 倍,否则无法验证「超峰时是否优雅降级」。如果压不到峰值,也要在报告中明确说明覆盖范围,不要把「没测到的部分」当作「没问题」。

Q4:压测结果和监控数据对不上怎么办?

先核对三件事:负载模型是否一致(查询组合)、数据规模是否一致、缓存状态是否一致。三者任一不同,数字就不可比。对不上是常态,重要的是记录压测时的完整条件,让结论可复现。

Q5:容量规划要多久做一次?

重大功能上线前必做,常规节奏按季度或按流量增长触发(如 QPS 较上次规划增长 50%)。Schema 有结构性变更(新增重查询、新增订阅)时也要重跑,因为负载模型变了。

Q6:订阅(Subscription)怎么压测?

订阅的负载特征与查询完全不同:它衡量的是并发连接数与事件广播频率,而非 QPS。压测脚本要建立并保持长连接,测量连接建立耗时、最大并发连接数、广播延迟与连接数增长时的内存曲线。k6 的 WebSocket 模块可以支撑这类场景,但要注意压测机自身的文件描述符与端口限制。

小结

GraphQL 压测的核心不是「跑出多少 QPS」,而是「用贴近真实的负载模型,找到单实例在目标延迟下的容量,并据此推算所需实例与下游容量」。它要求三件事同时做对:按流量占比混合查询、按真实分布采样变量与数据规模、以 P99 与事件循环延迟而非平均延迟为判据。容量公式 实例数 = 峰值 QPS / 单实例容量 × 安全系数 只是最后一步,真正决定结论质量的是前面的负载建模。把压测脚本与阈值门禁版本化、接入 CI,才能让性能回归在合并前被发现,而不是在大促的监控大屏上被发现。

相关阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「GraphQL」更多文章

  1. Mock 与测试策略
  2. 标量类型与输入校验
  3. 自定义指令与模式扩展