引言

一个老生常谈的场景:移动端首页要渲染"用户头像 + 昵称 + 最近三篇文章的标题和评论数"。用 REST,前端要发四个请求——GET /users/42、GET /users/42/posts?limit=3、然后对每篇文章再发一次 GET /posts/:id/comments。等四个请求串完,首屏已经过去 1.5 秒。于是有人提议上 GraphQL:一次请求,想要什么写什么。

GraphQL 确实能把上面四请求压成一请求。但"换成 GraphQL 就万事大吉"是个危险的错觉——它把性能问题从"网络往返多"转移成了"服务端 N+1 查询多",把缓存从 HTTP 层的"免费"变成了 GraphQL 层的"要自己搭"。本文不吹不黑,讲清楚 GraphQL 的核心机制、它与 REST 的真实差异,以及什么时候该用、什么时候别碰。


一、三个核心概念:Schema、Resolver、操作类型

GraphQL 的一切都围绕一个强类型 Schema 展开。Schema 是客户端和服务端的契约,客户端只能查询 Schema 里声明过的字段。

1.1 Schema:类型驱动的契约

graphql
type User {
  id: ID!
  name: String!
  posts: [Post!]!   # User 和 Post 形成关系图
}

type Post {
  id: ID!
  title: String!
  author: User!
}

type Query {
  user(id: ID!): User
  posts: [Post!]!
}

Query、Mutation、Subscription 是三种特殊根类型,分别对应"读""写""订阅实时事件"。字段后面的 ! 表示非空,[Post!]! 表示"非空列表,且列表里每个元素都非空"。这套类型系统让工具能自动生成 TypeScript 类型、做编译期校验——这是 REST 的 OpenAPI 也能做、但往往被束之高阁的事。

1.2 Resolver:字段如何取值

Schema 只回答"有什么",不回答"数据从哪来"。取值逻辑全在 Resolver 里,每个字段都可以有自己的 resolver:

ts
const resolvers = {
  Query: {
    user: (_parent, args, context) => context.db.findUser(args.id),
  },
  User: {
    // posts 字段单独解析,支持按需加载
    posts: (user, _args, context) => context.db.findPostsByAuthor(user.id),
  },
}

关键在 User.posts 这个 resolver:只有当客户端真的请求了 posts 字段时,它才会被执行。这就是 GraphQL 按需取值的机制,也是 N+1 问题的温床(后面细讲)。

1.3 三种操作:Query / Mutation / Subscription

操作 语义 类比 REST 特点
Query 读取数据 GET 幂等,可并行
Mutation 写数据 POST/PUT/DELETE 串行执行,保证顺序
Subscription 订阅实时变化 无直接对应(WebSocket/SSE) 长连接推送

容易踩的坑:Mutation 在 GraphQL 规范里是串行执行的,一个 Mutation 里的多个字段按顺序跑;而 Query 里多个字段是并行的。别把需要顺序保证的写操作放错地方。


二、与 REST 的真实差异:三个绕不开的问题

2.1 Over-fetching 与 Under-fetching

REST 的资源粒度是服务端定的。GET /users/42 返回的字段全由后端决定,前端想要昵称却收到整个用户对象(含邮箱、手机号)——这是 Over-fetching。反过来,前端要的头像在 /users/42、昵称却在 /profiles/42,一次拿不齐,得连环调用——这是 Under-fetching。

GraphQL 让前端声明式地描述需要什么:

graphql
query {
  user(id: 42) {
    name
    avatar
    posts(limit: 3) {
      title
      commentCount
    }
  }
}

一个请求,精确的字段,没有多余也没有短缺。这是 GraphQL 最核心的卖点,尤其在弱网、移动端、字段很多且客户端形态各异(App、Web、小程序)的场景里收益最大。

2.2 版本管理

REST 的版本演进通常是 v1、v2 或加字段。字段一旦加多,老客户端要么忽略要么报错。GraphQL 的官方建议是永不破坏性变更:加字段不用升版本,废弃字段用 @deprecated 标记,等所有客户端都迁走了再删。

graphql
type User {
  name: String!
  old_name: String @deprecated(reason: "用 name 替代")
}

代价是:你的 Schema 会越来越胖,废弃字段长期留着。这换来了客户端零感知的平滑演进,对多端产品是实打实的好处。

2.3 错误处理模型

REST 用 HTTP 状态码表达成败:404 是没找到,500 是服务器炸了。GraphQL 里几乎永远返回 200,错误放在响应的 errors 数组里:

json
{
  "data": { "user": null },
  "errors": [
    {
      "message": "User not found",
      "path": ["user"],
      "extensions": { "code": "NOT_FOUND" }
    }
  ]
}

这带来两个后果:一是部分失败成为常态——一个查询里五个字段,可能四个成功一个失败,data 和 errors 同时非空;二是监控、网关、CDN 那套基于状态码的告警逻辑要重写,不能只看 200 就认为成功。


三、N+1 问题与 DataLoader

这是 GraphQL 从"看起来很美"到"生产可用"之间最常翻车的地方。

3.1 问题在哪

看这个查询:

graphql
query {
  posts(limit: 20) {
    title
    author {
      name
    }
  }
}

如果 Post.author 的 resolver 是 db.findUser(post.authorId),那么查询 20 篇文章,就会执行 1 次查文章 + 20 次查作者 = 21 次查询。这就是 N+1——数据量一大,数据库连接池瞬间打满,接口从 50ms 退化成 2s。

问题的根源不是 GraphQL,而是逐条、逐字段的朴素加载方式。字段级 resolver 让"按需加载"很自然,也让"无脑循环查库"很自然。

3.2 DataLoader:把 N 次查询合并成 1 次

Facebook 开源了 DataLoader 专门解决这个。它的核心是批处理 + 缓存:把同一个 tick 内对同一字段的多次 load 收集起来,合并成一次 IN 查询。

ts
import DataLoader from 'dataloader'

// 批量加载:一次查出所有 authorId 对应的作者
const batchAuthors = async (ids: readonly string[]) => {
  const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids])
  const byId = new Map(rows.map((r) => [r.id, r]))
  // 必须按输入顺序返回,且数量与 ids 一一对应
  return ids.map((id) => byId.get(id) ?? null)
}

const authorLoader = new DataLoader(batchAuthors)

const resolvers = {
  Post: {
    author: (post, _args, context) => context.authorLoader.load(post.authorId),
  },
}

执行后,20 次 load 被合并成 1 次 WHERE id = ANY(...) 查询,再按顺序分发回每个字段。N+1 变成 1+1。

DataLoader 的三个使用要点:

  1. Loader 必须放在请求上下文里,每个请求一个实例。否则缓存跨请求共享,用户 A 的结果会串到用户 B。
  2. 批处理函数必须返回与输入等长、同序的数组,允许 null 占位。顺序乱了,字段会张冠李戴。
  3. 它解决的是"同一字段的多条加载",不是万能药。查询本身设计得不合理(比如一个字段内部又递归查了 N 次),DataLoader 救不了。

3.3 更彻底的做法

DataLoader 是补丁,更优的做法是从数据层就减少往返——用 JOIN 或对关系型数据用 GraphQL 生态的批加载方案(如 Prisma 的 include、Hasura/PostGraphile 这类自动生成层)。但即便用了 JOIN,DataLoader 作为兜底的批处理工具仍然值得常备。


四、缓存策略:REST 免费,GraphQL 要自己搭

这是两者被低估的最大差异。

4.1 REST 的缓存是"白送的"

REST 的资源有明确 URL,配合 Cache-Control、ETag、Vary,HTTP 缓存、CDN、浏览器三层都天然可用。GET /users/42 的响应可以被 CDN 边缘节点缓存,第二次请求根本到不了源站。

4.2 GraphQL 的缓存是"自己攒的"

GraphQL 的请求几乎都是 POST 到一个固定端点 /graphql,请求体是一段查询字符串。HTTP 层没法按 URL 缓存,CDN 也帮不上忙。你需要在应用层重建缓存,主流方案有三类:

方案 思路 适合场景 代价
响应级缓存 按 query + variables 哈希缓存整段响应 查询相对固定 命中率低,变量一变就失效
持久化查询 客户端只发 query ID,服务端存查询文本 减小请求体 + 可缓存 需要构建时注册查询
实体级缓存(规范化) 把响应拆成 User:42 这样的实体存起来 客户端 Apollo Cache 失效策略复杂,要处理一致性

其中规范化实体缓存是 GraphQL 客户端(Apollo Client、Relay)的标配:响应按 __typename + id 拆成实体,跨查询复用。但要注意,只有带全局 id 字段的实体才能被稳定地规范化,字段里没 id 的列表项缓存会很脆弱。

ts
// Apollo Client 的规范化缓存:同一条 User 只存一份
const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      User: { keyFields: ['id'] },  // 明确用 id 当实体的主键
    },
  }),
})

结论:如果你的场景高度依赖"读多写少 + 可大规模缓存",REST 可能反而更省心;GraphQL 的缓存要团队有意识地设计,否则就是每次查询都打到数据库。


五、成本与安全:GraphQL 把权力下放给了客户端

REST 的服务端是"我让你查什么你查什么",GraphQL 是"客户端说查什么就查什么"。这种灵活性是有成本的。

5.1 查询深度与复杂度失控

客户端可以写一个无限深的嵌套查询:

graphql
query {
  user(id: 1) {
    posts { author { posts { author { posts { ... } } } } }
  }
}

一次"合法"的查询就能打崩服务。所以必须做查询复杂度分析和深度限制:

ts
import depthLimit from 'graphql-depth-limit'
import { createComplexityLimitRule } from 'graphql-validation-complexity'

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),                       // 嵌套最多 7 层
    createComplexityLimitRule(1000, {    // 总复杂度上限
      onCost: (cost) => console.log('query cost:', cost),
    }),
  ],
})

5.2 限流:不能只看请求次数

REST 的限流天然按"请求数/秒"算。GraphQL 里一个请求可能比十个 REST 请求还重,所以限流要升级为按查询成本(复杂度 × 频率)来算。持久化查询配合预先计算的成本,是生产环境常见的组合拳。

5.3 授权:字段级权限

GraphQL 的授权不是"登录了就能查",而是字段级的。某次查询请求了 User.email,但当前用户没有权限,你得在 User.email 的 resolver 里拦下来:

ts
const resolvers = {
  User: {
    email: (user, _args, context) => {
      if (context.currentUser.id !== user.id && !context.currentUser.isAdmin) {
        throw new GraphQLError('Forbidden', {
          extensions: { code: 'FORBIDDEN' },
        })
      }
      return user.email
    },
  },
}

注意:不要在 resolver 之外用"根据查询字符串判断权限"这种脆弱的做法——查询文本可以被改。授权逻辑必须下沉到字段 resolver 或数据访问层,这是安全底线。

5.4 敏感信息与内省

默认的 introspection(内省)会把整个 Schema 暴露出去,攻击者能据此构造深度攻击查询。生产环境通常关掉内省,或只在开发环境开放:

ts
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
})

六、实战:Apollo Server + TypeScript 搭一个最小服务

用 Apollo Server v4 搭一个带 DataLoader 和复杂度限制的完整例子,串起前面所有概念。

ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { gql } from 'graphql-tag'
import DataLoader from 'dataloader'
import depthLimit from 'graphql-depth-limit'

// 1. Schema:类型驱动的契约
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    author: User!
  }

  type Query {
    user(id: ID!): User
    posts(limit: Int = 10): [Post!]!
  }

  type Mutation {
    createPost(title: String!, authorId: ID!): Post!
  }
`

// 2. DataLoader:把 N+1 合并成 1+1
const createLoaders = () => ({
  authorLoader: new DataLoader(async (ids: readonly string[]) => {
    const rows = await db.users.findMany({ where: { id: { in: [...ids] } } })
    const byId = new Map(rows.map((r) => [r.id, r]))
    return ids.map((id) => byId.get(id) ?? null)
  }),
  postLoader: new DataLoader(async (ids: readonly string[]) => {
    const rows = await db.posts.findMany({ where: { authorId: { in: [...ids] } } })
    const grouped = new Map<string, Post[]>()
    for (const row of rows) {
      grouped.set(row.authorId, [...(grouped.get(row.authorId) ?? []), row])
    }
    return ids.map((id) => grouped.get(id) ?? [])
  }),
})

// 3. Resolver:字段级解析
const resolvers = {
  Query: {
    user: (_: unknown, args: { id: string }) => db.users.findById(args.id),
    posts: (_: unknown, args: { limit: number }) => db.posts.findMany({ take: args.limit }),
  },
  User: {
    posts: (user: User, _: unknown, ctx: Context) => ctx.loaders.postLoader.load(user.id),
  },
  Post: {
    author: (post: Post, _: unknown, ctx: Context) => ctx.loaders.authorLoader.load(post.authorId),
  },
  Mutation: {
    createPost: (_: unknown, args: { title: string; authorId: string }) =>
      db.posts.create({ data: args }),
  },
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7)],
  introspection: process.env.NODE_ENV !== 'production',
})

const { url } = await startStandaloneServer(server, {
  context: async () => ({
    // 每个请求一个独立的 loader 实例,避免跨请求缓存污染
    loaders: createLoaders(),
    currentUser: await resolveCurrentUser(),
  }),
  listen: { port: 4000 },
})

console.log(`🚀 Server ready at ${url}`)

这段代码体现的几个关键点:


七、什么时候别用 GraphQL

GraphQL 不是 REST 的升级版,它是一套换了一种权衡的方案。以下场景,硬上 GraphQL 通常是在给自己挖坑:

场景 为什么不合适 替代
简单 CRUD、字段固定 引入 Schema + Resolver 是纯开销 REST
强依赖 HTTP 缓存/CDN 单一 POST 端点让 CDN 缓存失效 REST + Cache-Control
文件上传/二进制流 GraphQL 对 multipart 支持别扭 REST / 直传 OSS
团队不熟悉、缺工具链 复杂度、调试成本上升 先 REST,成熟后再演进
后端是第三方、无法改 GraphQL 需要服务端改造 客户端 BFF 层转换
写密集、实时要求极高 Mutation 串行、订阅有额外成本 REST + WebSocket

一个更务实的判断标准:如果客户端形态单一、字段稳定、且你能控制服务端缓存,REST 完全够用;只有当"多个客户端形态、字段差异大、前端迭代快、想要按需取数"这些需求同时出现时,GraphQL 的收益才盖过它的运维成本。

还有一个折中方案值得提:BFF(Backend for Frontend)。GraphQL 服务只作为聚合层,内部把多个 REST 服务的数据拼起来,暴露一个图给前端。这样既拿到了"一次请求拿全"的好处,又不用重写底层服务——很多大厂的第一版 GraphQL 就是这么落地的。


结语

回到开头那个四请求的首页。GraphQL 确实能一次拿全,但它替你省下的网络往返,会以"服务端批处理""缓存设计""查询成本治理"三笔账的形式还回来。所以选型的核心不是"哪个技术更新",而是你愿意承担哪一种复杂度:

code
REST 的复杂度:     客户端多轮请求、over/under-fetching、版本管理
GraphQL 的复杂度:  服务端 N+1、缓存自建、查询成本与安全治理

判断清单,照着过一遍:

code
□ 是否多个客户端(App/Web/小程序)复用同一后端?  → 是,GraphQL 加分
□ 字段差异大、前端迭代快,需要按需取数?          → 是,GraphQL 加分
□ 是否强依赖 CDN/HTTP 缓存、读多写少?            → 是,REST 加分
□ 团队是否熟悉 GraphQL 的缓存/DataLoader/权限?   → 否,先别上
□ 是否只是简单 CRUD?                             → 是,REST 就够

技术没有银弹,只有"当下这组约束下,哪种代价更便宜"。把 GraphQL 当工具,而不是当信仰——这才是实战该有的态度。