GraphQL 限流级联:别让一个慢 resolver 拖垮整个网关

一个慢 resolver 撞上上游 429,整个 GraphQL 网关就跟着卡死。用按查询的复杂度计费、DataLoader 批量、快速失败的熔断器和按上游隔离的连接池来修。

GraphQL 网关在 100 RPS 时一切正常,然后一个热查询开始把某个慢下游 API 打爆。下游用 HTTP 429 限你的流。这下所有碰到该下游的查询都失败了,连本该走缓存的查询也跟着挂。几秒之内,跟这个下游毫无关系的 schema 部分也开始变慢——因为网关那个共享连接池被卡住的重试塞满了。一条热路径,把整个网关拖垮了。

眼下最快的止血手段: 停掉对 429 的重试,让被限流的那个上游快速失败(在它前面挡一个熔断器),这样网关就不会一直占着连接,几秒内即可恢复。然后再用按查询的复杂度上限、DataLoader 批量、按上游隔离的连接池来防止复发。下面把两件事都讲清楚。

你属于哪一类?

你看到的现象最可能的原因跳到
级联前出现一个巨大查询(几百个字段)没有复杂度上限Step 1
上游调用数跟记录数成正比,而不是字段数N+1 resolver,没用 DataLoaderStep 2
短暂恢复后 429 一波一波又回来429 上重试,而不是快速失败Step 3
故障期间不相关的快查询也卡住共享连接池被占满Step 4
来自你不可控客户端、每小时几百种不同查询形状没有 safelisting / persisted query listStep 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_COMPLEXITY1000 起步;用上面的 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_LIST code 的 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 是否在往上涨来确认。

相关阅读

标签: #后端 #排查 #graphql