先说结论:REST 的价值不在 URL 好看,在于中间件能不能替你干活
我 2019 年接手过一个内部系统,接口全是 POST /api/getOrderList、POST /api/updateOrder 这种,参数一股脑塞进 JSON body。当时觉得挺方便,直到要做这四件事:给接口加缓存、让 GET 走 CDN、让网关按路由做限流、用 OpenTelemetry 自动打 span。四件事没一件能自动做——所有请求都是 POST /api/xxx,method 没有语义,status code 永远返回 200,缓存中间件连“这个请求到底能不能缓存”都判断不了。
后来重构成 REST 风格,GET /orders/{id}、PATCH /orders/{id}、DELETE /orders/{id}。最直观的变化:Nginx 的 proxy_cache 配置从 0 行变成 8 行,网关的限流规则从 200 多条缩成 6 条(按 method + 路径前缀匹配),APM 里终于能看到按状态码聚合的错误率曲线了。
这就是我的核心观点:判断一个 API 设计好不好,别问“它像不像 REST”,要问“通用工具能不能不加改造地理解它”。HTTP 方法、状态码、Location、ETag、Retry-After、Link 这些东西,本质上是几十年前就定好的机器可读契约(HTTP 语义的最新版本是 2022 年 6 月发布的 RFC 9110,它取代了老的 RFC 7231)。你遵守它,就能白嫖一大堆基础设施;你绕过它,就等于把这些活重新写进业务代码里。
反过来也成立——如果这套 API 只服务自己家的 App,永远不会有第二个消费方,也没有网关和 CDN,那 200 + 业务码也不是什么滔天大罪。教条主义没意义,关键是知道自己放弃了什么。
状态码:200 + 业务码真的那么不堪吗
国内团队大量用 {"code": 40001, "msg": "参数错误", "data": null},HTTP 状态码一律 200。我理解这个做法,它来自一个很现实的场景:早期前后端联调时,axios 拦截器会把非 2xx 直接扔进 catch,前端拿不到 body,于是后端干脆全返回 200。但代价是你放弃了三样东西:一,浏览器 DevTools、Charles、Kibana 里的 APM 全都按状态码做聚合,全 200 意味着你的错误率面板永远是 0,线上出事只能翻日志;二,重试库(Python 的 tenacity、Go 的 go-retryablehttp、前端的 axios-retry)默认只重试 5xx 和 429,你返回 200,重试逻辑就得全部手写;三,网关限流返回 429 时客户端和 CDN 能自动退避,这也是标准行为。
我现在的折中是:HTTP 状态码表达“这次请求在协议层面发生了什么”,业务码表达“业务规则为什么拒绝”,两者一起返回。具体映射我一般这么定:
| 场景 | 状态码 | 备注 |
|---|---|---|
| 创建成功 | 201 + Location | Location 头返回新资源地址 |
| 异步任务已受理 | 202 | 响应体里给 task_id |
| 删除成功且无内容 | 204 | 别返回 {} |
| JSON 解析失败 / 参数格式错 | 400 | |
| 未登录、token 过期 | 401 | 必须带 WWW-Authenticate |
| 已登录但无权限 | 403 | |
| 资源不存在 | 404 | 别拿 404 掩盖权限问题 |
| 唯一键冲突、状态机冲突 | 409 | 比如重复下单 |
| 乐观锁版本不匹配 | 412 | 配 If-Match 使用 |
| 字段值语义不合法 | 422 | 比如库存不足 |
| 强制前置条件 | 428 | 少见,但强制 If-Match 时有用 |
| 触发限流 | 429 + Retry-After | Retry-After 单位是秒 |
| 服务端异常 | 500 |
401 和 403 混用是重灾区。我见过一个系统所有权限不足都返回 401,结果前端拦截器一收到 401 就跳登录页,用户点了个没权限的按钮直接被踢下线,还得重新登录一次。这种 bug 排查起来特别费劲,因为从日志上看一切正常。
深分页:LIMIT 1000000, 20 是我见过最多的性能杀手
2021 年我压测过一个订单列表接口,表里 800 万行数据。SELECT * FROM orders WHERE status = 2 ORDER BY id LIMIT 1000000, 20,在 16C32G 的机器上平均耗时 3.2 秒,P99 到 4.7 秒。原因不复杂:MySQL 要先扫 1000020 条二级索引记录、回表 1000020 次,再扔掉前 100 万条。你翻到第几页,成本就线性涨到哪。前端一般不会翻到 5 万页,但爬虫会、对账导出会,还有那种“我要直接跳到 3 个月前”的运营后台会。
方案就是游标分页(keyset pagination):
-- 第一页
SELECT id, order_no, created_at FROM orders
WHERE status = 2 ORDER BY created_at DESC, id DESC LIMIT 20;
-- 下一页,cursor 来自上一页最后一条记录
SELECT id, order_no, created_at FROM orders
WHERE status = 2 AND (created_at, id) < ('2024-03-11 10:22:33', 88231)
ORDER BY created_at DESC, id DESC LIMIT 20;
注意 (created_at, id) 这个复合游标。只按 created_at 排序会出问题:同一秒有 200 个订单时,分页会漏数据或者重复。加上 id 做 tie-breaker 才稳定,在 (status, created_at, id) 上建联合索引能稳定走 index range scan。改完之后同样的第 5 万页,耗时降到 12~30ms。
API 侧我会把游标 base64 编码,GET /v1/orders?limit=20&cursor=eyJ0IjoiMjAyNC0wMy0xMSAxMDoyMjozMyIsImkiOjg4MjMxfQ,响应体里给 next_cursor。为什么要 base64?不是为了安全,是为了让客户端别去解析它、别自己拼游标——一旦有人开始拼游标,你就永远改不了它的内部结构了。这个坑我踩过,后来加了一版 v2 才甩掉。
顺带一句,limit 一定要设上限。我见过 ?size=100000 把 JVM 直接打到 OOM 的。现在我一律 min(size, 100),超了直接返回 400,不解释。
幂等:POST 不幂等是教科书说法,但支付、下单、发券场景必须有解
HTTP 语义里 POST 不幂等,这是对的。但现实是:用户点两次提交、网关超时重试、上游消息队列重复投递,这三种情况任何一个发生,你都会多一张订单。
我的做法是引入 Idempotency-Key 请求头,学 Stripe 那套。客户端自己生成 UUID v4,同一个业务操作重试时带同一个 key。服务端流程:第一步,Redis SET idem:{user_id}:{key} "processing" NX EX 86400,SETNX 失败说明之前来过;第二步,拿到锁就正常执行业务,把 status_code + 响应体存进去,TTL 24 小时;第三步,没拿到锁就读缓存,状态是 processing 就返回 409 加 Retry-After: 1,状态是 done 就直接回放缓存的响应,并加一个 Idempotent-Replay: true 头告诉客户端这是回放。
几个坑必须说清楚。key 的作用域一定要带 user_id 或 tenant_id,不然 A 用户的 key 被 B 用户撞上,会读到别人的响应——这不是理论风险,我真干过。TTL 别太长也别太短,24 小时基本够用,我们每天大概 40 万个 key,算上响应体和请求体 hash 大约 3GB 内存;短于上游重试窗口(一般 15 分钟到 1 小时)就没意义了。请求体要存 SHA-256,同一个 key 配不同 body 应该返回 422 而不是回放旧响应,否则会变成“改单”漏洞。幂等表别放 MySQL 主库,热 key 更新会把行锁打满,Redis 挂了就用数据库唯一索引兜底,比如 uk_biz_id。
PUT 本身幂等,但要注意“最后写入者赢”的问题。做乐观锁用 ETag:GET 返回 ETag: "v7",更新时带 If-Match: "v7",版本不对服务端返回 412,前端提示“数据已被他人修改,请刷新”。这比把 version 字段塞进 JSON body 里省事,因为网关和 CDN 都能识别这套头。
PATCH 与 action 子资源:我承认我违反了 REST 教条
先说 PUT。很多人以为 PUT 是“更新”,其实 PUT 是“用请求体完整替换目标资源”。你只传了 {"name": "张三"},服务端就得把 email、phone 全清空——不这么做你就违反了 PUT 语义,这么做又会把用户其他字段干掉。我见过最惨的一次,前端漏传了一个 avatar 字段,三千多个用户的头像被清空,靠 binlog 回滚了大半夜。
所以单字段更新用 PATCH,Content-Type: application/merge-patch+json(RFC 7396)。规则很简单:body 里出现的字段就覆盖,null 表示删除该字段,没出现的字段不动。但 RFC 7396 有个含糊点:你没法区分“把 phone 设为 null”和“不修改 phone”。要区分就得换 RFC 6902 的 JSON Patch,[{"op":"replace","path":"/phone","value":null}],表达能力强但客户端写起来更啰嗦。我一般默认 merge patch,只有内部配置中心这种明确要区分 null 的场景才上 JSON Patch。
另一个争议点是 action 子资源。纯粹主义者会说取消订单应该是 PATCH /orders/{id} {"status":"cancelled"}。我不同意。原因有两个:状态机校验(已发货的订单能不能取消)写进通用 PATCH handler 会很难看;取消订单往往不止改一个字段,还要退款、释放库存、发通知,塞进字段更新里语义是失真的。所以我现在这么分——简单字段更新走 PATCH /orders/{id};有副作用的业务动作走 POST /orders/{id}/cancel,返回 202 加一个 operation id。Stripe 的 /v1/payment_intents/{id}/confirm、GitHub 的 /repos/{owner}/{repo}/issues/{n}/lock 都是这个模式,说明它不是什么邪门歪道。
错误体、版本,还有一份可以直接抄的 checklist
错误体我推荐直接用 RFC 9457(就是原来的 RFC 7807,2023 年 7 月改的名)的 Problem Details:
{
"type": "https://api.example.com/errors/insufficient-stock",
"title": "库存不足",
"status": 422,
"detail": "商品 SKU-88231 当前可售 3 件,请求 5 件",
"instance": "/v1/orders",
"trace_id": "b7c2f1a9e4d3"
}
关键是 type 用 URI 且要能指向文档,这样客户端可以按 type 做精确分支,而不用去 match 文案。trace_id 一定要有,用户截图给你时直接拿它查链路,能省半小时。
版本策略我选 URL 路径 /v1/,理由很俗:可见、好调试、网关好配。Accept header 协商(Accept: application/vnd.example.v2+json)理论上更正确,但我合作过的 6 个外部团队里有 4 个连 curl -H 都不太会用。对外接口,能减少沟通成本的方案就是对的。日期版本(Stripe 的 Stripe-Version: 2024-06-20)适合 SDK 场景,但要求极强的向后兼容能力,一般团队玩不转。
最后是我给自己团队定的 checklist,每次接口评审都过一遍:一、分页接口有没有 limit 上限,深分页走游标还是 offset;二、POST 写接口有没有 Idempotency-Key,作用域带没带 user_id;三、错误响应里的 status 和 HTTP 状态码是否一致,有没有 trace_id;四、更新接口用 PUT 还是 PATCH,会不会误清空字段;五、有并发写的资源加没加 ETag 或版本号;六、404 和 403 分清楚没有;七、限流接口有没有返回 Retry-After;八、有没有 OpenAPI 3.1 文档,CI 里有没有跑契约测试。
第八条我认为最关键。我们之前出过一次事故:前端按旧文档传参,后端悄悄改了字段名,联调时没人发现,上线后大盘下单成功率掉了 12%。后来在 CI 里加了 Schemathesis 跑契约测试,每次 PR 全量扫一遍,这类问题基本绝迹。维护文档和实现不漂移的成本,比写测试高得多——这话我五年前不信,现在信了。