GraphQL 网关 100 RPS 时没事,然后一个热查询开始把某个慢下游 API 打爆。下游用 HTTP 429 限你流。这下所有碰到该下游的查询都失败了,连本该走缓存的查询也跟着挂。几秒之内,跟这个下游没关系的 schema 部分也变慢——因为网关连接池被卡住的重试塞满了。一条热路径把整个网关拖垮。修复方向:给每个 resolver 加复杂度计费、上 DataLoader 批量、对被限流的上游用熔断器快速失败。
常见原因
按踩坑频率排序。
1. 没有按查询的复杂度上限
Apollo Server 和 graphql-yoga 默认接受任意深度和字段数的查询。500 字段的查询比单字段贵 500 倍,但都按一次请求算。
怎么判断:跑 graphql-query-complexity,或者看网关日志里的查询长度。超过 200 节点的查询常见就说明没限制。
2. Resolver 做 N+1 拉取,没用 DataLoader
posts.author 的 resolver 每个 post 跑一次。查询要 100 个 post -> 100 次上游 author 调用。立刻打中上游限流。
怎么判断:单次 GraphQL 查询里上游调用次数。应该是字段数量级,不是记录数量级。
3. 429 上重试,没有快速失败
默认 fetch 重试把 429 当 500 处理。重试继续打被限流的上游,级联失败更深。
怎么判断:看重试逻辑。429 触发指数退避 + 重试就是在加深这个坑。
4. 共享连接池跨 resolver
50 个连接的 Axios / undici 池给所有 resolver 共享。一个慢 resolver 把池子塞满。其他 resolver 借不到连接。
怎么判断:监控池利用率。100% 且大多数连接处于挂起 = 被一个 resolver 占满了。
5. 没强制 persisted queries
爬虫用随机查询探。没有 persisted queries 时,每个随机形状都触发完整执行 + 下游调用。
怎么判断:看查询形状分布。每小时几百种不同形状就是开放查询。
6. 稳定查找没缓存
用户档案、产品信息、分类——每次查询都拉。本来应该缓存 60 秒以上。
怎么判断:跑上游调用分析。同一个 key 每秒拉 100 次就是没缓存。
动手前先确认
- 确认限流来源:哪个上游返回的 429。
- 找触发查询:GraphQL 操作名 + 文档 hash。
- 看网关指标:请求延迟分位、上游调用数、错误率。
- 整理级联时间线:哪些查询先挂、哪些跟着挂。
- 除非触发查询是最近加的,否则往前修不回滚。
需要收集的信息
- 慢上游 endpoint 和它文档里写的限流值。
- 级联时间窗内的网关日志,含操作名和耗时。
- 如果有埋点,DataLoader 命中率。
- undici / Axios / fetch 连接池统计。
- Apollo 或 graphql-yoga 的版本和配置。
分步修复
Step 1:加查询复杂度上限
import { createComplexityRule } from 'graphql-validation-complexity';
const ComplexityLimitRule = createComplexityRule({
maximumComplexity: 1000,
variables: {},
onCost: (cost) => {
metrics.histogram('graphql_query_cost').record(cost);
},
formatErrorMessage: (cost) =>
`Query is too complex: ${cost}. Maximum allowed: 1000`,
});
const server = new ApolloServer({
schema,
validationRules: [ComplexityLimitRule],
});
给重字段设复杂度:
type Query {
posts(first: Int = 10): [Post!]! @cost(complexity: 1, multipliers: ["first"])
search(query: String!, first: Int = 10): [Post!]! @cost(complexity: 5, multipliers: ["first"])
}
Step 2:每个 N+1 resolver 都加 DataLoader
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]));
return authorIds.map(id => byId.get(id) ?? null);
},
{ maxBatchSize: 100, cache: true }
);
// 每个请求的 context 里挂
const context = ({ req }) => ({
loaders: {
author: createAuthorLoader(),
tagsByPostId: createTagsLoader(),
},
});
// resolver 里用
Post: {
author: (post, _, { loaders }) => loaders.author.load(post.authorId),
}
DataLoader 把 100 次调用折叠成一批。
Step 3:429 用熔断器快速失败
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(callUpstream, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
// 限流上专门走快速失败
errorFilter: (err) => err.status !== 429,
});
breaker.fallback(() => {
throw new GraphQLError('Upstream rate-limited, please retry shortly', {
extensions: { code: 'RATE_LIMITED' },
});
});
// resolver 里调
async function fetchAuthor(id: string) {
return breaker.fire(id);
}
上游被限流时熔断器开 30 秒,网关用毫秒级返回明确错误,不再占着连接。
Step 4:每个上游独立的连接池
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 里选对应的 agent
fetch(url, { dispatcher: upstreamPools.slowApi });
慢 API 卡住自己那 10 个连接也不会影响其他池。
Step 5:强制 persisted queries
import { createPersistedQueryLink } from '@apollo/server';
const server = new ApolloServer({
schema,
persistedQueries: {
cache: new RedisCache({ url: process.env.REDIS_URL }),
requireSignature: true,
},
});
只有签名注册过的查询形状能执行。爬虫的随机查询在 parse 阶段就被拒。
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));
return user;
}
profile 数据 60 秒 TTL,能把热用户的上游调用砍掉 80-95%。
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 能看每个 resolver 的 p99。任意 resolver 突破 500ms p99 就告警。
验证
- 在 staging 复现触发查询;网关应该 200ms 内返回限流错误,不是挂着。
- 跑 2 倍峰值的压力测试;p99 延迟应该稳在 500ms 以内。
- 流量期间 DataLoader 命中率应该超过 80%。
- 模拟上游断掉,熔断器应该正常打开和恢复。
长期预防
- PR 模板里把”复杂度上限”列成新 resolver 的必检项。
- 任何 list-of-children resolver 默认加 DataLoader。
- 网关启动时按上游分独立连接池,标准化。
- 第一个季度内推动所有生产客户端用 persisted queries。
- 生产环境必须接 Apollo Studio 或等价的追踪。
容易踩的坑
- 用扩连接池来掩盖 429 级联——只是延后失败而已。
- 在 429 上加重试——上游已经撑不住了。
- 跨请求复用单个 DataLoader 实例——会跨用户串数据。
- 复杂度上限设得太宽(5000+),实际从不触发。
FAQ
复杂度上限设多少合适? 多数 API 从 1000 起步合理。观察一周再收紧。
DataLoader 能处理每个父字段需要不同字段的情况吗? 多挂几个 DataLoader,每种拉取形状一个。
熔断器开多久? 30 到 60 秒。够上游恢复,又不至于让用户感到长时间故障。