Day 9 Medium API Design REST / GraphQL / gRPC Pagination · Versioning

API 设计 — 契约一旦公开,破坏它的代价由所有调用方承担REST vs GraphQL vs gRPC · Pagination · Versioning · Rate Limiting

问题场景与约束

设计一个面向第三方开发者的开放平台 API(想象 GitHub / Stripe 那种),同时要服务自家 iOS/Android/Web 与外部集成。难点不在写接口,在于契约一旦公开,破坏它的代价由所有调用方承担——一个不更新的第三方集成可能跑了 5 年,你删一个字段它就挂。

今天讲四件事:协议选型、分页、版本管理、限流契约——API 设计里最容易在生产里翻车、面试里被追问的四块。

高层架构

graph LR subgraph Clients["客户端"] M["移动端
省流量"] W["Web"] T["第三方集成
可能 5 年不更新"] end GW["API Gateway
认证 · 限流 · 版本路由"] REST["REST /v1
资源模型 · 可 HTTP 缓存"] GQL["GraphQL
按需取数 · 单次往返"] subgraph Internal["内部 (gRPC mesh)"] S1["订单服务"] S2["用户服务"] S3["计费服务"] end DB[("DB / Cache")] M --> GW W --> GW T --> GW GW --> REST GW --> GQL REST -->|gRPC| S1 GQL -->|gRPC| S2 S1 --> S3 S1 --> DB classDef gw fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 classDef edge fill:#0e2030,stroke:#5eead4,color:#e8eef5 class GW gw class REST,GQL edge

核心思路:一个 Gateway 收口认证、限流、版本路由;对外用 REST + GraphQL(web 友好、可演进、易缓存),对内服务间用 gRPC(强类型、高吞吐)。信任边界两侧用不同协议,是几乎所有大平台的默认形态。

关键技术点

1. 协议选型:REST vs GraphQL vs gRPC

核心 trade-off:资源模型可缓存的简单 vs 客户端按需取数的灵活 vs 内部强类型高性能

原理:三种抽象。REST 把世界建模成资源 + HTTP 动词,无状态、可按 URL 被 CDN/浏览器缓存,但常over-fetch(多给用不到的字段)under-fetch(一个视图要多次往返)GraphQL 让客户端用一个查询声明要哪些字段,一次取齐、消除 over/under-fetch,代价是缓存与限流变难(都走 POST /graphql,URL 不再是缓存键)。gRPC 基于 HTTP/2 + Protobuf,二进制、强类型 IDL、支持双向流,延迟吞吐最优,但浏览器不能直连、调试不直观——天生是内部服务间协议。

维度RESTGraphQLgRPC
取数固定资源(易 over/under-fetch)按需字段,一次取齐固定 RPC 方法
HTTP 缓存✅ 天然(GET+URL)❌ 难(POST)❌ 不适用
类型/契约弱(OpenAPI 补)✅ schema 强类型✅ Protobuf 强类型
性能中(JSON 文本)✅ 高(二进制+流)
浏览器直连❌(需 gRPC-Web)
最佳场景公开资源 API、可缓存多端聚合、字段多变内部微服务、低延迟
# REST: 渲染一个 PR 页面要多次往返 (under-fetch + N+1)
GET /repos/o/r/pulls/42          -> PR 主体 (含一堆用不到字段, over-fetch)
GET /repos/o/r/pulls/42/commits  -> 提交列表
GET /repos/o/r/pulls/42/reviews  -> 评审
GET /users/alice                 -> 作者信息 (每个作者一次, N+1)

# GraphQL: 一次往返, 只取需要的字段
query {
  repository(owner:"o", name:"r") {
    pullRequest(number:42) {
      title
      author { login avatarUrl }
      commits(last:5) { nodes { oid } }
      reviews(last:10) { nodes { state } }
    }
  }
}
怎么选
现实案例:

2. 分页:Offset vs Cursor (Keyset)

核心 trade-off:offset 可随机跳页的简单 vs cursor 在大数据集 + 高频写下的稳定与高效

原理Offset 分页LIMIT 20 OFFSET 100000)实现简单、能跳到任意页,但有两个硬伤:(1) 深翻页慢——DB 要扫描并丢弃前 100000 行,O(offset);(2) 数据漂移——翻页过程中有人插入/删除,窗口错位,导致漏读或重复Cursor / Keyset 分页用「上一页最后一行的有序锚点」做游标(WHERE id > last_seen ORDER BY id),走索引 O(log n) 定位,且新插入的行不影响已翻过的页——稳定。代价是不能跳页、不能显示总页数

OffsetCursor (Keyset)
深翻页性能差 O(offset)✅ O(log n) 走索引
高频写下一致❌ 漂移/漏/重✅ 稳定
跳到第 N 页✅ 支持❌ 只能顺序翻
总数/总页✅ 可给❌ 通常不给
适用小数据集、管理后台大数据集、公开 API、infinite scroll
-- Offset: 深翻页要扫描 OFFSET 行后丢弃, 慢且翻页中插入会错位
SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 100000;

-- Keyset/cursor: 用上一页最后一个 id 作锚, 走主键索引
SELECT * FROM events
WHERE id > :last_seen_id          -- 由 opaque cursor 解码得到
ORDER BY id LIMIT 20;
-- next_cursor = encode(本页最后一行 id); 新插入行不影响已翻过的页
现实案例:

3. 版本管理:让契约「永不破坏」

核心 trade-off:显式版本(/v2)的破坏自由 vs 日期版本 + 兼容层的零破坏,但永久维护成本

原理:先分清什么是破坏性变更——字段/端点是向后兼容的(老客户端忽略新字段);字段、类型/语义、默认值才是 breaking。三种版本策略:(1) URL 路径/v1 //v2)——直观,但每个大版本要并行维护整套代码;(2) Header 协商——URL 干净,但不可见、易被代理/缓存忽略;(3) 日期版本 + 兼容层(Stripe 法)——账号pin在注册时的版本,核心逻辑只写最新,响应经一条向后 transform 链降级回客户端 pinned 的格式。新老客户端永远不破,代价是兼容层逐年累积。

graph LR REQ["请求
API-Version: 2018-02-28
账号 pinned 旧版本"] CORE["核心逻辑
只认最新 schema"] T1["transform
2020 → 2019"] T2["transform
2019 → 2018"] RESP["响应
降级回 2018 格式"] REQ --> CORE CORE -->|最新格式| T1 --> T2 --> RESP classDef c fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class CORE c
# 核心逻辑只产出最新版响应; 兼容层按账号 pinned 版本逐级降级
def respond(account, payload_latest):
    v = account.api_version                    # e.g. "2018-02-28"
    for change in breaking_changes_after(v):   # 按时间倒序应用
        payload_latest = change.downgrade(payload_latest)
    return payload_latest
# 加字段 = 向后兼容, 不进 transform 链
# 删字段/改类型 = breaking, 必须写一个对应的 downgrade()
取舍:URL 版本简单可见但版本爆炸(每个 /vN 一套代码,维护噩梦);日期版本对调用方零破坏,但每个 breaking change 都要写一段永久存活的 downgrade 逻辑,把复杂度从客户端转移到平台方。平台越想「对开发者好」,自己背的兼容包袱越重。
现实案例:

4. 限流契约:429 不只是拒绝,是一份协议

核心 trade-off:静默丢弃/裸 429 vs 带 Retry-After + 配额头 + 幂等键的可协作限流。(算法内部——token bucket / sliding window / 分布式限流——留 Day 10。)

原理:限流在 API 设计层是契约而非纯防御,要让客户端知道还剩多少额度、何时能重试、怎样重试安全:(1) 超限返回 429 + Retry-After(明确等多久);(2) 每个响应带 X-RateLimit-Remaining / Reset,客户端可主动减速而非撞墙;(3) 按 key 分配额层级(免费/付费);(4) 写请求配 Idempotency-Key,让限流后的重试不重复扣款(见 Day 7)。最忌裸 429——客户端只能盲目立即重试,制造重试风暴二次打垮后端。

# 限流是契约: 客户端读 Retry-After, 幂等键让重试安全
resp = POST("/v1/charges", body,
            headers={"Idempotency-Key": key})   # 同 key 服务端去重
if resp.status == 429:
    wait = resp.headers.get("Retry-After")       # 服务端告知等多久(秒)
    sleep(wait + jitter())                        # 加抖动, 避免同时重试风暴
    retry()                                       # 同一 key -> 不会重复扣款
# 平时也读 X-RateLimit-Remaining 主动减速, 而不是撞到 429 才反应
现实案例:

扩展与优化

常见陷阱与面试问题

1. 公开 API 用 offset 分页。 深翻页慢且数据漂移导致漏/重。大数据集、高频写场景必须用 cursor,并用 opaque token 藏实现细节。
2. 把内部字段/DB schema 直接暴露成 API。 等于把数据库结构变成公开契约,以后改表就破坏 API。API 要有独立的 DTO 边界。
3. GraphQL 不做 query cost / depth 限制。 一个深度嵌套查询(posts { comments { author { posts ... } } })能让后端做指数级 join,是经典 DoS 入口。
4. 把「加字段」当 breaking change 去发新版本。 加字段是向后兼容的,老客户端会忽略。误判会导致版本号爆炸、维护成本失控。
5. 限流只返回裸 429。 不给 Retry-After / X-RateLimit,客户端只能盲目立即重试 → 重试风暴二次打垮后端。限流是契约,要可协作。

面试可能追问:

  1. REST、GraphQL、gRPC 三者本质区别?什么场景非 GraphQL 不可、什么场景该用 gRPC?
  2. 为什么 GraphQL 难做 HTTP 缓存?persisted query 怎么部分挽回?
  3. cursor 分页为什么用 opaque token 而不是裸 id?它放弃了什么能力?
  4. 什么变更算 breaking、什么不算?日期版本 + 兼容层相比 /v2 的代价在哪?
  5. 一个第三方反复触发 429 还在猛重试,怎么从 API 契约层面让它「文明退避」?
  6. GraphQL 的 N+1 问题怎么来、DataLoader 怎么解决?

深入资源

深入思考

1. GraphQL 消除了 over/under-fetching,却几乎丢掉了 REST 在 HTTP/CDN 缓存上的天然优势。为什么?persisted query 怎么部分挽回?

为什么丢缓存:HTTP 缓存以 URL + 方法为键,且只缓存幂等 GET。REST 的 GET /users/42 天然是稳定缓存键。而 GraphQL 所有查询都打 POST /graphql,查询体在 body——同一 URL 对应无数查询,中间层无法按 URL 缓存,POST 也默认不缓存。缓存责任从「免费的 HTTP 基础设施」被推给「应用层自己实现」(如 Apollo 归一化客户端缓存)。

persisted query 怎么补:客户端预先把查询注册到服务端换取哈希;运行时只发哈希 + 变量。请求体小而确定,就能改用 GET /graphql?sha256=...——URL 重新变成稳定缓存键,CDN/限流又能工作。额外好处:服务端只接受白名单查询,自动挡掉恶意复杂查询。代价是多一步注册流程、动态拼查询的灵活性下降。

2. 既然日期版本 + 兼容层能让契约「永不破坏」,为什么不是所有公司都这么做?它的二阶成本是什么?

表面完美:调用方零破坏、工程师只写最新代码。但成本隐蔽且逐年复利

  • 兼容层永久存活:每个 breaking change 写一段 downgrade,且永远不能删(只要还有一个账号 pin 在老版本)。多年累积成几百段变换,相互作用使测试矩阵爆炸。
  • 排障变难:一个 bug 可能只在「某老版本经过某条 transform 链」时出现,复现要先还原客户端 pinned 的版本。
  • 需基建兜底:覆盖所有版本的自动化测试、版本回放、变换 DSL。中小团队没这套,硬上会被兼容层拖死。

所以它适合 Stripe 这种「API 即产品、调用方极多且不可控」的公司——为稳定性付永久维护税划算。能推动客户端升级的内部场景,简单的 /v2 + 弃用窗口更经济。本质是把破坏的痛苦从所有调用方转移到平台方自己,值不值取决于调用方的数量与不可控程度。

3. cursor 分页为什么坚持用 opaque(不可解析)token,而不是直接把 last_id 暴露给客户端?这又引入了什么新约束?

为什么 opaque:若游标是裸的 last_id=12345,客户端会依赖它的结构——猜测、手工构造、跳着传。一旦你想换底层实现(从单列 id 改成 (created_at, id) 复合锚点以支持按时间排序,或加入分片路由),所有依赖裸结构的客户端就崩了。编码成不透明字符串(base64 一个内部结构体),等于把分页实现排除在公开契约之外——这正是 AIP-158 的核心:能解析的就会被依赖,被依赖的就改不动

引入的新约束:(1) 游标会过期——里面 pin 的排序键/快照点不能永久有效,客户端要能处理「游标失效」并从头翻;(2) 不能跳页、不能算总数,与「显示共 N 页」冲突;(3) 游标不能携带授权——必须每次重新鉴权,否则拿到别人的游标就能越权;(4) 排序键必须唯一且有序,否则边界行漏/重(常用 (timestamp, id) 复合键打破并列)。

4. 公开 API 几乎清一色 REST/GraphQL,gRPC 基本只在内部。如果硬把 gRPC 当公开 API,会付出哪些代价?
  • 浏览器不能直连:gRPC 依赖 HTTP/2 的 trailer 等底层特性,浏览器 fetch 拿不到,必须经 gRPC-Web + 代理转换——给第三方平添一层基建门槛。
  • 调试不直观:二进制 Protobuf 不能像 JSON 那样 curl 一下肉眼看懂,第三方开发者排障成本陡增;生态工具(Postman 类)支持也弱于 REST。
  • 强 IDL 耦合:调用方要拿到 .proto 并生成 stub,升级 proto 的协调成本高;公开场景你无法强制成千上万第三方同步更新。
  • 缓存/CDN 无从下手:没有 URL 语义,HTTP 缓存基础设施完全用不上。

但这些「缺点」在内部恰是优点:服务你自己控、能统一升级 proto、要的是低延迟强类型而非人类可读、东西向流量不需要 CDN。所以协议选择本质是「调用方是否可控」:可控(内部)→ 优化机器效率选 gRPC;不可控(公开)→ 优化人类可达性与演进性选 REST/GraphQL。

5. GraphQL 的 N+1 问题比 REST 更隐蔽——为什么?DataLoader 是怎么解决的,它的「批 + 缓存」边界在哪?

为什么更隐蔽:REST 的 N+1 在客户端可见(你能数请求次数)。GraphQL 的 N+1 藏在服务端 resolver 里:查 posts { author { name } },框架对每个 post 的 author 独立调一次 resolver——100 个 post 就是 100 次 SELECT author WHERE id=?。外面看只是「一个查询」,后端却炸出 1+100 次 DB 访问,且随查询形状动态变化,不盯监控发现不了。

DataLoader 怎么解:单次请求内做两件事——(1) batch:不立即执行每个 load(id),把同一 tick 内的所有 id 攒成一批,事件循环结束时合并成一次 WHERE id IN (...),N 次降为 1 次;(2) per-request cache:同请求里重复 load(同 id) 直接返缓存。

边界:缓存只在单请求生命周期内有效(防跨请求脏读、保证一次查询内一致),非全局缓存;它解决「取数放大」,不解决「查询本身过深/过复杂」——后者要靠 query cost 分析另行限制。两者是正交的两道防线。