GraphQL 限流级联失败

一个慢 resolver 触发限流,把所有共享该上游的查询都拖垮。通过 resolver 复杂度计费、DataLoader 批量、熔断器来修。

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 秒。够上游恢复,又不至于让用户感到长时间故障。

相关阅读

标签: #后端 #排查 #graphql