GraphQL Schema 设计:类型、解析与 N+1
GraphQL 的 schema 就是 API 的契约——设计好 schema 比写好 resolver 更重要。本文讲清 schema 设计原则(类型优先/描述/分页 Connection/枚举 vs 字符串)、resolver 的 DataLoader 解决 N+1、schema 版本化与废弃,以及"什么时候用 GraphQL 什么时候别用"。
本文要点
- 主题聚焦:Next.js、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
GraphQL Schema 设计:类型、解析与 N+1
写过 GraphQL 的人大致分两段心态:刚上手时觉得"前端想查啥查啥,真爽",踩过半年坑后才发现——真正决定一个 GraphQL 服务能不能长期活下去的,不是 resolver 写得多花哨,而是 schema 设计得好不好。
REST 的契约藏在代码和文档里,能拖、能糊弄;GraphQL 的契约就是 schema 本身,它被类型系统强约束、被客户端直接消费、被工具链(Codegen、Playground、ESLint)逐字校验。schema 一旦上线就很难改,一个随手命名的字段能让你在未来三年里每次 code review 都被点名。
这篇文章讲清六件事:schema 为什么是契约、类型怎么设计才精确、分页为什么必须 Connection、N+1 怎么用 DataLoader 治、版本化怎么演进才安全,以及——什么时候根本不该上 GraphQL。代码用 SDL(Schema Definition Language)+ TypeScript resolver,对照着看最直观。
一、Schema 即契约:先设计,再写 resolver
很多团队的 GraphQL 落地流程是这样的:DBA 建表 → 后端照着表写 type → 顺手写 resolver → 上线。三个月后 schema 长成了数据库的形状,而不是业务和前端的形状。这是 GraphQL 走向腐化的第一个、也是最大的根因。
正确顺序是:先画 schema,再谈实现。 schema 不是数据库的投影,是能力的声明。它回答的是"客户端能做什么",而不是"数据库里有什么表"。这两件事在 REST 里经常被混为一谈,在 GraphQL 里必须分开。
来看一个反例,典型的"照表抄 type":
# 反例:把数据库表结构原样搬上来
type User {
id: Int!
first_name: String!
last_name: String!
email: String!
pwd_hash: String!
created_at: String!
is_deleted: Int!
}
type Query {
getUserById(id: Int!): User
getAllUsers: [User!]!
}
这段 schema 看起来"能用",但它把至少五个坑埋进了契约:
first_name/last_name用 snake_case,违反 GraphQL 官方命名规范(必须 camelCase)。pwd_hash把敏感实现细节泄漏给客户端。created_at用String,丢失了精度和时区信息。is_deleted用Int装布尔,语义靠口头约定。getAllUsers没分页,数据量一大就炸。
这些都不是"resolver 里处理一下就行"的小问题——它们是契约,会被前端依赖、被文档生成器渲染、被 codegen 转成 TypeScript 类型。改一个字段名就是一次破坏性变更。
同样的业务,认真设计过的 schema 长这样:
type User {
id: ID!
displayName: String!
email: EmailAddress!
createdAt: DateTime!
status: UserStatus!
}
enum UserStatus {
ACTIVE
SUSPENDED
DELETED
}
type Query {
user(id: ID!): User
users(first: Int = 20, after: String): UserConnection!
}
区别不是"好不好看",是"能不能演进"。后者把实现细节(密码哈希、软删除标记)关在后端,把语义(status 枚举)显式声明,把可空性、分页、标量精度全部固化进契约。半年后要加字段、改实现、换数据源,前端一行代码不用动。
心法:schema 是给客户端看的,不是给数据库画的。 设计 schema 时假装你不知道数据库长什么样,只问一个问题——"前端要完成这个业务,需要哪些能力?"
二、类型设计:精确优于随意
GraphQL 的类型系统比 TypeScript 还严格,但它不会替你思考。类型给得越精确,客户端越安全,文档越清晰,前后端吵架越少。这一节讲四条硬原则。
1. 枚举优于字符串:把"约定"变成"约束"
字符串类型是 schema 设计里的万能垃圾桶——什么都能往里塞,但什么也保证不了。
# 反例:status 是个黑洞
type Order {
id: ID!
status: String!
}
客户端拿到 status: "paid",它怎么知道合法值有哪些?看文档?问后端?看代码?GraphQL 给了你 enum,就是为了消灭这种不确定性:
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELED
REFUNDED
}
type Order {
id: ID!
status: OrderStatus!
}
enum 的好处是结构性的:
- 编译期校验:客户端 query 里写错枚举值,构建阶段直接报错。
- 类型推导:codegen 生成的 TS 类型是
'PENDING' | 'PAID' | ...,联合类型自动收窄。 - 文档自洽:GraphiQL / Apollo Studio 里 enum 自带可选值列表,文档永远和代码同步。
- 演进可控:新增枚举值是非破坏性变更(只要 resolver 能处理),删值才是破坏性的。
只有两种情况允许用 String:一是值域真的开放(用户名、自由文本),二是用 GraphQL 没法表达的复杂结构(JSON blob,此时用 JSON 标量并写清楚 description)。 "现在只有两三种值,先写 String 以后再说"——这个"以后"永远不会来。
2. 描述就是文档:description 是一等公民
GraphQL 有内置的文档系统,它的载体就是 description 字段。不写 description 的 schema 等于没有文档,而且比没有文档更糟——因为它看起来有文档。
# 反例:客户端只能猜
type Product {
price: Float!
stock: Int!
sku: String!
}
price 是含税还是不含税?币种呢?stock 是可售库存还是实物库存?sku 是唯一的吗?这些全得问人。
"商品的可售信息。价格均为含税价,币种见 currency 字段。"
type Product {
"含税售价,单位为 currency 指定的货币的最小货币单位(分)。"
price: Int!
"价格币种,ISO 4217 三字母代码,如 CNY、USD。"
currency: CurrencyCode!
"当前可售库存(已扣除预占),不会为负。"
stock: Int!
"商品唯一编码,全局唯一,可用于去重。"
sku: String!
}
写 description 的几条实操规则:
- 字段、参数、枚举值、类型都要写,缺一不可。枚举值尤其容易被忽略。
- 用"约束式"语言:说清楚单位、范围、是否唯一、是否可空语义、时区、格式。
- 把"什么时候返回这个值"写清楚——可空字段尤其要说明什么情况下为 null。
- description 支持 Markdown,复杂字段用列表和代码块。
工具链会把这些 description 直接渲染进 Playground、生成进 codegen 的 JSDoc、显示在 Apollo Studio 的 Explorer 里。一份 description,三处消费,零额外维护成本。 这是 GraphQL 相对 REST 最被低估的优势之一。
3. 命名:驼峰是法律,输入输出要分家
GraphQL 的命名规范不是建议,是规范——所有标识符(字段名、参数名、枚举值除外)必须 camelCase,类型名必须 PascalCase。enum 值惯例全大写。这条线一旦越过,整个生态的工具都会跟你别扭:codegen 转出来的 TS 字段名带下划线,ESLint 规则报警,客户端写起来十分别扭。
# 反例:snake_case 字段
type Article {
published_at: DateTime!
author_id: ID!
}
# 正例:camelCase
type Article {
publishedAt: DateTime!
authorId: ID!
}
注意:DB 列名是 snake_case 没关系,resolver 里做一层映射即可。schema 永远是 camelCase,这是面向客户端的契约,不是数据库的镜像。
第二条命名原则更关键——输入类型和输出类型必须分开。新手最爱犯的错是用同一个 type 既做返回又做入参:
# 反例:Input 和 Output 混用
input UserInput {
id: ID! # 创建时哪来的 id?
createdAt: DateTime! # 客户端凭什么设置创建时间?
status: UserStatus!
displayName: String!
email: EmailAddress!
}
User 作为返回类型有 id、createdAt,但创建用户时这两字段根本不该由客户端传。正确的做法是给每个写操作定义专门的 input 类型:
input CreateUserInput {
displayName: String!
email: EmailAddress!
password: String!
}
input UpdateUserInput {
displayName: String
email: EmailAddress
status: UserStatus
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
input 类型的字段可空性反映了"是否必须传",Update 里所有字段可选表示"部分更新"。这套 input/output 分离的纪律看起来啰嗦,但它是 schema 能长期演进的基石。
4. 标量要精确:别什么都塞 String
GraphQL 内置五个标量:Int、Float、String、Boolean、ID。但生产级 schema 几乎都会自定义标量,因为内置的太粗:
scalar DateTime
scalar EmailAddress
scalar URL
scalar UUID
scalar Decimal
scalar JSON
自定义标量的意义不只是"换个名字",关键是它可以挂载解析逻辑和校验规则。DateTime 强制 ISO 8601 格式,Decimal 强制用字符串承载避免浮点精度丢失(金额永远不要用 Float!),EmailAddress 在入口就校验格式。这些约束写在 schema 里,所有客户端共享,比每个 resolver 里手写校验强一万倍。
type Money {
"金额,单位为分,避免浮点精度问题。"
amount: Int!
currency: CurrencyCode!
}
金额这种场景,用 Int(最小货币单位)+ currency 枚举,比 Float 安全得多。99.99 元存成 9999,前端格式化展示。这是后端基本功,但在 GraphQL 里因为类型显式,更要做对。
三、分页:Connection 是工业标准
只要一个列表字段可能变长,就必须分页。不分页的 [User!]! 是一颗定时炸弹——今天一万条,明年一百万条,某天前端一次 query 把整个表拉下来,数据库直接 OOM。
GraphQL 的分页有事实标准,叫 Connection 模式(来自 Relay 规范),长这样:
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int = 20, after: String, last: Int, before: String): UserConnection!
}
第一次看这套结构会觉得啰嗦——为什么不直接 [User!]! 加 offset?原因有三:
1. cursor 比 offset 稳健。 offset 分页在数据变动时会错位:你翻到第 2 页时别人新增了一条,第 2 页显示的还是原来的第 2 页内容,会出现重复或遗漏。cursor 是基于某条记录的游标(通常是 id 或 createdAt 的编码),即使中间有插入/删除,"这条之后取 20 条"的语义始终稳定。
2. edges 给了挂载点。 Edge 不只是 node 的包装,它本身可以承载"这个节点在当前上下文里的元信息"。比如查询某用户的文章列表,ArticleEdge 可以带 cursor 和 relationMeta(比如这篇文章在该用户的发布顺序、是否置顶),这是直接返回 [Article] 表达不了的。
type ArticleEdge {
node: Article!
cursor: String!
"该文章在作者作品列表中的序号(从 1 开始)。"
rankInAuthor: Int!
"是否为作者的置顶文章。"
isPinned: Boolean!
}
3. pageInfo 是标准化的翻页状态。 hasNextPage / endCursor 这套字段被所有 GraphQL 客户端组件(Apollo、Relay、urql)原生支持,前端拿到的分页 UI 逻辑可以完全复用。你一旦自己发明 { items, offset, total } 这种私货分页,每一个消费方都得重写一遍。
实话说:不要发明自己的分页格式。 哪怕只有 10 条数据,也用 Connection。一致性带来的工具链复用和价值,远大于"少写几个 type"的省事。等到列表变长那一天,你已经天然支持游标分页,不用重构。
简单实现参考(Node 端):
const DEFAULT_FIRST = 20;
const MAX_FIRST = 100;
export const resolvers = {
Query: {
users: async (_parent, args, ctx) => {
const limit = Math.min(args.first ?? DEFAULT_FIRST, MAX_FIRST);
const after = args.after
? Buffer.from(args.after, 'base64').toString('utf8')
: null;
// 多取一条判断 hasNextPage
const rows = await ctx.prisma.user.findMany({
take: limit + 1,
skip: after ? 1 : 0,
cursor: after ? { id: after } : undefined,
orderBy: { id: 'asc' },
});
const hasNextPage = rows.length > limit;
const nodes = hasNextPage ? rows.slice(0, limit) : rows;
const endCursor = nodes.at(-1)?.id;
return {
edges: nodes.map((node) => ({
node,
cursor: Buffer.from(node.id).toString('base64'),
})),
totalCount: await ctx.prisma.user.count(),
pageInfo: {
hasNextPage,
hasPreviousPage: Boolean(after),
startCursor: nodes[0] ? Buffer.from(nodes[0].id).toString('base64') : null,
endCursor: endCursor ? Buffer.from(endCursor).toString('base64') : null,
},
};
},
},
};
注意 take: limit + 1 这一小技巧——多取一条用来判断 hasNextPage,比单独 count 一次便宜得多。cursor 用 base64 包一层,是为了让客户端把它当不透明字符串处理,不鼓励它解码猜结构。
四、N+1:resolver 的隐形杀手
GraphQL 的执行模型是"字段级解析"——每个字段都可以有自己的 resolver。这给了极大的灵活性,但也埋下了 GraphQL 最经典的性能坑:N+1 查询。
看一段几乎所有人都会写的 resolver:
const resolvers = {
Query: {
articles: () => db.article.findMany({ take: 20 }), // 1 次查询
},
Article: {
author: (parent) => db.user.findUnique({ where: { id: parent.authorId } }), // 每篇文章 1 次
},
};
前端 query:
query {
articles {
title
author {
name
}
}
}
执行过程:
articlesresolver 跑 1 次 SQL,返回 20 篇文章。- 对每篇文章,
Article.authorresolver 各跑 1 次 SQL 查作者。 - 总共 1 + 20 = 21 次 SQL。如果 author 里再展开
author.posts,就是 1 + 20 + 200。
这就是 N+1。它不是 bug,是 GraphQL 字段解析模型的天然副产物——每个 resolver 独立、字段并行执行,没人统筹。
REST 里你不会遇到这个问题,因为 REST 通常是"一次请求一次 join,自己写 SQL"。GraphQL 把查询结构交给了前端,join 的责任就落到了后端 resolver 层。治这个病的标准答案是 DataLoader。
DataLoader:请求级批量 + 缓存
DataLoader 是 Facebook 为这个问题专门造的轮子,核心两件事:
- 批量(batch):在一个事件循环 tick 里收集所有
load(id)调用,一次性传给 batch function。 - 缓存(cache):同一个请求内,同一个 key 只查一次。
改造上面的 resolver:
import DataLoader from 'dataloader';
export function createLoaders(ctx) {
return {
userLoader: new DataLoader<string, User>(async (ids) => {
// ids 是这一个 tick 收集到的所有 user id,去重后一次性查
const rows = await db.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((u) => [u.id, u]));
// 必须按入参顺序返回,缺失的用 null 占位
return ids.map((id) => byId.get(id) ?? null);
});
};
}
const resolvers = {
Query: {
articles: async (_p, _a, ctx) => {
const articles = await db.article.findMany({ take: 20 });
// 主动把 authorId 收集起来,触发批量
for (const a of articles) ctx.loaders.userLoader.prime(a.authorId, undefined);
return articles;
},
},
Article: {
author: (parent, _a, ctx) => ctx.loaders.userLoader.load(parent.authorId),
},
};
执行过程变成:
articles跑 1 次查 20 篇文章。- GraphQL 并行执行 20 个
Article.authorresolver,每个调用userLoader.load(authorId)。 - 这些 load 都在同一个 tick 收集,DataLoader 把 20 个 id(去重后可能只有 5 个不同作者)合并成 1 次
WHERE id IN (...)查询。 - 总共 1 + 1 = 2 次 SQL,无论文章有多少篇。
这就是 N+1 的标准解法。几个工程上的关键点:
1. DataLoader 必须是请求级的,不能全局单例。 缓存是"这次请求内别重复查",跨请求复用缓存会导致数据脏、权限串。每个请求创建新的 loader 实例,挂到 context 上:
// 在 GraphQL context 工厂里
export function buildContext({ req }) {
return {
loaders: createLoaders(),
user: req.user,
prisma,
};
}
2. batch function 的返回必须严格按入参顺序。 DataLoader 靠下标对齐结果,顺序错了会张冠李戴。上面 ids.map((id) => byId.get(id) ?? null) 这一步是新手最容易写错的——直接 return rows 是不行的,因为数据库不保证返回顺序。
3. 缓存策略要清楚边界。 DataLoader 默认同 key 同请求只查一次,这正好。但如果你在 mutation 之后还要读,记得清缓存或重建 loader:
Mutation: {
updateUser: async (_p, args, ctx) => {
const user = await db.user.update({ where: { id: args.id }, data: args.input });
ctx.loaders.userLoader.clear(args.id); // 清掉旧值
return user;
},
}
4. 不只是数据库。 DataLoader 也能批量化任何异步来源:RPC 调用、第三方 API、甚至内部 service。凡是"N 个独立请求能合并成 1 个批量请求"的场景,都用得上。比如调一个 /users/batch?ids=... 的 HTTP 接口,思路完全一样。
最后强调一句:N+1 不会在开发环境暴露,因为 dev 数据量小、看起来都正常。 它会在生产环境、在某个热门列表页、在某个周五下午突然变成性能事故。上线前用 EXPLAIN 或加日志统计每个请求的 SQL 次数,是必要的护栏。Apollo Server 有 @apollo/server/plugin 可以记录 resolver 耗时,配 DataLoader 的批量日志,基本能定位所有 N+1。
五、版本化与演进:只加不删,@deprecated 优雅退场
GraphQL 没有 REST 那种 /v2/users 的版本号概念。官方哲学是"无版本化 schema"——通过持续演进来避免破坏性变更,目标是一个 schema 用十年。这听起来很美,前提是你严格遵循两条铁律:
铁律一:只加,不删。 任何已经被前端依赖的字段,都不能直接删除或改类型。String 改 Int、字段改名、可空改非空(更严格),都是破坏性变更。
铁律二:用 @deprecated 标记退场。 当一个字段要下线,不能直接删——给它打上 @deprecated,告诉所有工具链"别再用这个了",同时提供替代方案:
type User {
id: ID!
name: String! @deprecated(reason: "使用 displayName 代替。name 会在 2026-10-01 下线。")
displayName: String!
}
@deprecated 的效果是结构性的:
- Apollo Studio、GraphiQL 等工具会把该字段标灰、加删除线。
- ESLint / codegen 规则可以警告甚至禁止新代码使用 deprecated 字段。
- GraphQL introspection 里有专门的
isDeprecated和deprecationReason字段,可以写脚本统计全 schema 的废弃情况。
这是一套可观测的退场机制——你能精确知道还有多少 query 在用 deprecated 字段、是谁在用、什么时候能安全删除。Apollo Server 可以接 usage reporting,按字段统计真实调用次数;当某个 deprecated 字段连续 30 天零调用,就可以放心删了。
典型演进场景
场景 1:字段改名。 name 要改成 displayName。先加 displayName(非破坏),再把 name 标 deprecated 并让它的 resolver 直接返回 displayName 的值(保持兼容)。等监控显示没人用 name 了,再删。
// 过渡期:name 仍然能用
const resolvers = {
User: {
displayName: (user) => user.name,
name: (user) => user.name, // 等价 alias,仅为兼容
},
};
场景 2:字段类型变更。 price: Float 要改成 price: Money。GraphQL 不允许直接改字段类型,只能新加字段:
type Product {
price: Float! @deprecated(reason: "使用 moneyPrice 代替,避免浮点精度问题。")
moneyPrice: Money!
}
场景 3:枚举值调整。 新增枚举值是非破坏性的(客户端可能没见过这个值,但不会编译失败);删除枚举值是破坏性的。所以枚举要"加而不删",老值 deprecated 但保留。
enum OrderStatus {
PENDING
PAID @deprecated(reason: "使用 PROCESSING 代替,统一状态机。")
PROCESSING
SHIPPED
DELIVERED
CANCELED
}
重要:客户端要处理"未知枚举值"。 新增的枚举值老客户端没见过,GraphQL 客户端 codegen 时建议开启"未知值兜底成 null / 默认值"的策略,避免新值导致前端崩溃。这是 schema 演进的连带责任。
Schema 治理:把规则写成检查
人工遵守演进规则是不靠谱的,团队一大就会有人破坏契约。把规则固化成自动化检查:
graphql-eslint:强制字段必须有 description、禁止String类型当枚举用、命名规范检查。- schema diff:CI 里对比生产 schema 和本次 PR 的 schema,发现破坏性变更(字段删除、类型收窄、枚举删值)直接 fail。
- deprecated 监控:定时跑脚本统计 deprecated 字段的调用量,输出下线候选清单。
# 用 graphql-inspector 做 schema diff
npx graphql-inspector diff 'git:origin/main:schema.graphql' './schema.graphql' \
--rule 'breaking'
这一步是 GraphQL 工程化成熟度的分水岭。没有 schema 治理的 GraphQL 项目,最终都会变成谁也不敢动的屎山。
六、什么时候用 GraphQL,什么时候别用
讲了这么多 GraphQL 的好处,但必须说清楚一件事:GraphQL 不是银弹,很多场景用了反而更糟。 一个资深工程师的价值,不是会用什么,是知道什么时候不用什么。
该用 GraphQL 的场景
1. 多客户端、多形态消费同一份数据。 典型是同时有 Web、iOS、Android、小程序,每个端要的字段不一样。REST 在这种场景下要么过度返回(移动端浪费流量),要么爆炸式拆接口(/users/:id/profile、/users/:id/avatar、/users/:id/brief)。GraphQL 让客户端自己声明要什么,一份 schema 服务所有端,收益巨大。
2. 前端需要自治、需要灵活组合数据。 比如 dashboard 类产品,不同页面要组合不同的数据切片;再比如内容平台,文章详情页要带作者信息、相关推荐、评论摘要。GraphQL 让前端在不发版的情况下调整数据形状,后端不用为每个新页面加接口。
3. 聚合多个后端服务。 BFF(Backend for Frontend)模式里,GraphQL 作为聚合层,把多个微服务/RPC 的数据拼成一个对前端友好的视图。resolver 层调多个下游,前端只看到一棵树。这种"数据编排"场景,GraphQL 比 REST 聚合层优雅得多。
4. 强类型 + 工具链价值能兑现。 如果团队会用 codegen、会用 Apollo Studio、会写 schema 治理,GraphQL 的类型系统能把前后端的协作效率拉高一截。类型即契约、文档即代码、改动可检测,这些是实打实的工程收益。
别用 GraphQL 的场景
1. 简单 CRUD,REST 完全够。 一个内部 admin 后台,五张表十几个接口,前后端就你一个人写——上 GraphQL 是自找麻烦。你要为它搭 server、配 loader、写 schema、维护 codegen,换来的灵活性根本没人消费。REST + OpenAPI 在这个量级又快又稳。
2. 对性能极敏感、查询模式固定的场景。 GraphQL 的解析层、loader 层、字段级 resolver 都有开销。如果是高频、固定形状的查询(比如日志上报、埋点写入、支付回调),直接 REST 或 gRPC 更快。GraphQL 的查询语言解析本身就不便宜,超高频场景这个成本会被放大。
3. 团队不熟、没有治理能力。 GraphQL 的复杂度是真实存在的——schema 设计、N+1、版本化、权限下沉到字段层、复杂度限制(防恶意嵌套 query),每一项都需要专门的知识。如果团队没人踩过这些坑,强行上 GraphQL 会演变成"看起来很潮但谁也不敢改"的状态。REST 的简单是有价值的,不要为了用而用。
4. 强 HTTP 语义依赖的场景。 GraphQL 全走 POST /graphql,丢失了 HTTP 的语义层——CDN 缓存(GET + Cache-Key)、状态码(401/403/404 全是 200)、ETag、内容协商。如果有强 HTTP 缓存需求(公开内容、CDN 边缘缓存),要么用 GraphQL 的 persisted query + GET,要么直接 REST 更省心。BFF 层混用(GraphQL 查读 + REST 写入)也是常见做法。
5. 写多读少、事务密集。 GraphQL 的 mutation 模型相对粗糙,复杂的事务、跨表一致性、幂等控制,在 REST/RPC 里反而表达得更清晰。GraphQL 的优势在"读"侧——灵活组合、按需取数;"写"侧的收益不大。
一个实用的判断清单
选 GraphQL 之前问自己这五个问题:
- 会不会有 3 个以上客户端消费同一份数据?
- 前端是否会频繁调整要取的字段(发版独立于后端)?
- 数据是否来自多个需要聚合的下游?
- 团队是否有能力做 schema 治理(lint、diff、codegen)?
- 是否能接受为 N+1、复杂度控制投入专门的工程化成本?
4 个以上"是",上 GraphQL 收益明显;2 个以下,REST 更划算。 中间地带可以考虑"读用 GraphQL、写用 REST"的混合架构,不要强求统一。
技术选型最忌讳"新就是好"。GraphQL 解决了一类问题(数据灵活组合、类型契约、前端自治),同时引入了另一类问题(N+1、性能开销、治理复杂度)。清楚自己买的是什么单,比追新重要得多。
小结
GraphQL 的核心张力,始终在"灵活性"和"约束"之间。schema 是约束的载体,写得好,灵活性是红利;写得糊,灵活性就是负债。几条要点收束:
- Schema 是契约,设计优先于 resolver。 先画 schema 再写实现,把 schema 当成给客户端的能力声明,而不是数据库的镜像。
- 类型精确到枚举、标量、可空性。 enum 永远优于 string,自定义标量优于万能 String,input/output 类型必须分离。
- 分页用 Connection,别造私货。 cursor 比 offset 稳,edges 给挂载点,pageInfo 是全工具链的通用语。
- N+1 是 GraphQL 的默认病,DataLoader 是标配解药。 loader 必须请求级、batch 函数按序返回、上线前用日志统计 SQL 次数做护栏。
- 演进靠"只加不删 + @deprecated",靠 schema 治理保落地。 无版本化是理想,CI 里做 schema diff 和 deprecated 监控是现实。
- 选型要冷血:多端、聚合、前端自治才上 GraphQL;简单 CRUD、强 HTTP 语义、团队不熟就别碰。
一句话送给你:schema 写得好,resolver 是体力活;schema 写得烂,resolver 是救火队——而救火队总有灭不完的火。 设计 schema 时多花的一小时,能在未来三年省下无数个深夜。
Related