GraphQL 常被描绘成 API 设计的革命性方法。与其与固定的、服务端定义的端点交互,它让你发查询、在单个请求里精确取回你需要的数据。虽然这没错,但实践中 GraphQL 和 REST 都传输 HTTP 请求、接收 JSON 结果。真正的差异更微妙——也更有趣。
- REST——资源身份就是 URL;服务端决定响应形状;读用 GET,写用 POST/PUT/PATCH/DELETE。
- GraphQL——客户端在单个查询里精确指定返回哪些字段;通过类型化 schema 把资源形状与你如何取它解耦。
- 过度获取 vs 不足获取——REST 常返回过多字段或需要多次往返;GraphQL 用单个精确查询同时解决两者。
- 内省(Introspection)——GraphQL schema 自描述;客户端能查询 schema 本身。REST 需要 Swagger/OpenAPI 等外部工具。
- 用 GraphQL应对复杂、深层嵌套的数据需求(移动应用、商品页);用 REST应对简单 CRUD API、公开 API,或 HTTP 缓存关键时。
GraphQL 和 REST 没看起来那么不同。GraphQL 引入了微妙的修改——主要围绕资源如何被描述和获取——显著改善了开发者体验。
资源(Resources)
REST 的核心概念是资源。每个资源由一个 URL 唯一标识,你通过对该 URL 发 GET 请求访问它。
// GET /api/movies/123
{
"title": "Oppenheimer",
"director": {
"name": "Christopher Nolan",
"dob": "1970/07/30"
},
"released": "2023/08/30"
}
GraphQL 把资源形状与你如何取它解耦。你独立于检索方法在 schema 里定义类型:
type Movie {
id: ID
title: String
released: Date
director: Director
}
type Director {
id: ID
name: String
dob: Date
movies: [Movie]
}
type Query {
movie(id: ID!): Movie
director(id: ID!): Director
}
API 设计
REST API 被描述为一份端点目录。形状是线性的——一个你可调用的扁平 URL 列表:
GET /movies/:id
GET /director/:id
GET /movies/:id/review
POST /movies/:id/review
GraphQL 转而用 schema——API 里一切可访问内容的类型化图。读操作用 Query,写用 Mutation:
type Query {
movie(id: ID!): Movie
director(id: ID!): Director
}
type Mutation {
addMovieReview(input: AddMovieReviewInput): Review
}
关键差异速览
| 方面 | GraphQL | REST |
|---|---|---|
| 资源身份 | 与你如何取它分离 | URL 就是资源身份 |
| 响应形状 | 客户端决定返回哪些字段 | 服务端决定包含什么 |
| 关联数据 | 通过 schema 遍历一个请求搞定 | 多个请求或自定义参数 |
| 读 vs 写 | Query / Mutation 关键字 | GET / POST / PUT / DELETE 动词 |
| Schema / 文档 | 内建内省 | 外部工具(Swagger、OpenAPI) |
请求处理器 vs 解析器
REST 里,每个端点映射到一个处理函数。这是个简单的 Express 例子:
// REST——每个端点一个处理器
app.get('/hello', function(req, res) {
res.send('Hello World!')
})
// GraphQL——每个字段一个解析器
const resolvers = {
Query: {
movie: (parent, { id }) => getMovieById(id),
director: (parent, { id }) => getDirectorById(id),
}
}
最大的实际差异:GraphQL 让你在单个请求里取深层嵌套的关联数据,而 REST 通常需要多次往返或过度获取。对复杂前端数据需求,GraphQL 胜。对客户端可预测的简单 CRUD API,REST 常更清爽。
解析器执行模型
理解 GraphQL 实际如何运行你的查询——逐字段地——对写出高性能服务器至关重要。请求到达时,GraphQL 运行时把查询文档解析成抽象语法树,对照 schema 校验,然后通过为每个被选字段调用一个解析器函数来执行它。解析器是签名为 (parent, args, context, info) 的纯函数:parent 是父字段的解析值,args 是查询参数,context 是请求作用域对象(auth token、DataLoader 实例、DB 连接),info 暴露当前字段的 AST。
执行是深度优先的:根 Query 字段先解析,然后运行时扇出在同一深度层级内并行解析每个子字段。这意味着同层级的兄弟字段能并发解析,但子字段在其父返回值前无法开始。实际含义是深查询树会链式调用解析器,而解析器里任何同步 IO 都阻塞整个链。
const resolvers = {
Query: {
// 根解析器——收到 null parent、查询 args
movies: async (parent, args, context) => {
return context.db.query('SELECT * FROM movies LIMIT ?', [args.limit])
},
},
Movie: {
// 字段解析器——parent 是上面的 movie 行
director: async (movie, args, context) => {
return context.db.query('SELECT * FROM directors WHERE id = ?', [movie.directorId])
},
},
}
N+1 问题
解析器模型有个众所周知的失效模式。若你查 100 部电影的列表、每部有一个 director 字段,GraphQL 运行时会调 Movie.director 解析器100 次——每个电影对象一次。每次调用发一个新数据库查询。那是列表 1 个查询加 directors 100 个查询:经典的 N+1 问题。规模化时这把一个亚毫秒操作变成数百次顺序往返。
GET /movies 的 REST 处理器通常做单个 JOIN 或工程师控制下手动批量查找。GraphQL 的每字段解析器架构使 N+1 模式成为默认结果,若你不主动对抗它——每个嵌套字段都是潜在地雷。
DataLoader:批处理与请求级缓存
Facebook 的 DataLoader 库(现已是每个 GraphQL 生态的标准)用两个协同机制解决 N+1。首先,它把单个事件循环 tick 内所有 load 调用批处理成你提供的单个批函数——所以 100 次单独的 directorLoader.load(id) 调用变成对你的批函数一次带 100 个 ID 数组的调用。其次,它在请求期间记忆化结果,所以从查询不同部分加载同一 director ID 两次只打数据库一次。
关键地,DataLoader 实例应在 context 工厂里每请求创建——跨请求共享 DataLoader 会在用户间泄露数据。
import DataLoader from 'dataloader'
// 批函数:每 tick 带所有累积的 key 调用一次
const batchDirectors = async (directorIds) => {
const rows = await db.query(
'SELECT * FROM directors WHERE id = ANY(?)',
[directorIds]
)
// DataLoader 要求结果顺序与 key 相同
const byId = Object.fromEntries(rows.map(r => [r.id, r]))
return directorIds.map(id => byId[id] ?? new Error(`Director ${id} not found`))
}
// 在 Apollo 的 context 工厂里每请求创建
const server = new ApolloServer({
resolvers: {
Movie: {
director: (movie, _, context) =>
context.directorLoader.load(movie.directorId), // 批处理了!
},
},
context: () => ({
directorLoader: new DataLoader(batchDirectors), // 每请求新建
}),
})
有了这套,单个请求里 100 次 directorLoader.load(id) 调用合并成一个带 100 个 ID 的 SQL 查询。若同一 director ID 出现多次(同导演的多部电影),记忆化层返回缓存值而完全不碰数据库。结果:电影 1 个查询 + 所有 director 1 个批量查询,无论结果集大小。
缓存:GraphQL 最难的问题
REST 免费继承几十年的 HTTP 缓存基础设施。GET /movies/123 响应能被浏览器、代理和 CDN 按 URL 为键缓存;Cache-Control、ETag、Last-Modified 头自动接好一切。GraphQL 抛弃了这个,因为几乎所有 GraphQL 请求都经 POST 发到单个端点——HTTP 缓存默认把所有 POST 当不可缓存。这不是小问题;光 CDN 缓存就能在设计良好的 REST API 上吸收 90%+ 的读流量。
第 1 层——应用级缓存
最可移植的方案是在数据层而非 HTTP 层缓存。DataLoader 已提供请求级记忆化(上面描述)。对跨请求缓存,解析器能在打数据库前检查 Redis。这与 REST 用的 cache-aside 模式一样,只是移进解析器而非 controller。
更 GraphQL 原生的方法是用缓存提示做响应字段级缓存。Apollo Server 的 @cacheControl 指令让你给单个类型或字段标注 max-age 和 scope(PUBLIC 或 PRIVATE)。服务器计算所有解析字段中最小的缓存提示并据此发一个 Cache-Control 头——若所有字段都公开且 max-age 非零,允许共享缓存缓存响应。
type Movie @cacheControl(maxAge: 3600, scope: PUBLIC) {
id: ID!
title: String!
director: Director!
}
type Director @cacheControl(maxAge: 86400, scope: PUBLIC) {
id: ID!
name: String!
}
type Viewer @cacheControl(maxAge: 0, scope: PRIVATE) {
// 用户特定——绝不跨用户共享
watchlist: [Movie!]!
}
第 2 层——持久化查询
自动持久化查询(APQ)同时解决两个问题:它们把 POST 请求转成 GET 请求(解锁 HTTP 缓存和 CDN)并通过发查询哈希而非完整查询字符串来缩小请求体。协议分两步。客户端先发只带哈希的 GET;若服务器以前见过它,执行缓存的查询。若没,服务器返回 PersistedQueryNotFound 错误,客户端用完整查询体重试为 POST,服务器按哈希为键存储该查询供后续请求用。
# 步骤 1:客户端只发哈希(GET = CDN 可缓存)
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123"}}
# 步骤 2:哈希未找到则服务器返回错误
{ "errors": [{ "message": "PersistedQueryNotFound" }] }
# 步骤 3:客户端用完整查询体重试(POST)
POST /graphql
{ "query": "{ movies { title director { name } } }",
"extensions": { "persistedQuery": { "sha256Hash": "abc123" } } }
# 后续请求:带哈希的 GET → CDN 缓存命中
有了 APQ + @cacheControl 提示,完全公开的查询(无用户特定数据)能像 REST 一样从 CDN 边缘节点服务。问题:一个混合公开电影数据和观众观看列表的单个查询会得到最差的缓存提示(maxAge 0、PRIVATE)并完全绕过 CDN。设计查询把公开和私有数据分开,或把它们拆成两个请求。
Schema 设计最佳实践
GraphQL schema 是公共契约——改它会破坏客户端。深思熟虑的 schema 设计避免日后痛苦的迁移,并让 API 直观易用。
默认可空 vs 默认非空
GraphQL SDL 里,每个字段默认可空;! 后缀标记字段为非空。常见错误是为"性能"或人体工学过度使用 !,然后发现一个缺失的嵌套资源迫使你一路向上返回 null,把你承诺非空的字段变成运行时错误。一条有原则的规则:只在服务器总能提供时才标字段非空——主键、总被设置的时间戳、刚创建对象上的字段。可空字段发出"这可能合法地缺失"的信号。
为客户端设计,而非数据库
GraphQL schema 应反映产品的领域模型,而非数据库 schema 的 1:1 投影。若数据库有分开的 first_name 和 last_name 列但每个客户端都拼接它们,在 schema 里暴露一个 fullName 字段并在解析器里拼接。这把业务逻辑放在一处,防止客户端各自重新发明同样的转换。
Mutation 的输入类型
总是把 mutation 参数包进一个专门的 input 类型,而非内联列出参数。这让你能随时间给输入类型加字段而不改 mutation 签名,让参数列表不至于变得笨重,并让 schema 自描述 mutation 期望什么。
# 避免:内联参数在你需要加字段时会破坏
type Mutation {
createMovie(title: String!, releaseYear: Int!): Movie
}
# 推荐:命名输入类型——增量变更非破坏性
input CreateMovieInput {
title: String!
releaseYear: Int!
tagline: String # 后加的——无破坏性变更
}
type Mutation {
createMovie(input: CreateMovieInput!): CreateMoviePayload!
}
type CreateMoviePayload {
movie: Movie
errors: [UserError!] # 领域错误放载荷里,不滥用 HTTP 错误
}
Mutation 返回载荷类型
上面的模式也展示了返回一个载荷类型而非直接返回实体。这让 mutation 能在单个响应里同时返回结果实体和任何面向用户的校验错误,而不滥用 HTTP 错误层。它也留出空间加辅助数据(受影响的计数、被副作用的对象)而无破坏性变更。
分页:基于游标与 Relay Connection 规范
REST 通常通过 ?page=2&limit=20(偏移分页)或响应里的 next_cursor 字段分页。偏移分页有个众所周知的缺陷:若页间有项被插入或删除,你会看到重复或跳过项。基于游标的分页——用一个指向结果集位置的不透明指针——无论请求间是否有变更都稳定。
GraphQL 把这个编纂进 Relay Connection 规范,它已成为整个生态的非官方标准。connection 模式把节点列表包进一个携带分页元数据的类型化信封,与数据并存。
type Query {
movies(first: Int, after: String, last: Int, before: String): MovieConnection!
}
type MovieConnection {
edges: [MovieEdge]
pageInfo: PageInfo!
}
type MovieEdge {
node: Movie!
cursor: String! # 不透明 base64 位置 token
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
客户端取下一页时传 after: pageInfo.endCursor。服务器解码游标(通常是 base64 编码的行 ID 或时间戳),把它作为 WHERE id > :cursor 子句应用,并返回下一页。这无论并发插入如何都稳定。edges 包装层存在是为携带每边元数据(如用户跟随用户到用户关系边的时间),这就是为什么数据不直接是节点的扁平数组。
偏移分页支持随机访问跳页(跳到第 50 页),游标分页不能。若你的 UI 有编号页或需要"跳到第 N 页"功能,偏移分页务实上没问题——只需接受结果可能在页间移位。游标分页是无限滚动 feed、时间线,以及任何稳定性比随机访问更重要的列表的正确默认。
错误处理与部分响应
GraphQL 的错误模型与 REST 根本不同。REST 里错误通常产生非 200 状态码,响应体要么空要么是单个错误对象——整个请求要么成功要么失败。GraphQL 几乎总返回 HTTP 200,即便解析器错误发生。错误改为出现在顶层 errors 数组里,与 data 对象并存。这支持部分响应:若一个字段的解析器失败但其他成功,客户端收到所有能解析的数据,连同失败部分的错误细节。
{
"data": {
"movies": [
{ "title": "Oppenheimer", "director": { "name": "Christopher Nolan" } },
{ "title": "Dune Part Two", "director": null } // 解析器失败
]
},
"errors": [
{
"message": "Director not found",
"locations": [{ "line": 4, "column": 5 }],
"path": ["movies", 1, "director"],
"extensions": { "code": "NOT_FOUND" }
}
]
}
path 字段精确指出哪个列表项里的哪个字段失败。extensions 对象是放机器可读错误码的标准位置,客户端可据此分支——生产中绝不在 extensions 里放敏感堆栈跟踪。部分响应模型是双刃剑:客户端收到有用的部分数据,但即便 200 响应也必须总检查 errors 数组,这对从 REST 来的团队反直觉。
用户错误 vs 系统错误
生产 schema 里一个有用的模式是区分用户错误(输入校验失败、"email 已被占用")和系统错误(数据库不可用、未处理异常)。系统错误出现在顶层 errors 数组里。用户错误应建模为 mutation 载荷里的领域类型——它们是预期结果,不是异常,所以把它们编进 schema 让它们自描述且对客户端代码生成类型安全。
订阅:实时 GraphQL
REST 没有内建的服务端推送更新机制;团队把 WebSocket、SSE 或长轮询作为带外协议外挂上去。GraphQL 有一等的 Subscription 操作类型,把实时事件集成进与读写相同的 schema 和查询语言。
订阅打开一个持久连接(通常通过用 graphql-ws 协议的 WebSocket),并在订阅事件发生时流式发新结果。客户端写一个看起来与查询完全一样的订阅文档——只选它需要的字段——服务器每个事件只发那些字段。
# Schema
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newMessage(conversationId: ID!): Message!
}
# 客户端订阅——收到一个 Order 更新流
subscription TrackOrder($orderId: ID!) {
orderStatusChanged(orderId: $orderId) {
id
status
updatedAt
estimatedDelivery
}
}
服务端,订阅由一个 pub-sub 引擎支撑。Apollo Server 支持开发用的进程内 PubSub 和生产用的 Redis 或 Kafka 支撑的 pub-sub。当一个 mutation 改变订单状态,它发布到一个按 orderId 为键的 topic;订阅解析器监听那个 topic 并在把结果流给已连接客户端前按订阅 ID 过滤进来的事件。
订阅带运维开销:每个活跃订阅者持有一个 WebSocket 连接,服务器重启断开所有客户端。生产中,你通常在无状态应用服务器前放一个无状态 WebSocket 网关(如 AWS API Gateway WebSockets,或专用订阅服务器),用 Redis pub-sub 作共享事件总线。
联邦与 Schema 拼接
规模化时——尤其在微服务组织里——单个铁板一块的 GraphQL schema 成为协调瓶颈。每个想暴露数据的团队必须给同一个 schema 文件提 PR。Schema 联邦(federation)通过让每个服务拥有自己的 GraphQL 子图、而一个中心网关把它们组合成客户端当作单一 API 查询的统一超图来解决这个。
Apollo Federation
Apollo Federation(主流实现)用 SDL 指令标注类型如何跨子图共享。一个类型能在一个子图定义、在另一个子图扩展。网关检查查询计划,尽可能并行把子查询派发给合适的子图,并在响应客户端前把结果拼接在一起。
# movies-subgraph:定义 Movie,拥有它
type Movie @key(fields: "id") {
id: ID!
title: String!
released: Int!
}
# reviews-subgraph:用 reviews 字段扩展 Movie
extend type Movie @key(fields: "id") {
id: ID! @external # 由 movies-subgraph 拥有
reviews: [Review!]! # 由 reviews-subgraph 贡献
}
type Review {
id: ID!
rating: Int!
comment: String
}
当客户端查 movie { title reviews { rating } },网关把 title 发给 movies-subgraph、把 reviews 发给 reviews-subgraph(用电影的 id 作 join 的 key),然后合并结果。从客户端视角只有一个 API。每个团队独立部署自己的子图——给 Review 加字段无需与 movies 团队协调。
Schema 拼接 vs 联邦
| 方面 | 联邦(Federation) | Schema 拼接(Stitching) |
|---|---|---|
| 类型所有权 | 按子图,用 @key 显式 | 在网关用自定义转换合并 |
| 运行时耦合 | 网关启动时拉取实时子图 SDL | 在构建时或启动时拼接 schema |
| 团队自治 | 高——每个子图独立部署 | 中——合并逻辑住在中心 |
| 生态支持 | Apollo Router、GraphOS、强工具 | graphql-tools;观点更少 |
| 查询规划 | 自动分布式查询规划 | 手动 link/delegate 定义 |
| 最适合 | 有许多独立团队的大组织 | 小规模 schema 组合、渐进迁移 |
安全:限制查询复杂度
GraphQL 的灵活性就是它的攻击面。一个恶意——或仅仅粗心——的客户端能发单个查询,在服务器上生成指数级昂贵的执行。不像 REST 服务器控制每个端点的成本,GraphQL 里客户端组合查询,一个深层嵌套或循环结构的查询能触发数百万次解析器调用。
查询深度限制
最简单的保护是给查询能嵌套多深封顶。深度 10 对几乎任何真实产品用例都很宽裕;更深的几乎肯定是 bug 或攻击。深度限制强制起来很便宜——它只需在执行开始前遍历查询 AST,增加可忽略的开销。
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10), // 拒绝深于 10 层的查询
],
})
查询复杂度分析
光深度不够——一个宽查询(在深度 2 请求 1000 个字段)可能和深的一样昂贵。复杂度分析给每个字段分配数值成本,拒绝总成本超预算的查询。字段成本可以是静态的(每字段成本 1)或动态的(列表字段成本与其 first 参数成比例)。这让你能说"这个 API 会执行每请求成本最高 1000 的查询"——实质是一个同时涵盖深度和广度的预算。
import { createComplexityLimitRule } from 'graphql-query-complexity'
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1, // 叶字段成本 1
objectCost: 2, // 对象字段成本 2
listFactor: 10, // 列表乘子——first:50 成本 50×子
onCost: (cost) => {
metrics.histogram('graphql.query_cost', cost)
},
}),
],
})
限流与持久化查询白名单
除每查询分析外,在 API 网关层应用限流——按 IP 和按已认证用户的令牌桶是标准方法。在你控制所有客户端的生产 API 里(第一方移动应用或 SPA),持久化查询白名单是最强安全姿态:服务器只执行哈希在批准注册表里的查询;任意查询字符串被完全拒绝。这在生产中实质把 GraphQL 变成类型化 REST API,同时在开发中保留完整灵活性。
生产中禁用内省(它给攻击者映射你整个 schema)。设查询深度限制(10 是合理默认)。设复杂度预算。在网关按用户限流。客户端是第一方就用持久化查询白名单。绝不信任客户端提供的字段别名来绕过解析器授权——在每个触及敏感数据的解析器里检查 auth,不只在根。
版本化哲学
REST 版本化通常在 URL 层处理(/api/v1/、/api/v2/),保留前一版本直到客户端迁移。这很显式但造成维护开销:每个端点的两个版本并发运行,bug 必须在两者里修,弃用是跨所有消费者的协调练习。
GraphQL 持不同哲学立场:schema 应无版本化地演化。SDL 的弃用机制让你用 @deprecated(reason: "Use newField instead") 标记字段而不移除它们。不请求旧字段的客户端不受影响;在旧字段上的客户端在内省和工具里看到弃用警告。新字段是增量的、非破坏性的。schema 持续演化而非以版本化快照。
type Movie {
id: ID!
title: String!
releaseYear: Int!
# 旧字段保留存活,客户端被推动迁移
year: Int @deprecated(reason: "Use releaseYear instead")
}
这个哲学的实际极限是移除类型或字段——GraphQL 没有安全方式做到而不可能破坏现有客户端。实践中,成熟 schema 里字段会累积。纪律要求追踪哪些客户端请求哪些字段(Apollo Studio 的字段使用分析做这个)并在移除前跑一段宽限期。最终状态——一个满是没人用的弃用字段的 schema——是从不退役旧版本的 REST API 的 GraphQL 等价物。
REST vs GraphQL vs gRPC——决策矩阵
真实系统很少纯活在一个范式里。理解每个何时是对的工具,是区分资深工程师与默认伸手 GraphQL 者的所在。
| 标准 | GraphQL | REST | gRPC |
|---|---|---|---|
| 主要传输 | HTTP/1.1(订阅用 WebSocket) | HTTP/1.1 或 HTTP/2 | HTTP/2(二进制帧) |
| 数据格式 | JSON | JSON(或 XML、表单数据) | Protocol Buffers(二进制) |
| Schema / 契约 | SDL——运行时可内省 | OpenAPI/Swagger——可选、外部 | .proto 文件——严格、代码生成 |
| 性能 | 中——JSON 解析开销;复杂查询单次往返 | 中——JSON;嵌套数据多次往返 | 高——二进制编码、多路复用流、载荷小约 5–10 倍 |
| HTTP 缓存 | 困难(默认 POST;需 APQ + CDN 技巧) | 原生(GET 响应按 URL 缓存) | 无——非为 HTTP 缓存设计 |
| 流式 / 实时 | 订阅(WebSocket) | SSE / 长轮询 / WebSocket(外挂) | 原生双向流 |
| 浏览器支持 | 完整——任何浏览器都行 | 完整——原生浏览器 API | 有限——需 grpc-web 代理 |
| 过度/不足获取 | 消除——客户端选字段 | 常见——服务端决定响应形状 | 无——生成的客户端精确匹配 proto |
| 最适合 | 复杂前端、字段需求多样的移动端、BFF(Backend for Frontend) | 公开 API、简单 CRUD、HTTP 缓存关键的读、文件上传 | 内部微服务间调用、高吞吐、多语言后端 |
| 运维复杂度 | 高——schema 联邦、DataLoader、持久化查询、N+1 警惕 | 低——理解充分、海量工具生态 | 中——proto 管理、跨服务的破坏性变更纪律 |
实用启发
- 用 GraphQL当你有多种客户端类型(web、iOS、Android、伙伴集成)且数据需求显著不同,或当一个商品页需要在单个低延迟请求里从多个资源取数据时。经典用例是一个 BFF(Backend for Frontend)层,把多个微服务聚合成一个客户端优化的图。
- 用 REST应对消费者未知、HTTP 缓存有价值的公开 API;访问模式可预测的简单 CRUD 服务;内容协商重要的文件上传或响应;以及想要最少运维开销和最广中间件生态的团队。
- 用 gRPC应对性能关键、载荷大小重要、需要双向流,或想在编译期强制严格契约的内部服务间通信。gRPC 很少直接暴露给浏览器——前面放一个翻译层(gRPC-Gateway、gRPC-web,或 REST/GraphQL 门面)。
- 混合架构很正常:一个面向客户端的 GraphQL API 网关、内部微服务间的 gRPC,以及第三方 webhook 和公开集成的 REST 是常见且明智的组合。选取舍适合特定通信通道的协议,而非适合整个系统的那个。
系统设计面试被问"GraphQL 还是 REST?"时,绝不无条件选一个。说明你在优化什么:若客户端有可变数据需求且你控制两端,GraphQL。若是公开 API 或你需要 HTTP 缓存来便宜地扩展读,REST。若是有延迟 SLO 的内部服务间,gRPC。展示对三者及其取舍的认识,才赢得资深级别认可。
GraphQL vs REST——何时选 GraphQL?当客户端有复杂、多样的数据需求(如移动应用和 web 应用需要同一数据的不同字段)——GraphQL 用单个类型化查询消除过度获取和多次往返。
GraphQL 里的 N+1 问题是什么?查一个项列表,每项触发单独的解析器调用——取 100 部电影各为导演发单独 DB 查询。修法是 DataLoader:它把单个事件循环 tick 内所有 .load(id) 调用批处理成一个批函数,然后在请求生命周期内记忆化结果,把 N 个查询坍缩成 1 个。
REST 相对 GraphQL 的优势?REST 的 HTTP 缓存直截了当(GET 请求按 URL 缓存);GraphQL 默认用 POST,使 HTTP 级缓存更难。REST 对消费者稳定可预测的公开 API 也更简单。
GraphQL 里怎么做缓存?三层:DataLoader 做请求级记忆化;解析器里的 Redis 做跨请求缓存;自动持久化查询(APQ)把 POST 转 GET 让 CDN 能缓存公开查询。用 @cacheControl 给类型标注以按字段设 max-age。
怎么防止恶意客户端发查询 DoS 你的 GraphQL 服务器?深度限制(拒绝深于 N 层的查询)、复杂度分析(每字段分配数值成本,超预算拒绝)、网关按用户限流,以及生产中:持久化查询白名单,只执行已知查询哈希。
什么时候用 gRPC 而非 GraphQL?内部微服务通信,那里二进制编码、双向流和严格 proto 契约比开发者人体工学或浏览器兼容性更重要。