mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1668 字
5 分钟
GraphQL 与 REST:API 设计范式的对比与选择
2024-03-12

某电商平台的移动端首页需要展示用户信息、推荐商品列表和未读通知数量。使用 REST API 时,前端需要发起三个独立请求,且每个端点返回的数据包含大量不需要的字段。移动端团队因此推动迁移到 GraphQL,期望一次请求获取所有数据。然而上线后,GraphQL 的 N+1 查询问题导致后端数据库负载飙升,响应时间反而变慢了。这个真实案例说明,GraphQL 与 REST 并非简单的优劣关系,而是各有适用场景的设计范式。

一、GraphQL 核心概念#

1. Schema 定义#

Schema 是 GraphQL 的类型系统核心,定义了 API 的所有数据类型和操作入口。一个完善的 Schema 应该包含标量、枚举、接口、联合类型等高级特性:

# 自定义标量
scalar DateTime
scalar JSON
# 枚举类型
enum UserRole {
ADMIN
EDITOR
VIEWER
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# 接口定义
interface Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
# 核心业务类型实现接口
type User implements Node {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
friends: [User!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node {
id: ID!
title: String!
content: String!
status: PostStatus!
author: User!
tags: [String!]!
createdAt: DateTime!
updatedAt: DateTime!
}
# 联合类型——搜索结果可以是多种类型
union SearchResult = User | Post
# 查询入口
type Query {
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
post(id: ID!): Post
search(keyword: String!): [SearchResult!]!
}
# 变更入口
type Mutation {
createUser(input: CreateUserInput!): User!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
# 输入类型
input CreateUserInput {
name: String!
email: String!
role: UserRole = VIEWER
}
input UpdatePostInput {
title: String
content: String
status: PostStatus
}

2. 查询示例#

GraphQL 的核心优势在于客户端可以精确指定需要的字段,一个请求即可获取嵌套数据:

# 获取用户及其文章和作者信息
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
name
email
role
posts(limit: 10) {
title
status
tags
author {
name
}
}
}
}
# 使用片段(Fragment)复用字段选择
fragment UserFields on User {
id
name
email
role
}
query GetUsers {
users(limit: 5) {
...UserFields
}
}
# 联合类型查询需要内联片段
query Search($keyword: String!) {
search(keyword: $keyword) {
... on User {
name
email
}
... on Post {
title
status
}
}
}

对比 REST 获取同样的数据,需要多次请求:

# REST 方式:需要多个请求
GET /api/users/1 # 获取用户信息
GET /api/users/1/posts?limit=10 # 获取用户文章
GET /api/posts/1/author # 每篇文章的作者信息(N+1)

3. Mutation 与错误处理#

mutation CreatePost($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}

GraphQL 的错误处理与 REST 截然不同:

{
"data": null,
"errors": [
{
"message": "Email already exists",
"locations": [{ "line": 2, "column": 3 }],
"path": ["createUser"],
"extensions": {
"code": "DUPLICATE_EMAIL",
"timestamp": "2026-03-19T10:00:00Z"
}
}
]
}

即使部分字段出错,GraphQL 也能返回部分成功数据,这是 REST 做不到的。

二、REST 与 GraphQL 深度对比#

1. 核心差异#

特性RESTGraphQL
数据获取多端点单端点
字段控制固定返回精确指定
类型安全弱(OpenAPI 补充)Schema 强类型
学习曲线
缓存HTTP 缓存天然支持需客户端缓存
实时性SSE/WebSocket订阅
版本管理URL 版本化Schema 演进
文档需额外生成内省自动生成
过度获取常见按需获取
欠获取常见按需获取

2. 版本管理对比#

REST 通常通过 URL 管理版本:

# REST 版本管理
GET /api/v1/users/1
GET /api/v2/users/1
# 旧版本维护成本高,v1 和 v2 可能长期并存

GraphQL 通过 Schema 演进避免版本爆炸:

# 旧字段可以标记为废弃,无需删除
type User {
name: String! @deprecated(reason: "Use displayName instead")
displayName: String!
}
# 客户端有充足时间迁移,服务端无需维护多版本

3. 典型请求对比#

以一个博客应用为例,移动端首页需要展示:用户信息、文章列表、通知数量。

REST 方式:

GET /api/users/me
GET /api/posts?limit=20&sort=-createdAt
GET /api/notifications/count?unread=true
# 3 次请求,且每个端点可能返回多余字段

GraphQL 方式:

query HomePage {
me {
name
avatar
}
posts(limit: 20, sort: "-createdAt") {
title
excerpt
author { name }
}
notifications(unread: true) {
totalCount
}
}
# 1 次请求,精确获取所需字段

三、Resolver 与 N+1 问题#

1. Resolver 实现#

Resolver 是 GraphQL 的字段解析函数,每个字段都可以有独立的 Resolver:

from typing import List
from graphql import GraphQLResolveInfo
async def resolve_user(root, info: GraphQLResolveInfo, id: str):
return await db.users.find_by_id(id)
async def resolve_user_posts(user, info: GraphQLResolveInfo):
return await db.posts.find_by_author(user.id)
async def resolve_post_author(post, info: GraphQLResolveInfo):
return await db.users.find_by_id(post.author_id)
# Schema 与 Resolver 绑定
resolvers = {
"Query": {
"user": resolve_user,
},
"User": {
"posts": resolve_user_posts,
},
"Post": {
"author": resolve_post_author,
},
}

2. N+1 问题详解#

N+1 是 GraphQL 最常见的性能陷阱。查询一篇文章列表时,每篇文章都要单独查一次作者:

# 原始方式:N+1 查询
posts = await Post.all() # 1 次查询,返回 100 篇文章
for post in posts:
# 每篇文章触发 1 次 Resolver,查询 1 次数据库
author = await User.get(post.author_id) # 100 次查询
# 总计:101 次数据库查询

这在 REST 中不会出现,因为 REST 端点通常会 join 查询返回完整数据。但在 GraphQL 中,Resolver 是按字段独立执行的,N+1 几乎不可避免。

3. DataLoader 批量解决#

DataLoader 通过批量加载和缓存两个机制解决 N+1 问题:

from dataloader import DataLoader
class UserLoader(DataLoader):
async def batch_load_fn(self, user_ids: List[str]):
# 一次查询获取所有用户
users = await User.get_batch(user_ids)
# 必须按 user_ids 顺序返回结果
user_map = {u.id: u for u in users}
return [user_map.get(uid) for uid in user_ids]
# 在 Resolver 中使用
async def resolve_post_author(post, info):
loader = info.context["user_loader"]
return await loader.load(post.author_id)
# DataLoader 会将同一 tick 内的所有 load 调用合并
# 100 个 load(author_id) -> 1 次 batch_load_fn 调用
# 总查询数从 101 降到 2

DataLoader 的工作原理:

请求进入 -> Resolver 1 调用 load(1)
-> Resolver 2 调用 load(2)
-> Resolver 3 调用 load(3)
-> ...
-> 事件循环下一个 tick
-> DataLoader 收集 [1, 2, 3, ...]
-> 调用 batch_load_fn([1, 2, 3, ...])
-> 一次批量查询,分发结果给各 Resolver

4. 其他 N+1 解决方案#

方案优点缺点
DataLoader通用、框架无关需要手动维护 Loader
ORM 预加载简单直接可能过度加载不需要的数据
手写 SQL Join性能最优灵活性差,维护成本高
Hasura/PostGraphile自动优化绑定特定数据库

四、订阅机制#

GraphQL 订阅基于 WebSocket 实现服务端推送。

1. WebSocket 订阅#

subscription onNewComment($postId: ID!) {
newComment(postId: $postId) {
id
content
author { name }
}
}

2. 订阅实现原理#

# 服务端订阅 Resolver(基于 asyncio)
async def subscribe_new_comment(root, info, postId):
async for comment in comment_event_stream(postId):
yield comment
# 客户端连接管理
class SubscriptionManager:
def __init__(self):
self.connections = {}
async def subscribe(self, ws, subscription_id, query):
# 验证查询、建立订阅
async for result in execute_subscription(query):
await ws.send_json(result)
async def unsubscribe(self, subscription_id):
# 清理资源
pass

3. 订阅 vs SSE vs WebSocket#

特性GraphQL 订阅SSE原生 WebSocket
方向服务端推送服务端推送双向
协议WebSocketHTTPWebSocket
类型安全Schema 保证
重连框架内置浏览器自动重连需手动实现
过滤参数化需自行实现需自行实现

五、缓存策略#

1. HTTP 缓存 vs GraphQL 缓存#

策略说明适用场景
GET 查询HTTP 缓存(Cache-Control)REST API
Apollo Normalize客户端归一化缓存GraphQL SPA
Redis 字段缓存字段级别 TTL服务端 Resolver
Persisted Queries预编译查询 + 白名单生产环境
CDN 边缘缓存缓存完整响应公共数据

2. Apollo 客户端缓存#

Apollo Client 使用归一化缓存,以 __typename:id 为键存储每个对象:

// Apollo 缓存自动归一化
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// 局部状态与远程数据合并
posts: {
keyArgs: ["sort", "filter"],
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
});
// 手动更新缓存
cache.updateQuery({ query: GET_USER }, (data) => ({
...data,
user: { ...data.user, name: "New Name" },
}));

3. 服务端缓存#

from functools import lru_cache
import redis
# Resolver 级别缓存
redis_client = redis.Redis()
async def resolve_trending_posts(root, info):
cache_key = "trending:posts"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
posts = await db.posts.find_trending()
await redis_client.setex(cache_key, 300, json.dumps(posts)) # 5 分钟 TTL
return posts

4. Persisted Queries#

生产环境中,将查询语句预先注册到服务端,客户端只发送查询 ID:

# 服务端:查询白名单
PERSISTED_QUERIES = {
"hash_abc123": "query GetUser($id: ID!) { user(id: $id) { name email } }",
}
async def execute_persisted_query(query_id, variables):
query = PERSISTED_QUERIES.get(query_id)
if not query:
raise ValueError("Unknown query")
return await execute(query, variables)
# 客户端只发送 ID,减少网络传输,防止恶意查询

六、Schema 设计模式#

1. 命名空间与模块化#

大型项目需要按业务领域拆分 Schema:

# 用户模块
extend type Query {
user(id: ID!): User
}
# 文章模块
extend type Query {
post(id: ID!): Post
posts(limit: Int): [Post!]!
}
# Python 中使用 Schema Stitching 或 Federation
from ariadne import make_executable_schema
user_schema = load_schema_from_path("schemas/user.graphql")
post_schema = load_schema_from_path("schemas/post.graphql")
schema = make_executable_schema([user_schema, post_schema], resolvers)

2. Relay 连接规范#

分页是 API 的核心需求,Relay 规范定义了标准的分页结构:

type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

3. 设计模式总结#

模式适用场景关键点
命名空间多业务线extend type 拆分
接口与联合抽象类型避免过度使用,保持简单
Relay 连接分页cursor 优于 offset
输入类型Mutation 参数独立 Input 类型
废弃标记Schema 演进@deprecated 渐进迁移

七、安全考量#

1. 查询深度限制#

恶意查询可以无限嵌套,造成 DoS 攻击:

# 限制查询深度
from graphql import validate, depth_limit_rule
max_depth = 10
rules = [depth_limit_rule(max_depth)]
def validate_query(query):
errors = validate(schema, parse(query), rules)
if errors:
raise ValueError(f"Query too deep: {errors}")

2. 查询复杂度分析#

# 基于字段成本计算查询复杂度
from graphql import cost_analysis
max_cost = 1000
cost_map = {
"User": {"fields": {"posts": {"multiplier": "limit", "cost": 5}}},
"Query": {"fields": {"users": {"multiplier": "limit", "cost": 10}}},
}
def validate_cost(query):
cost = calculate_cost(query, cost_map)
if cost > max_cost:
raise ValueError(f"Query cost {cost} exceeds limit {max_cost}")

3. 安全策略汇总#

策略防御目标实现方式
深度限制嵌套查询 DoSmax_depth = 10
复杂度限制宽查询 DoSmax_cost = 1000
Persisted Queries恶意查询注入白名单机制
超时控制长时间查询请求级 timeout
速率限制查询频率IP/Token 维度限流
Introspection 关闭信息泄露生产环境禁用

八、选型决策#

1. 何时选择 GraphQL#

  • 多端差异大:移动端、Web 端、小程序需要的字段差异显著
  • 复杂嵌套关系:数据实体之间有大量关联关系
  • 前端驱动:前端团队需要灵活获取数据
  • 内部微服务:API Gateway 聚合多个后端服务
  • 实时需求强:订阅机制天然支持实时数据

2. 何时选择 REST#

  • 简单 CRUD:资源明确,操作标准化
  • 公共 API:第三方集成,REST 更通用
  • 文件上传下载:GraphQL 对二进制流支持不佳
  • HTTP 缓存优先:CDN / 代理层缓存需求强
  • 团队经验:团队对 REST 更熟悉,迁移成本高

3. 混合方案#

实际项目中,GraphQL 和 REST 可以共存:

/api/v1/* -> REST(文件上传、Webhook 回调)
/graphql -> GraphQL(数据查询、实时订阅)
/health /metrics -> REST(运维接口)

4. 决策矩阵#

场景推荐理由
移动端优化GraphQL减少请求数和传输量
简单 CRUDREST开发快,工具链成熟
复杂嵌套关系GraphQL一次查询获取关联数据
公共 APIREST通用性强,学习成本低
内部微服务GraphQLAPI Gateway 聚合能力强
文件上传REST + GraphQLREST 处理上传,GraphQL 返回结果
实时数据GraphQL订阅机制原生支持
CDN 缓存场景RESTHTTP 缓存天然友好

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

GraphQL 与 REST:API 设计范式的对比与选择
https://blog.souloss.com/posts/web/web-graphql-vs-rest/
作者
Souloss
发布于
2024-03-12
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时