GraphQL API 设计:Schema / Resolver / N+1 / DataLoader / Federation

puml.online

GraphQL 解决了 REST 的过度/不足获取问题,但引入了 N+1、缓存、限流新挑战。这篇是 GraphQL Schema 设计原则、Resolver 链、DataLoader 解决 N+1、Apollo Federation 微服务联邦、Subscription 实时推送、安全与持久化查询。

GraphQL vs REST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@startuml
title "REST vs GraphQL Request Pattern"

actor "Mobile App" as M
actor "Web App" as W

participant "REST API" as R
participant "GraphQL API" as G

== REST: 多个 endpoint ==
M -> R : ① GET /users/123
R --> M : ② user info
M -> R : ③ GET /users/123/posts
R --> M : ④ posts
M -> R : ⑤ GET /users/123/followers
R --> M : ⑥ followers

note right of M : 3 个请求,数据可能重复

== GraphQL: 一个 endpoint ==
W -> G : ⑦ POST /graphql\n{ user(id:123) { name, posts { title }, followers { name } } }
G --> W : ⑧ 一次返回所有字段

note right of W : 1 个请求,精确字段

@enduml

REST 痛点:

  • 过度获取/users/123 返回所有字段,客户端只用了 name
  • 不足获取 — 需要 user + posts + followers,3 个请求
  • 多端维护 — Mobile / Web 需要不同 endpoint

GraphQL 优势:

  • 一次请求,精确字段
  • 强类型 schema — 客户端 SDK 自动生成
  • 聚合多服务 — Apollo Federation 跨服务

GraphQL Schema 设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# schema.graphql
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
followers(limit: Int = 20): [User!]!
createdAt: DateTime!
}

type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
publishedAt: DateTime
}

type Comment {
id: ID!
body: String!
author: User!
post: Post!
}

type Query {
user(id: ID!): User
me: User
posts(limit: Int = 20, offset: Int = 0): [Post!]!
}

type Mutation {
createPost(input: CreatePostInput!): Post!
updateProfile(input: UpdateProfileInput!): User!
deletePost(id: ID!): Boolean!
}

type Subscription {
postAdded(authorId: ID): Post!
notification(userId: ID!): Notification!
}

input CreatePostInput {
title: String!
content: String!
}

input UpdateProfileInput {
name: String
email: String
}

scalar DateTime

GraphQL Resolver 链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@startuml
title "GraphQL Resolver Chain"

actor "Client" as Client
participant "GraphQL Server" as GQL
participant "Resolver: User" as R1
participant "Resolver: User.posts" as R2
participant "Resolver: Post.author" as R3
participant "Resolver: Post.comments" as R4
participant "Resolver: Comment.author" as R5
database "DB" as DB

Client -> GQL : ① query { user(id:1) { name, posts { title, author { name }, comments { body, author { name } } } } }

GQL -> R1 : ② resolve User
R1 -> DB : ③ SELECT * FROM users WHERE id=1
DB --> R1 : ④ user
R1 --> GQL : ⑤ user

par 并行解析 posts[]
GQL -> R2 : ⑥ resolve User.posts
R2 -> DB : ⑦ SELECT * FROM posts WHERE user_id=1
DB --> R2 : ⑧ posts
R2 --> GQL : ⑨ posts
end

loop 每个 post
par post.author
GQL -> R3 : ⑩ resolve Post.author
R3 -> DB : ⑪ SELECT * FROM users WHERE id=?
DB --> R3 : ⑫ author
and post.comments
GQL -> R4 : ⑬ resolve Post.comments
R4 -> DB : ⑭ SELECT * FROM comments WHERE post_id=?
DB --> R4 : ⑮ comments
end

loop 每个 comment
GQL -> R5 : ⑯ resolve Comment.author
R5 -> DB : ⑰ SELECT * FROM users WHERE id=?
DB --> R5 : ⑱ author
end
end

GQL --> Client : ⑲ 一次性返回

@enduml

注意 resolver 链的潜在 N+1!

N+1 问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@startuml
title "N+1 Query Problem"

actor "Client" as Client
participant "GraphQL" as GQL
database "DB" as DB

Client -> GQL : ① query { posts { title, author { name } } }

GQL -> DB : ② SELECT * FROM posts LIMIT 20
DB --> GQL : ③ 20 posts

loop 每个 post (20 次)
GQL -> DB : ④ SELECT * FROM users WHERE id=?
DB --> GQL : ⑤ author
end

note over DB
20 + 1 = 21 次查询
1 次 posts + 20 次 users
慢!
end note

GQL --> Client : ⑥ 响应

@enduml

典型场景:20 个 post,每个 author 是不同 user → 21 次 DB 查询

DataLoader 解决 N+1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@startuml
title "DataLoader Batch + Cache"

actor "Client" as Client
participant "GraphQL" as GQL
participant "DataLoader" as DL
database "DB" as DB

Client -> GQL : ① query { posts { author { name } } }

GQL -> DB : ② SELECT * FROM posts LIMIT 20
DB --> GQL : ③ 20 posts

note over DL
DataLoader 接收 20 个 author_id
合并成一次批量查询
end note

GQL -> DL : ④ loadMany([id1, id2, ..., id20])

DL -> DB : ⑤ SELECT * FROM users WHERE id IN (id1, ..., id20)
DB --> DL : ⑥ 20 users (按入参顺序)
DL --> GQL : ⑦ 返回 authors 数组

note over DB
只 2 次查询:
- 1 posts
- 1 users (IN 批量)
end note

GQL --> Client : ⑧ 响应

@enduml

DataLoader 三特性:

  • Batch — 同一 tick 内合并多个 load 为一次
  • Cache — 同一 key 不重复请求
  • Per-request — 每次请求一个新 loader,缓存不跨请求

JS DataLoader 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const DataLoader = require('dataloader');

// 批量加载 user
const userLoader = new DataLoader(async (userIds) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
// 必须按入参顺序返回
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id));
});

// resolver
const Post = {
author: (post, _, { loaders }) => loaders.user.load(post.author_id),
};

Python strawberry + aiocache:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@strawberry.type
class Post:
id: strawberry.ID

@strawberry.field
async def author(self, info) -> 'User':
# 用 info.context 里 pre-loaded 的数据
return await info.context['user_loader'].load(self.author_id)

# 创建 loader
async def get_user_by_id(user_ids: list[int]) -> list[User]:
users = await db.query("SELECT * FROM users WHERE id = ANY($1)", [user_ids])
return [User.from_row(u) for u in users]

context = {
'user_loader': DataLoader(load_fn=get_user_by_id)
}

Apollo Federation 微服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@startuml
title "Apollo Federation - Composed GraphQL"

actor "Client" as Client
participant "Apollo Gateway" as Gateway
participant "Users Subgraph" as Users
participant "Posts Subgraph" as Posts
participant "Reviews Subgraph" as Reviews
database "Users DB" as UDB
database "Posts DB" as PDB
database "Reviews DB" as RDB

Client -> Gateway : ① query { user(id:1) { name, posts { title, reviews { rating } } } }

Gateway -> Gateway : ② 解析 query plan
note right
1. Users.user
2. Posts.posts (扩展字段)
3. Reviews.reviews (扩展字段)
end note

Gateway -> Users : ③ query User
Users -> UDB : ④ SELECT
UDB --> Users : ⑤ user
Users --> Gateway : ⑥ user

Gateway -> Posts : ⑦ query Posts.userPosts
Posts -> PDB : ⑧ SELECT posts WHERE user_id
PDB --> Posts : ⑨ posts
Posts --> Gateway : ⑩ posts

Gateway -> Reviews : ⑪ query Reviews.postReviews
Reviews -> RDB : ⑫ SELECT reviews WHERE post_id IN (...)
RDB --> Reviews : ⑬ reviews
Reviews --> Gateway : ⑭ reviews

Gateway -> Gateway : ⑮ 组装嵌套结构
Gateway --> Client : ⑯ 响应

@enduml

Federation 关键概念:

  • Subgraph — 独立服务的 GraphQL API
  • Entity — 跨服务共享的类型(@key)
  • Gateway — 路由 + 组合各 subgraph
  • Query Planner — 解析 query,决定跨 subgraph 调用顺序

Subgraph schema 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
}

# posts subgraph
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}

type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}

# reviews subgraph
extend type Post @key(fields: "id") {
id: ID! @external
reviews: [Review!]!
}

GraphQL Subscription 实时推送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@startuml
title "GraphQL Subscription - WebSocket"

actor "Client" as Client
participant "GraphQL Server" as GQL
participant "PubSub (Redis/Postgres)" as PS
participant "Event Source" as ES

Client -> GQL : ① WS 订阅\nsubscription { postAdded(authorId:1) { id, title } }
GQL -> PS : ② SUBSCRIBE postAdded:author:1
PS --> GQL : ③ subscribed

note over ES : 新事件发生
ES -> PS : ④ PUBLISH postAdded {id, title, author_id=1}
PS -> GQL : ⑤ 推送
GQL -> Client : ⑥ WS 推送新数据
Client --> Client : ⑦ UI 更新

@enduml

PubSub 实现:

  • 本地 — Node EventEmitter
  • Redis — Redis Pub/Sub,跨实例
  • PostgresLISTEN/NOTIFY,简单
  • Kafka — 高吞吐场景

Subscription resolver:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { RedisPubSub } from 'graphql-redis-subscriptions';
const pubsub = new RedisPubSub();

const Subscription = {
postAdded: {
subscribe: (_, { authorId }) =>
pubsub.asyncIterator([`postAdded:author:${authorId}`]),
},
};

// 触发
pubsub.publish(`postAdded:author:1`, {
postAdded: { id: 123, title: 'New Post' }
});

持久化查询(Persisted Queries)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@startuml
title "Persisted Query Flow"

actor "Mobile Client" as M
participant "GraphQL Server" as GQL
database "Apollo Registry" as Reg

== 第一次:注册 ==
M -> GQL : ① POST /graphql\nquery: ""\nextensions.persistedQuery: { version:1, sha256Hash: "abc..." }
GQL -> Reg : ② 查 hash "abc..."
Reg --> GQL : ③ 没找到,需要完整 query
GQL -> Reg : ④ 注册 {hash: "abc...", query: "完整 query"}
Reg --> GQL : ⑤ 注册成功
GQL --> M : ⑥ 200 (client 缓存 hash → query 映射)

== 之后:用 hash ==
M -> GQL : ⑦ POST /graphql\nextensions.persistedQuery: { sha256Hash: "abc..." }
GQL -> Reg : ⑧ 查 hash
Reg --> GQL : ⑨ 找到
GQL -> GQL : ⑩ 解析 hash 对应 query,执行
GQL --> M : ⑪ 200 数据

@enduml

持久化查询的好处:

  • 请求体积减少 90%+ — 客户端只发 hash,不发送完整 query
  • 服务端可拒绝未注册 hash防恶意查询(白名单模式)
  • APQ(Automatic Persisted Queries) — 自动注册

限流与防护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@startuml
title "GraphQL Cost Analysis + Rate Limiting"

actor "Client" as Client
participant "GraphQL" as GQL
participant "Cost Analyzer" as CA
participant "Rate Limiter" as RL

Client -> GQL : ① query { user(id:1) { posts { author { posts { comments { ... } } } } } }

GQL -> CA : ② 计算 query cost
CA -> CA : ③ 嵌套深度 = 5
CA -> CA : ④ 字段数 = 50
CA -> CA : ⑤ alias 重复 = 10
CA -> CA : ⑥ 总 cost = 1500

alt Cost > 1000
CA --> GQL : ⑦ reject (too complex)
GQL --> Client : ⑧ 400 Bad Request
else Cost <= 1000
GQL -> RL : ⑨ check rate limit
alt Rate exceeded
RL --> GQL : ⑩ reject
GQL --> Client : ⑪ 429 Too Many Requests
else OK
GQL -> GQL : ⑫ execute
GQL --> Client : ⑬ 200
end
end

@enduml

限流策略:

  • Query Depth — 嵌套深度限制(默认 7-10)
  • Query Complexity — 字段总数 + alias
  • Cost Analysis — 自定义权重(关系型查 > 缓存查)
  • Rate Limit — 每秒请求数 / 每小时请求数

错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}

type CreatePostPayload {
post: Post
errors: [Error!]
userErrors: [UserError!]!
}

type UserError {
field: String
message: String!
code: ErrorCode!
}

enum ErrorCode {
VALIDATION_ERROR
DUPLICATE_EMAIL
UNAUTHORIZED
RATE_LIMITED
}

错误三原则:

  1. 不要用 HTTP 500 表示业务错误 — 用 200 + payload 里的 errors
  2. GraphQL errors 字段保留给系统错误(server crash / DB down)
  3. 业务错误 用 union type 或 payload 里的 userErrors

实战踩坑

  • N+1 没解决 — DataLoader 必须每个 request 一个实例,跨 request 会数据不一致。info.context['loader'] 模式
  • Query 太深posts.author.posts.author.posts.author... 用户构造 deep query。深度限制 5-7 层
  • Introspection 暴露 schema — 生产环境应该禁 introspection。noIntrospection: true
  • Subscription 内存泄漏 — 客户端断开但 subscription 没取消。WebSocket 关闭时清理
  • N+1 在 federation — Gateway 端 SQL JOIN 跨服务不可能。每个 subgraph 自己 DataLoader
  • 持久化查询污染 — 攻击者注册恶意 query 永久占用服务端。自动注册 + 人工审核
  • 缺少监控 — GraphQL 错误难追踪(整个 query 是一个请求)。Apollo Studio / OpenTelemetry
  • Auth 在 resolver — 每个 resolver 单独检查权限,易遗漏Schema directive @auth

决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
要做什么?
├─ 客户端灵活查询 → GraphQL
├─ 简单 CRUD / 公开 API → REST
├─ 高吞吐 / 简单缓存 → gRPC
├─ 文件上传 → REST (multipart)
└─ 内部服务通信 → gRPC

Schema 设计?
├─ 单体应用 → 单 schema
├─ 微服务 → Apollo Federation
└─ 已有 REST API → graphql-mesh 包装

性能?
├─ 解决 N+1 → DataLoader (必做)
├─ 大量相同 query → Redis 缓存 resolver
├─ 查询复杂 → query cost + depth limit
└─ 实时数据 → Subscription

安全?
├─ 公开 API → 持久化查询 + 复杂度限制
├─ 内部 → JWT + resolver auth
└─ 复杂权限 → Schema directive

最小起步:Apollo Server + DataLoader + 深度限制。生产加 Federation + Subscription + Apollo Studio 监控。

记住:GraphQL 不是 REST 替代品,它解决过度/不足获取问题简单 CRUD 用 REST 更简单复杂聚合 / 多端适配用 GraphQL别为了用 GraphQL 而用

  • 标题: GraphQL API 设计:Schema / Resolver / N+1 / DataLoader / Federation
  • 作者: puml.online
  • 创建于 : 2026-07-30 18:10:00
  • 更新于 : 2026-08-14 21:34:29
  • 链接: https://puml.online/blog/plantuml-graphql-api-design/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。