去年秋天排查一个线上问题,用户反馈「下单成功了但订单列表里没有」。网关日志显示订单服务返回的是 HTTP 200,响应体是 {"code": 5001, "msg": "库存服务超时", "data": null}。前端小伙伴很委屈,说业务码不是 0 就算失败,会走 toast 提示,不会跳转到订单详情页——但那天他们改了一版逻辑,判断条件写成了只在 res.status === 200 时才跳转。链路上三个人,两套错误语义,一次下游超时,就凑成了这个结果。这事之后我把我们所有对外服务翻了一遍,200 里套错误的接口占了七成。这个坑跟 REST 有没有关系?有,而且是根上的。
先把名词捋清楚:Fielding 说的 REST 不是 URL 长什么样
Roy Fielding 在 2000 年的博士论文第 5 章里定义了 REST,一共六个约束:客户端-服务器、无状态、可缓存、统一接口、分层系统、按需代码(这个是可选的)。注意「可缓存」和「无状态」排在前面,而「统一接口」下面那四条子约束里,从来没有任何一条说过「资源名要用复数名词、要用连字符不要下划线」。
现在网上流传的那一堆 RESTful 规范——Google API Design Guide、微软的 REST API Guidelines、Zalando 那份挂在 GitHub 上的 RESTful API Guidelines——严格讲都只能叫「HTTP + JSON 接口风格指南」,跟 Fielding 的架构约束是两回事。Zalando 那份文档光命名就写了十几条,但整份文档对缓存的讨论还不到一页。这是个挺典型的信号:大家真正在意的其实是「好不好看」,不是「好不好用」。还有个 Richardson 成熟度模型,Leonard Richardson 在 2008 年 QCon 上讲的,Martin Fowler 后来写文章推广开。大部分人停在 Level 2,我觉得 Level 3 的 HATEOAS 在绝大多数业务里投入产出比是负的,后面细说。
我不是说命名规范没用。几十号人写接口,没有统一约定一定会乱。但你把全部精力投在 URL 命名上,收益很快就见顶了。真正决定这个 API 三年后还能不能维护的,是另外几件事。
分歧点其实是:接口契约的演进权归谁
过去六年我参与过三个从 REST 换到 GraphQL 的项目,也见过换成 gRPC 的。换完之后团队普遍会经历一个「爽——痛——冷静」的周期。爽的是前端不用等后端排期了;痛的是 N+1 查询、权限校验散落各处、慢查询排查要靠 tracing 一条条追;冷静下来之后,大家才想明白真正的问题从来不是「REST 还是 GraphQL」,而是「接口契约变了,改造成本谁承担」。
REST 把演进成本压在服务端。你可以加字段、加接口,老客户端不受影响,但一旦要改语义——比如金额字段从「元」改成「分」——服务端就得同时兼容两套,或者开新版本,成本在这边。GraphQL 把成本推到客户端:schema 加了 required 字段、删了字段、改了 enum,客户端得跟着改,而且很多情况下是运行时才发现,Apollo 那套 persisted query 和 schema check 能缓解一部分,但要额外搭基建。gRPC 把成本放在中间的 IDL 上,protobuf 的字段编号一旦发布就不能动,删字段要 reserved,规则很清楚也很硬,好处是编译期就能挡住一部分破坏性变更。
我自己判断的标准就一条:改一次接口语义,需要几个团队同时发版? 如果是 0 个,那随便选,别纠结;如果是 3 个以上,选谁都痛苦,不如先把契约测试和版本策略搞起来。
| 维度 | REST/JSON | GraphQL | gRPC |
|---|---|---|---|
| 契约演进归属 | 服务端 | 客户端 | IDL |
| 浏览器直连 | 原生支持 | 需要 POST + 客户端库 | 需要 grpc-web + 代理 |
| 弱网表现 | 请求数多、单包小 | 单请求大、容易超 payload | 二进制、体积最小 |
| 缓存 | 可直接复用 HTTP 缓存 | 基本靠应用层 | 无标准方案 |
| 调试 | curl + 浏览器 | 需要 GraphiQL | grpcurl |
| 典型场景 | 对外开放平台 | 前端驱动的聚合层 | 内部高频服务调用 |
这张表我在团队 wiki 里放两年了,每次有人提「要不要上 GraphQL」,我就先把它甩过去,然后再聊业务场景。
被严重低估的成本:网络往返次数
一个真实数字。我们做过一次统计,Android 首页冷启动过程中,客户端串行加并行一共发了 23 个 HTTP 请求。拆开看:商品卡片信息 1 个、库存状态 1 个、优惠标签 1 个、运费估算 1 个、评价条数 1 个……每个接口都很「RESTful」,都很干净,都是 /api/v1/products/{id}/inventory 这种教科书式路径。然后在 4G 弱网环境下,P95 是 3.8 秒。
问题不出在任何单个接口的设计上,而是「资源」这个粒度和「屏幕」这个粒度对不上。REST 的资源模型是服务端视角切的,用户看到的是一个页面。你可以在服务端加 BFF 做聚合,也可以上 GraphQL,也可以在客户端做并发控制,但无论哪种方案,都得先承认一件事:REST 的 URL 设计得再漂亮,也解决不了请求数量的问题。而且请求数一多,超时重试、部分失败的组合状态就会指数级膨胀,前端的容错代码会变得很难看。
后来我给我们团队定的规矩是:面向移动端首页的接口,BFF 层默认聚合,单屏请求数控制在 3 个以内。这是我个人的经验值,没有理论依据,但三个项目试下来,比追求「严格 RESTful」好用得多。
缓存:REST 唯一别人抢不走的优势,可惜大部分团队扔了
HTTP 缓存是 REST 架构里最实打实的红利。Cache-Control、ETag、If-None-Match、Last-Modified、Vary 这一整套东西,浏览器、CDN、Nginx、Service Worker 全都原生支持,你在 GraphQL 和 gRPC 上找不到等价物。
但现实是,我见过的对外 API 里正确实现 ETag 的不到两成。原因也简单:大部分接口带用户身份,Authorization 头一变缓存就废了;而 Vary 这个头写错,能把 CDN 直接打穿——把 Vary: Authorization 写上去,等于每个用户的每个 token 都是一份独立缓存,命中率归零,源站压力反而更大。
几个容易写错的点,我踩过不止一次:
ETag用弱校验W/"xxx"还是强校验"xxx",取决于你的响应是不是字节级一致。JSON 序列化时 key 顺序如果不固定,就只能用弱 ETag,否则同一个资源两次算出来的哈希不一样,缓存永远不命中。If-None-Match命中要返回304,而且304不能带 body。但很多框架默认会把 body 塞进去,CDN 那边就懵了,表现是缓存行为变得很怪。Cache-Control: no-cache和no-store不是一回事。no-cache是「可以存,但每次都要回源验证」,no-store是「根本别存」。我见过有人想禁止缓存写了no-cache,结果 CDN 还是缓存了,因为语义理解反了。stale-while-revalidate这个扩展指令写在 RFC 5861 里,Cloudflare 和 Fastly 都支持,能把 P99 拉下来一大截,但用它之前得确认你的数据能接受「短暂陈旧」,商品价格这种就不行。
如果你的 API 是公开数据——汇率、榜单、商品详情这类——不上 HTTP 缓存基本等于白扔钱。这部分我能再写一篇,先打住。
版本管理:三种流派和它们的实际代价
这块我见过三种做法,都有人在用,而且都是大厂。
路径版本。 Twilio 的 API 路径是 /2010-04-01/Accounts/{Sid}/Messages.json。对,那个日期就是 2010 年 4 月 1 日,一直用到现在。好处是极其明确,你复制一条 URL 就知道调的是哪一版;坏处是 URL 里塞了个日期,资源标识和版本耦合了,同一个资源在不同版本下是不同 URL,缓存也得分开算。
Header 版本。 GitHub 用的是 X-GitHub-Api-Version: 2022-11-28,Stripe 用的是 Stripe-Version。Stripe 的做法更狠——版本是账号级绑定的,你注册时绑定一个版本,升不升级你说了算,不升级的话 Stripe 在服务端帮你做字段转换,一直兼容下去。这套机制的工程成本极高,Stripe 内部维护了大量版本转换逻辑,普通团队学不来,别硬抄。
不带版本。 Slack 早期就这么干,靠只增不减的字段演进。理论上很美,实践中你会发现总有些改动是破坏性的,最后还是要补一个版本机制。
我个人的取舍是:对外开放平台用路径版本,内部服务用 Header 或者干脆不做版本靠灰度。路径版本对第三方开发者最友好,他们水平参差不齐,你没法指望每个人都认真读 header 文档。另外提一句,Accept: application/vnd.github.v3+json 这种媒体类型协商的版本方案,理论上才是 Fielding 最推崇的(因为版本是表述层的属性,不是资源的属性),但现实里几乎没人用对。我自己只在一个项目上试过,后来因为网关和 CDN 对 Accept 头的处理不一致,放弃了。
状态码这件事,我改变了看法
回到开头那个 200 套 500 的问题。我以前是坚定的「该用 4xx 就用 4xx」派,现在没那么绝对了。
原因是这样的:HTTP 状态码的语义空间主要是给传输层和中间件用的,不是给业务用的。502 出现的时候,你的负载均衡会重试;429 出现的时候,客户端 SDK 会退避;401 出现的时候,很多前端框架会自动跳登录。这些行为都是好事。但如果你把「用户积分不足」映射成 403,某些网关会把它当成权限问题记进安全日志;你把「手机号已注册」映射成 409,有些客户端库会以为该重试。语义一旦跑偏,中间件的行为就不可控了。
我现在的做法是分开处理:传输层和鉴权层的错误,老老实实用 HTTP 状态码——401 没登录、403 有身份但没权限、404 资源不存在、429 限流(记得带 Retry-After)、5xx 服务端问题。业务规则错误,用 200 或者 422 加响应体里的 code 字段,422 Unprocessable Entity 在 RFC 4918 里本来是给 WebDAV 用的,但语义还算贴切,用得也广。
关键不是选了哪个方案,是在文档里写死,并且所有服务保持一致。最怕的是订单服务用 200、支付服务用 422、库存服务用 403,前端要写三套判断逻辑,迟早出事。
顺带说一个更细的点:POST 创建资源的幂等。Stripe 的 Idempotency-Key 头是好东西,客户端生成一个 UUID,服务端缓存 24 小时,重复请求直接返回第一次的结果,网络超时重试的场景下能救命。自己实现的话注意一点,同一个 key 配不同的请求体应该返回 422 或者 409,别傻乎乎返回缓存,那会掩盖 bug。
一点私货
这些年我看过太多「RESTful API 最佳实践」的文章,标题都很响亮,内容基本是命名规范加状态码对照表。不是说没用,是这些东西的投入产出比已经很低了。你的 URL 从 /getUserById 改成 /users/{id},用户的 P99 不会因此降一毫秒,但你的团队会为了这个争论两个下午。
我判断一个 API 设计得好不好,现在就看三件事:新来的人拿到文档,能不能在半小时内调通一个完整业务流;线上出问题时,能不能只靠 curl 和日志定位到是哪一层的问题;一年后改一个字段,需要联动几个团队。这三条跟 REST 关系不大,跟「你有没有认真对待过这个接口」关系很大。
REST 不是过时了,是被讲烂了。讲到大家都在抠 URL 里该用复数还是单数,没人再在意缓存和幂等这些真正影响可用性的东西。挺可惜的。