GraphQL 网关在 100 RPS 时一切正常,然后一个热查询开始把某个慢下游 API 打爆。下游用 HTTP 429 限你的流。这下所有碰到该下游的查询都失败了,连本该走缓存的查询也跟着挂。几秒之内,跟这个下游毫无关系的 schema 部分也开始变慢——因为网关那个共享连接池被卡住的重试塞满了。一条热路径,把整个网关拖垮了。
眼下最快的止血手段: 停掉对 429 的重试,让被限流的那个上游快速失败(在它前面挡一个熔断器),这样网关就不会一直占着连接,几秒内即可恢复。然后再用按查询的复杂度上限、DataLoader 批量、按上游隔离的连接池来防止复发。下面把两件事都讲清楚。
你属于哪一类?
| 你看到的现象 | 最可能的原因 | 跳到 |
|---|---|---|
| 级联前出现一个巨大查询(几百个字段) | 没有复杂度上限 | Step 1 |
| 上游调用数跟记录数成正比,而不是字段数 | N+1 resolver,没用 DataLoader | Step 2 |
短暂恢复后 429 一波一波又回来 | 在 429 上重试,而不是快速失败 | Step 3 |
| 故障期间不相关的快查询也卡住 | 共享连接池被占满 | Step 4 |
| 来自你不可控客户端、每小时几百种不同查询形状 | 没有 safelisting / persisted query list | Step 5 |
| 同一个查找 key 每秒被拉几十次 | 稳定数据没缓存 | Step 6 |
常见原因,按踩坑频率排序
1. 没有按查询的复杂度上限
Apollo Server 和 graphql-yoga 默认接受任意深度、任意字段数的查询。500 字段的查询大约比单字段贵 500 倍,但你把两者都算成一次请求、一个限流 token。
怎么判断:接一个复杂度估算器(见 Step 1),把每个 operation 的 cost 打到日志里;或者扫网关日志里查询文档的长度。如果超过约 200 节点的查询很常见,就说明没限制。
2. Resolver 做 N+1 拉取,没用 DataLoader
posts.author 的 resolver 每个 post 跑一次。查询要 100 个 post,就会触发 100 次独立的上游 author 调用,几乎立刻打中上游限流。
怎么判断:数一下单次 GraphQL 查询里的上游调用次数。它应该跟「字段数」成正比,而不是跟「记录数」成正比。
3. 在 429 上重试,而不是快速失败
默认的 fetch / axios 重试策略把 429 当成 500 一样处理(视为临时故障、退避后重试)。对一个已经被限流的上游做重试,只会把它打得更狠,把级联挖得更深。
怎么判断:看你的重试配置。如果 429 会触发指数退避 + 重试,那就是在加深这个坑。补充一点:429 理想情况下应该等它的 Retry-After header 时间过去之后再重试,绝不要立即重试。
4. 共享连接池跨 resolver
一个比如 50 连接的 Axios / undici 池被所有 resolver 共享。一个慢 resolver 把池子塞满挂起的连接,快 resolver 就借不到连接了。
怎么判断:监控连接池利用率。利用率 100% 且大多数连接都挂在同一个 host 上,就说明池子被单个上游占满了。
5. 没有 safelisting / persisted query list
客户端(或爬虫)发任意形状的查询。如果网关层没强制 persisted query list,每个新形状都会触发完整的 parse、validation、execution 和下游调用。
怎么判断:看查询形状分布。如果生产客户端每小时产生几百种不同的 operation hash,那基本就是开放、不受限的查询。
6. 稳定查找没缓存
用户档案、产品信息、分类——这些很少变,却每次查询都现拉。它们本应缓存 60 秒以上。
怎么判断:跑一个上游调用分析。同一个 key 每秒被拉几十次,就是没缓存。
动手前先确认
- 确认限流来源:哪个上游返回的
429,它文档里写的限流值是多少。 - 找到触发 operation:GraphQL 操作名和文档 hash。
- 拉出级联时间窗内的网关指标:请求延迟分位、按上游分的调用数、错误率。
- 整理级联时间线:哪些查询先挂、哪些跟着挂。
- 往前修,不要回滚——除非触发查询是刚上线的,那回滚那次改动就是最快的缓解。
分步修复
Step 1:加查询复杂度上限
老的 graphql-validation-complexity 包提供的是一个 validation rule,但它看不到请求的 variables(validation 在变量绑定之前就跑了),所以 posts(first: $n) 这种 cost 会被算少。截至 2026 年 6 月,建议改用 graphql-query-complexity,它跑在 Apollo Server 的 didResolveOperation 钩子里,能拿到 variables。下面这段 plugin 写法针对 Apollo Server 5(当前大版本;Apollo Server 4 已于 2026-01-26 终止支持,AS5 需要 Node.js 20+)。@apollo/server 的 import 路径在 AS4 和 AS5 上一致,所以同一段 plugin 在两者上都能跑。
import { ApolloServer } from '@apollo/server';
import {
getComplexity,
fieldExtensionsEstimator,
simpleEstimator,
} from 'graphql-query-complexity';
import { GraphQLError } from 'graphql';
const MAX_COMPLEXITY = 1000;
const complexityPlugin = {
async requestDidStart() {
return {
async didResolveOperation({ request, document, schema }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
metrics.histogram('graphql_query_cost').record(complexity);
if (complexity > MAX_COMPLEXITY) {
throw new GraphQLError(
`Query is too complex: ${complexity}. Maximum allowed: ${MAX_COMPLEXITY}`,
{ extensions: { code: 'QUERY_TOO_COMPLEX' } },
);
}
},
};
},
};
const server = new ApolloServer({ schema, plugins: [complexityPlugin] });
在 SDL 里用 @cost 声明每个字段的 cost,并乘上分页参数,这样大的 first 就会被相应计价:
type Query {
posts(first: Int = 10): [Post!]! @cost(complexity: 1, multipliers: ["first"])
search(query: String!, first: Int = 10): [Post!]! @cost(complexity: 5, multipliers: ["first"])
}
MAX_COMPLEXITY 从 1000 起步;用上面的 histogram 观察一周真实流量后再收紧。
Step 2:每个 N+1 resolver 都加 DataLoader
每个请求都新建一个 DataLoader 实例(绝不要跨请求复用——见「容易踩的坑」),让它在单次 GraphQL operation 内把所有 load 批量化并去重。
import DataLoader from 'dataloader';
const createAuthorLoader = () => new DataLoader<string, Author>(
async (authorIds) => {
const authors = await db.author.findMany({
where: { id: { in: [...authorIds] } },
});
const byId = new Map(authors.map(a => [a.id, a]));
// 返回顺序必须和 authorIds 完全一致,每个 id 占一个位置
return authorIds.map(id => byId.get(id) ?? null);
},
{ maxBatchSize: 100, cache: true },
);
// 在 context 工厂里为每个请求新建 loader
const context = async ({ req }) => ({
loaders: {
author: createAuthorLoader(),
tagsByPostId: createTagsLoader(),
},
});
// resolver 里用
const resolvers = {
Post: {
author: (post, _args, { loaders }) => loaders.author.load(post.authorId),
},
};
DataLoader 把同一个 tick 里的 100 次 author 查找折叠成一次批量拉取。两个容易翻车的规则:批量函数返回的结果顺序必须和输入 key 完全一致(每个 key 一个位置,找不到就填 null),并且输入和输出的数组长度必须相等。
Step 3:429 用熔断器快速失败
在级联正在发生时,这是单点收益最高的改动。用 opossum(截至 2026 年 6 月当前版本为 v9.0.0)把上游调用包起来,让被限流的上游触发熔断,而不是一直堆积卡住的连接。
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(callUpstream, {
timeout: 3000, // 单次调用超过 3s 就放弃(opossum 默认 10000)
errorThresholdPercentage: 50, // 失败率 >= 50% 时打开(这也是默认值)
resetTimeout: 30000, // 30s 后进入 halfOpen 再探测一次(也是默认值)
// errorFilter 返回 TRUE 表示该错误「不计入」打开判断。
// 对 429 返回 false,让限流错误能快速触发熔断。
errorFilter: (err) => err.status !== 429,
});
breaker.fallback(() => {
throw new GraphQLError('Upstream rate-limited, please retry shortly', {
extensions: { code: 'RATE_LIMITED' },
});
});
async function fetchAuthor(id: string) {
return breaker.fire(id);
}
注意 errorFilter 的方向。opossum 官方文档的定义是:「这是一个可选函数,会在熔断包裹的函数失败时被调用;如果它返回 truthy,熔断器的 failPure 统计就不会增加。」也就是说,返回 true 是告诉 opossum「忽略这个错误」(不计入打开判断),返回 false 才计入。我们的过滤器是 (err) => err.status !== 429,对 429 返回 false——意味着 429(以及超时,过滤器同样让它走 false)会被计入。所以上游一旦被限流,熔断器几次请求内就会打开,网关用毫秒级返回明确错误,而不是把连接占满整个 timeout。过了 resetTimeout 熔断器会进入 halfOpen 状态再探测一次,探测成功后自动关闭。
Step 4:按上游隔离连接池
给每个上游一个独立的 undici Agent(各自的连接预算),这样慢 API 卡住时不会把快 API 的连接饿死。
import { Agent } from 'undici';
const upstreamPools = {
fastDb: new Agent({ connections: 50, pipelining: 1 }),
slowApi: new Agent({ connections: 10, pipelining: 1 }), // 故意小一点
search: new Agent({ connections: 20, pipelining: 1 }),
};
// 按 resolver / 按上游 host 选对应的 dispatcher
await fetch(url, { dispatcher: upstreamPools.slowApi });
这样慢 API 可以把自己那 10 个连接占满,也碰不到另外 70 个连接。这就是 bulkhead(隔舱)模式:把影响范围限制在单个上游内。
Step 5:用 persisted query list 做 safelisting
要真正拒绝任意爬虫查询,你需要的是 safelisting,而不是普通的 Automatic Persisted Queries(APQ)。APQ 只是把查询 hash 换成完整查询字符串以减小请求体——它并不会阻止一个从没见过的查询执行。safelisting 需要一个由网关强制执行的 persisted query list(PQL)。
- 用 GraphOS / Apollo Router 时:在 build 阶段把客户端的可信 operation 注册进 PQL,然后设置 router 的 persisted-queries 安全级别。截至 2026 年 6 月,级别从
log_unknown/ audit 模式(把未注册的 operation 记日志,相当于演练)逐步升到完整的 safelisting——这时 router 会拒绝任何不在 PQL 里的 operation。参见 Safelisting with persisted queries。先跑 audit 模式,直到日志显示每个合法客户端 operation 都已注册,再切到强制。 - 在没有 GraphOS 的自托管 Apollo Server 上:APQ 本身不是 safelisting。你可以自己维护一份 operation hash 的白名单,在某个 plugin 的
didResolveOperation里拒绝未知 hash(抛带PERSISTED_QUERY_NOT_IN_LISTcode 的GraphQLError),或者把强制逻辑挪到 router 上。
无论哪种方式,都把 safelisting 和 Step 1 的复杂度上限结合起来——safelisting 拦未知形状,复杂度上限管住那些「已知但很贵」的查询。
Step 6:稳定查找用 Redis 缓存
async function getUserProfile(id: string) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
await redis.setex(`user:${id}`, 60, JSON.stringify(user)); // 60s TTL
return user;
}
profile 数据 60 秒 TTL,通常能把热用户的上游调用砍掉 80% 到 95%,很多时候光这一项就足够让你压在上游限流之下了。TTL 按「数据能安全过期多久」来定。另外记住:DataLoader 的请求级缓存(Step 2)负责在「单次查询内」去重,而 Redis 负责在「跨查询、跨请求」之间去重。
Step 7:每个 resolver 加追踪和告警
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginUsageReporting({
sendVariableValues: { none: true },
sendHeaders: { none: true },
}),
],
});
接好之后 Apollo Studio(GraphOS)就能看到每个 resolver、每个 operation 的 p99 延迟。任意 resolver 突破 500ms p99 就告警,再单独对上游 429 速率告警,这样级联还没影响到用户你就能发现。如果你不用 GraphOS,等价做法是给每个 resolver 打 OpenTelemetry span 导到你的 APM。
怎么确认修好了
- 在 staging 复现触发查询。 网关应该在约 200ms 内返回
RATE_LIMITED错误,而不是挂上几秒。 - 跑 2 倍峰值的压力测试。 p99 延迟应稳在 500ms 以内,单个上游的
429不应该以错误的形式出现在不相关的查询上。 - 检查 DataLoader 批量。 流量期间,每个 operation 的上游调用数应该跟字段数成正比,DataLoader 命中率应超过 80%。
- 模拟上游断掉。 确认熔断器会打开(快速报错),上游恢复后又能自动关闭,无需人工干预。
- 验证隔离。 在慢上游被限流时打一个快查询,确认它的延迟没有变化。
长期预防
- 每加一个新 resolver,就把「查询复杂度上限」列进 PR 模板。
- 任何 list-of-children 的 resolver 默认上 DataLoader。
- 网关启动时按上游分独立连接池,标准化;每季度复查一次池大小。
- 一个季度内把生产客户端迁到 persisted query list(先 audit 模式,再 safelisting)。
- 生产环境必须接每个 resolver 的追踪(GraphOS 或 OpenTelemetry)。
容易踩的坑
- 靠扩连接池来掩盖
429级联——只是把失败往后拖,还浪费更多上游 token。 - 在
429上加重试——上游已经撑不住了;应当尊重Retry-After,剩下的交给熔断器吸收。 - 跨请求共享同一个 DataLoader 实例——它的缓存会跨用户串数据,而且永不过期。永远按请求新建 loader。
- 复杂度上限设得太宽(5000+),实际从不触发——先量出真实 cost,再把上限设在合法 p99 之上一点。
- 以为 APQ 等于 safelisting——并不是。APQ 是请求体大小优化;拒绝未知查询需要 persisted query list。
FAQ
复杂度上限设多少合适? 多数 API 从 1000 起步合理。先把每个 operation 的 cost 打一周日志,再把上限设在合法 p99 之上一点,让真实查询能过、滥用查询被挡。
DataLoader 能处理每个父字段需要不同字段的情况吗? 多挂几个 DataLoader,每种拉取形状一个(比如一个按 authorId、另一个按 postId)。一个 loader 对应一种查询意图,批量才不会出错。
熔断器应该开多久? resetTimeout 设 30 到 60 秒比较合适——够上游恢复,又能在用户察觉到长时间故障之前先探测重试。
APQ 足够挡住爬虫查询吗? 不够。Automatic Persisted Queries 只是用 hash 缩短请求。要拒绝未注册查询,需要通过 persisted query list(GraphOS/Apollo Router)或在服务端 plugin 里做自定义白名单检查来实现 safelisting。
429 到底该不该重试? 只有在 Retry-After 间隔(或较长退避)过去之后才重试,而且绝不要在正在喂级联的那条请求路径上重试。故障正在发生时,应当快速失败、卸载流量,而不是重试。
我的 opossum 熔断器在 429 狂刷时却从不打开,为什么? 几乎都是 errorFilter 方向写反了。opossum 在 errorFilter 返回 truthy 时会忽略该错误(不计入打开判断)。如果你写成 errorFilter: (err) => err.status === 429,那就等于告诉它「忽略每一个 429」,熔断器自然永远不会打开。把它改成 (err) => err.status !== 429,这样 429 返回 false 就会被计入。可以打印 breaker.stats,在故障期间观察 failures 是否在往上涨来确认。