给 REST 接口压测很直观:一个 URL、一组参数、固定响应体,ab 或 wrk 打几千个请求就能画出曲线。给 GraphQL 压测则完全不同——同一个端点 /graphql 可能承载几十种操作,有的只是取一个字段,有的嵌套五层并触发上百次数据库查询。如果用「一条固定查询打满 QPS」的方式压测,得到的容量数字在生产环境几乎必然失真:真实流量是混合的,而混合流量的成本远高于单条查询的简单平均。
本文的目标是把 GraphQL 压测从「跑个数字」变成「可解释的容量结论」。我们会讲清楚为什么 GraphQL 的负载模型必须按查询组合加权、如何用 k6 编写贴近真实流量的脚本、哪些指标能揭示瓶颈、以及如何把压测结果换算成可指导扩缩容的容量公式。最后给出一套可接入 CI 的回归门禁。
一、GraphQL 压测与 REST 压测的差异
1.1 五个本质差异
| 维度 | REST | GraphQL |
|---|---|---|
| 端点 | 多端点,各自成本不同 | 单端点,成本由查询决定 |
| 请求体 | 简单参数 | 嵌套查询,形状差异巨大 |
| 成本模型 | 近似常量 | 随深度、宽度、别名数变化 |
| 缓存 | 按 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 门禁 |
| Artillery | YAML 易读、场景清晰 | 团队协作、快速验证 |
| autocannon | 极致吞吐、低开销 | 单查询上限、基准对比 |
| wrk/vegeta | 超低开销 | 纯 HTTP 层压测(不解析 GraphQL 错误) |
三、负载模型:查询组合与权重
3.1 从哪里拿到真实比例
真实查询比例有三个可靠来源,按可信度排序:
- APM / trace 数据:按 operation name 统计调用量占比,最准确;
- 服务端日志:记录每次请求的 operation name 与查询哈希;
- 持久化查询清单:如果启用了 APQ/白名单,清单本身就是流量分布的上界。
拿到比例后,还要拿到每个 operation 的平均与 P99 成本(字段数、下游调用数、耗时),两者结合才是完整的负载模型。
3.2 负载模型表
| Operation | 流量占比 | 平均字段数 | 平均下游调用 | 平均耗时 |
|---|---|---|---|---|
Light | 60% | 3 | 1 | 12ms |
Medium | 30% | 24 | 3 | 85ms |
Heavy | 10% | 180 | 12 | 420ms |
Search | 5% | 40 | 5 | 260ms |
CreateOrder(mutation) | 2% | 10 | 4 | 150ms |
这个表可以直接驱动压测脚本的权重,也能用来估算「每 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%。排查顺序:
- 看下游调用数——每请求 3 次,正常,排除 N+1;
- 看事件循环延迟——P99 达到 300ms,指向阻塞;
- 定位到 resolver 里有一段同步的 JSON 深拷贝,在大对象上耗时 80ms;
- 换成结构化共享或惰性拷贝后,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 Resolver 性能与 N+1 问题根治
- 可观测性与链路追踪
- GraphQL 服务端实现深度解析
- 性能与负载测试方法论
- Kubernetes 自动扩缩容
- Node.js 集群与进程管理
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。