RESTful API 设计我踩过的 6 个坑
大概是几年前,我把手上那个订单系统的 120 多个接口,从动词式的 RPC 风格硬掰成了 REST 风格。三个礼拜改完,自测全过,我以为自己干了件很漂亮的事。结果上线第一周,前端找了我五次,移动端三次,有个第三方对接方直接打电话过来问「你们这个 PATCH 到底要不要传全量字段」。
从那以后我对 REST 的态度就变了——它是一套约定,不是宗教。下面这几个坑都是我真实踩过的,有代码有数字,写下来给正在做 API 设计的人参考。
坑一:PUT 和 PATCH,到底谁改谁
RFC 9110(2022 年 6 月发布,替代了原来的 RFC 7231)写得很清楚:PUT 是「用请求体里的表示替换目标资源的整个状态」,PATCH 是「对资源做部分修改」。听起来没歧义对吧,问题是现实里没几个人这么用。
我见过最多的写法是 PUT /users/1 传 {"name": "张三"},后端只更新 name 字段。这实际上就是 PATCH 的语义,被当成 PUT 在使了。这么干有什么后果?假设 users 表有 name、email、phone 三个字段,两个客户端同时操作:A 只想改 name,B 只想改 phone。如果接口语义是「部分更新」,两边各自提交互不影响,没问题。但只要有客户端真的按 PUT 语义提交了全量,另一个客户端的修改就被悄悄覆盖了,这就是经典的 lost update,排查起来特别费劲,因为两边都觉得自己没写错。
我的建议很土:要么严格 PUT 全量替换、缺字段就置空,要么干脆全用 PATCH,别混着来。PATCH 的 Content-Type 建议用 application/merge-patch+json(RFC 7396)或 application/json-patch+json(RFC 6902),很多人直接写 application/json,严格讲不规范,但你写了通常也没人拦你。顺带一提,PATCH 在 HTTP 规范里不是幂等也不是安全的,所以别指望网关帮你自动重试。
坑二:200 里包一个 code 字段,到底算不算反模式
网上骂这个的特别多,说什么「HTTP 状态码白设计了」。我以前也跟着骂,直到有一次做支付对接被打脸。
场景是这样的:一个下单接口,可能失败的原因有十几种——余额不足、库存扣减失败、风控拦截、优惠券已失效、地址不在配送范围……如果全映射成 HTTP 状态码,你得发明一堆 4xx,前端还得靠 message 字符串去判断具体原因,那还不如直接给业务码。而且 axios 默认的 validateStatus 是 status >= 200 && status < 300,非 2xx 全走 catch,前端就得在 catch 里区分「网络断了」和「余额不足」,这俩处理逻辑完全不一样,硬塞一个分支里很难受。
// 全 200 + 业务码的写法
axios.post('/api/orders', data).then(res => {
if (res.data.code !== 0) {
// 业务失败,但 res 是 200
}
}).catch(err => {
// 这里只剩网络错误了,逻辑很干净
})
// 纯 HTTP 状态码的写法
axios.post('/api/orders', data, { validateStatus: s => s < 500 })
.then(res => { /* 需要自己按 res.status 分支 */ })
我现在的做法是分层:协议层面的错误(401 未登录、403 无权限、404 资源不存在、429 限流、500 服务器炸了)老实用 HTTP 状态码;业务规则层面的失败(余额不足、库存不够)返回 200 + 业务码。412、422 这种状态码偶尔用,但别指望前端每个人都记得住 422 是啥意思。这不是最优解,但是团队协作成本最低的解。
坑三:分页,深翻页用 OFFSET 是真的会慢
这个坑我有实测数据。orders 表大概 2000 万行,8 核 16G 的 MySQL 8.0,SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 1000000 大概要 1.2 秒,因为引擎得先扫出来并丢弃前 100 万行。改成 WHERE id > 1000000 ORDER BY id LIMIT 20(keyset / cursor 分页),走主键索引,稳定在 3-8ms 之间。不同机器数字肯定不一样,但量级差距是真实的。
我的经验线是:offset 超过 1 万就该换方案了。前端如果做的是「无限滚动」而不是「跳到第 500 页」,cursor 分页几乎没缺点,返回体里带个 next_cursor 就行:
{
"data": [ ... ],
"next_cursor": "eyJpZCI6MTIzNDU2N30",
"has_more": true
}
代价是没法跳页,也没法告诉用户「共 35241 条」。如果业务非要总数(比如后台管理列表),那就老老实实上 offset,但建议把 COUNT(*) 换成估算值,或者干脆用 LIMIT 10000 硬性截断,别让用户点第 1000 页。GitHub 的 API 当年用的就是 Link header + page 参数,后来也补了 cursor 方式,说明大家最后都撞到同一堵墙上。
坑四:POST 重复提交,别在业务代码里防
下单、支付、发券这类接口,用户网络卡一下手动点两次,或者客户端超时重试,就会出问题。很多人在业务层写「查一下有没有同样的订单」,靠订单号或者用户 ID + 商品 ID 去重,这做法在并发下根本不牢靠,两个请求同时查、同时没查到、同时插入,照样两条。
比较通用的做法是让客户端带一个 Idempotency-Key 请求头,值是个 UUID,服务端拿这个 key 做唯一约束,第一次请求正常处理并把结果缓存(Stripe 那边是缓存 24 小时),后续同一个 key 直接返回缓存结果。数据库层面对 key 建唯一索引,让并发在 DB 层被挡住,而不是在应用层用 if 判断。
POST /api/payments
Idempotency-Key: 4b1c9e2a-7f3d-4a5b-9c8e-1d2f3a4b5c6d
Content-Type: application/json
顺便提醒,Idempotency-Key 这个头是 Stripe 带火的,不是 HTTP 标准里的东西。你要用之前先确认下自己的网关、CDN 会不会把这个自定义头吃掉,我就在 Nginx 上被坑过一次,因为 underscores_in_headers 默认 off,带下划线的头会被直接丢掉。
坑五:HATEOAS,我劝你别碰
Roy Fielding 2000 年博士论文里提的 REST 是带超媒体约束的,也就是响应里应该带 _links,客户端靠链接发现下一步能干什么。这是 REST 六大约束之一,理论上不做这个就不算 REST。
但我从业这些年,见过的生产系统里老老实实做 HATEOAS 的,不超过三个。原因很现实:客户端开发者根本不会去解析你的 links,他们还是打开文档,把 URL 硬编码进去,那你多返回的那堆 _links 字段就是纯浪费流量和认知负担。而且加了 links 之后,接口返回体会变得很啰嗦,嵌套两层以上可读性直线下降。
我的态度是:如果你的 API 是给内部团队用的,忘掉 HATEOAS,把文档写清楚比什么都强。如果是要做开放平台、给完全不认识的第三方用,那可以认真考虑一下,因为超媒体确实能降低对方的耦合。这是个成本收益问题,不是信仰问题。
坑六:版本号,放 URL 还是放 Header
这个争论估计还能吵十年。放 URL(/api/v1/orders)的好处是直观,浏览器里直接能打开,日志里一眼能看出调的是哪个版本。放 Header(Accept: application/vnd.myapp.v1+json)理论上更「干净」,因为 URL 代表资源,版本是表示层的差异。
我选 URL,理由特别朴素:出问题的时候,运维在日志里 grep 不到 Header,但一定 grep 得到 URL。而且很多老网关、日志采集工具对自定义 Header 的支持就是不如对路径的支持。
另外一个小细节,别把 v1 改成 v2 就删掉 v1。至少给老版本留半年的过渡期,在返回头里加个 Deprecation: true 和 Sunset 说明下线时间(这俩是 RFC 8594 和 RFC 9745 定义的),别搞突然袭击。我见过有公司周五下午直接下线 v1,周末被客户电话打爆。
最后说点私货
REST 这套东西是 20 多年前从「文档在网络上怎么互相引用」这个场景抽象出来的,它解决的核心问题是可发现性和松耦合。但现在大部分业务 API 面对的是自己家的 App、自己家的 H5,客户端和服务的版本是一起发布的,根本不存在什么「客户端不知道下一步能干什么」的问题。
所以我的真实观点是:把 REST 当成一套命名约定和语义约定来用就够了——名词做路径、动作用方法、状态码分层、幂等性搞清楚。至于 HATEOAS、严格的超媒体、把每个操作都抽象成资源这些,看你的场景,该省就省。API 设计的目标是让调用方少写 bug、少打电话问你,不是拿论文去对标。
上面这些坑你要是也踩过,欢迎交流。反正我现在写接口,先问的三个问题永远是:这个接口会不会被重试?分页会不会翻很深?出错了客户端要区分几种情况?这三个问题想清楚了,八成坑都避开了。