设计一个面向第三方开发者的开放平台 API(想象 GitHub / Stripe 那种),同时要服务自家 iOS/Android/Web 与外部集成。难点不在写接口,在于契约一旦公开,破坏它的代价由所有调用方承担——一个不更新的第三方集成可能跑了 5 年,你删一个字段它就挂。
今天讲四件事:协议选型、分页、版本管理、限流契约——API 设计里最容易在生产里翻车、面试里被追问的四块。
核心思路:一个 Gateway 收口认证、限流、版本路由;对外用 REST + GraphQL(web 友好、可演进、易缓存),对内服务间用 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、支持双向流,延迟吞吐最优,但浏览器不能直连、调试不直观——天生是内部服务间协议。
| 维度 | REST | GraphQL | gRPC |
|---|---|---|---|
| 取数 | 固定资源(易 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 } }
}
}
}
核心 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) 定位,且新插入的行不影响已翻过的页——稳定。代价是不能跳页、不能显示总页数。
| Offset | Cursor (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); 新插入行不影响已翻过的页
核心 trade-off:显式版本(/v2)的破坏自由 vs 日期版本 + 兼容层的零破坏,但永久维护成本。
原理:先分清什么是破坏性变更——加字段/端点是向后兼容的(老客户端忽略新字段);删字段、改类型/语义、改默认值才是 breaking。三种版本策略:(1) URL 路径(/v1 //v2)——直观,但每个大版本要并行维护整套代码;(2) Header 协商——URL 干净,但不可见、易被代理/缓存忽略;(3) 日期版本 + 兼容层(Stripe 法)——账号pin在注册时的版本,核心逻辑只写最新,响应经一条向后 transform 链降级回客户端 pinned 的格式。新老客户端永远不破,代价是兼容层逐年累积。
# 核心逻辑只产出最新版响应; 兼容层按账号 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()
核心 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 才反应
X-RateLimit-Remaining / Reset,超限返回 403/429 并告知重置时间,是公开 API 限流头的事实标准。ETag + If-None-Match 条件请求(304 省带宽),If-Match 乐观并发(防 lost update);可缓存 GET 挂 CDN。posts { comments { author { posts ... } } })能让后端做指数级 join,是经典 DoS 入口。面试可能追问:
为什么丢缓存:HTTP 缓存以 URL + 方法为键,且只缓存幂等 GET。REST 的 GET /users/42 天然是稳定缓存键。而 GraphQL 所有查询都打 POST /graphql,查询体在 body——同一 URL 对应无数查询,中间层无法按 URL 缓存,POST 也默认不缓存。缓存责任从「免费的 HTTP 基础设施」被推给「应用层自己实现」(如 Apollo 归一化客户端缓存)。
persisted query 怎么补:客户端预先把查询注册到服务端换取哈希;运行时只发哈希 + 变量。请求体小而确定,就能改用 GET /graphql?sha256=...——URL 重新变成稳定缓存键,CDN/限流又能工作。额外好处:服务端只接受白名单查询,自动挡掉恶意复杂查询。代价是多一步注册流程、动态拼查询的灵活性下降。
表面完美:调用方零破坏、工程师只写最新代码。但成本隐蔽且逐年复利:
所以它适合 Stripe 这种「API 即产品、调用方极多且不可控」的公司——为稳定性付永久维护税划算。能推动客户端升级的内部场景,简单的 /v2 + 弃用窗口更经济。本质是把破坏的痛苦从所有调用方转移到平台方自己,值不值取决于调用方的数量与不可控程度。
为什么 opaque:若游标是裸的 last_id=12345,客户端会依赖它的结构——猜测、手工构造、跳着传。一旦你想换底层实现(从单列 id 改成 (created_at, id) 复合锚点以支持按时间排序,或加入分片路由),所有依赖裸结构的客户端就崩了。编码成不透明字符串(base64 一个内部结构体),等于把分页实现排除在公开契约之外——这正是 AIP-158 的核心:能解析的就会被依赖,被依赖的就改不动。
引入的新约束:(1) 游标会过期——里面 pin 的排序键/快照点不能永久有效,客户端要能处理「游标失效」并从头翻;(2) 不能跳页、不能算总数,与「显示共 N 页」冲突;(3) 游标不能携带授权——必须每次重新鉴权,否则拿到别人的游标就能越权;(4) 排序键必须唯一且有序,否则边界行漏/重(常用 (timestamp, id) 复合键打破并列)。
curl 一下肉眼看懂,第三方开发者排障成本陡增;生态工具(Postman 类)支持也弱于 REST。.proto 并生成 stub,升级 proto 的协调成本高;公开场景你无法强制成千上万第三方同步更新。但这些「缺点」在内部恰是优点:服务你自己控、能统一升级 proto、要的是低延迟强类型而非人类可读、东西向流量不需要 CDN。所以协议选择本质是「调用方是否可控」:可控(内部)→ 优化机器效率选 gRPC;不可控(公开)→ 优化人类可达性与演进性选 REST/GraphQL。
为什么更隐蔽: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 分析另行限制。两者是正交的两道防线。