API 设计:好接口长什么样
接口能返回数据、调用方能拿来用,需求就算做完了?可一旦上量,问题全冒出来:状态全返 200,调用方只能扒 body 里的
code判断成败;网络一抖重试,订单建了两笔、款扣了两次;列表越翻越慢;报错只甩一句"系统异常",对方对着日志干瞪眼。
这些都不是"功能 bug"——接口确实跑通了,只是设计得不好。这篇把好接口最该懂的几件事讲清楚:状态码、幂等、分页、错误处理,以及一份落地 checklist。配套一个零依赖 demo,跑一下就能看到"烂返回"和"好返回"差在哪。
一、RESTful 基础:别让所有路都叫"操作"
写接口,最先要扭过来的一个习惯是:别把接口当"函数调用"来设计。
很多人写接口的第一反应是动词驱动:/getUserList、/createOrder、/deleteComment——一个操作一个路径,全用 POST。这能跑,但路径会越堆越乱,而且丢掉了 HTTP 本身就有的一套表达力。
RESTful 的核心就一句话:把接口看成对"资源"的操作,用 URL 表示资源(名词),用 HTTP 动词表示操作。
| 操作 | 动词 | 路径 | 说明 |
|---|---|---|---|
| 查列表 | GET | /users | 拿一批用户 |
| 查单个 | GET | /users/123 | 拿 id=123 的用户 |
| 创建 | POST | /users | 新建一个用户 |
| 全量更新 | PUT | /users/123 | 用新对象整个替换 |
| 局部更新 | PATCH | /users/123 | 只改传来的字段 |
| 删除 | DELETE | /users/123 | 删掉这个用户 |
动词不只是好看。它自带语义约定:GET 是只读的、可以安全重试、可以被缓存;DELETE/PUT 是幂等的(同一请求发几次结果一样);POST 才是"每次都可能产生新东西"的那个。调用方、网关、CDN 都按这套约定来,你顺着用,很多事不用自己重新发明。
状态码:别什么都 200 再往 body 里塞 code
这是最该改的一个反模式:无论成功失败都返 HTTP 200,把真正的成败塞进 body 的一个 code 字段里({"code": 500, "msg": "..."})。
为什么不好?因为 HTTP 状态码是一套全行业都认的标准信号,你把它废了,等于让所有中间环节都失明:
- 网关 / 负载均衡靠状态码判断后端健不健康,全 200 它以为你一切正常,5xx 该熔断的不熔断;
- 监控告警靠 5xx 比例报警,全 200 → 服务挂着也不告警;
- 调用方没法用标准方式判断成败,只能去 body 里扒那个非标准的
code,每个接口还可能不一样。
正确做法是用状态码表达大类,body 再放业务细节:
| 区间 | 含义 | 常见码 |
|---|---|---|
| 2xx | 成功 | 200 OK、201 Created(创建成功)、204 No Content(成功但无返回体) |
| 4xx | 调用方错了 | 400 参数错、401 未认证、403 没权限、404 资源不存在、409 冲突、429 限流 |
| 5xx | 服务端错了 | 500 内部错误、502/503 上游挂了 / 不可用 |
记一个关键分界:4xx 是"你(调用方)传错了,改了再来",5xx 是"我(服务端)的锅,你重试可能就好"。这条线划清楚,排障时双方就不用扯皮——状态码已经替你说了是谁的问题。
这并不是说 body 里不能有业务码。恰恰相反,4xx 之下还有很多业务细分(密码错 / 验证码过期 / 余额不足),这些用 body 里的
code表达正合适(见第四节)。状态码管大类,业务code管细节,两者分工,而不是用 body 顶替状态码。
二、幂等:重复执行,不能产生多次效果
幂等(Idempotent):同一个请求,执行一次和执行多次,对系统产生的效果完全一样。查询天生幂等(查一次查十次结果都一样);真正要操心的是写操作。
为什么这事躲不掉?因为"请求只到达一次"是个幻觉。现实里:
- 网络会重试:客户端发了创建订单请求,服务端其实已经处理成功,但响应在回来的路上丢了。客户端没收到,按重试逻辑又发一次——于是建了两单。
- 用户会重复点:支付按钮点下去没反应(其实在转圈),用户手一抖又点一下。
- 消息会重投:消息队列大多保证"至少一次"投递,同一条消息可能被消费两遍(这也是异步专题里反复强调要幂等的原因)。
只要这个写操作"重复执行会出事"——支付、下单、扣库存、转账——就必须做幂等。重复查询无所谓,重复扣款是事故。
怎么实现:幂等键 + 服务端去重
最通用的做法是 幂等键(Idempotency-Key):
- 调用方为"这一次业务操作"生成一个全局唯一的 key(比如一个 UUID),放在请求头
Idempotency-Key里。注意 key 代表"这一次下单"这个意图,重试要带同一个 key,而不是每次新生成。 - 服务端收到请求,先拿这个 key 查"去重表":
- 没见过 → 正常处理,把"key → 处理结果"记下来;
- 见过 → 不再执行业务,直接返回上次的结果(这叫"回放")。
这样,同一个 key 不管发多少次,业务逻辑只真正跑一次,调用方每次拿到的还是同一个结果(同一个订单号),皆大欢喜。
demo 场景一就是这个:同一个幂等键调用"创建订单"两次。不做幂等 → 建出两单(扣两次款);带幂等键去重 → 第二次直接回放第一次的结果,最终只有一单。
落地时去重表通常放 Redis(带 TTL,key 不用永久留)或数据库的唯一索引(把幂等键设为唯一列,重复插入直接被数据库挡下)。还要注意并发:两个同 key 请求几乎同时到,得用锁或唯一约束保证只有一个能真正执行,另一个等结果——别让"去重"自己产生竞态。
三、分页:为什么列表越翻越慢
列表接口几乎都要分页。最常见的是 page / size 这种翻页器式的分页,但它藏着一个上量才暴露的坑。
offset 分页:翻得越深越慢
?page=1000&size=10 背后,数据库执行的大致是 LIMIT 10 OFFSET 9990——意思是**"先数着跳过前 9990 行,再取 10 行"。问题就在"跳过":数据库没法直接蹦到第 9990 行,它得老老实实扫过前面那 9990 行**再丢掉。翻到第 1 页扫 10 行,翻到第 1000 页要扫 10000 行——扫描量随页数线性增长,越往后越慢。
demo 场景二把这点量化了:10 万行数据取第 1000 页,offset 分页扫描 10000 行,游标分页只扫 10 行,差了约 1000 倍。这正好呼应数据专题里查询优化的那条原则:别让数据库做无谓的扫描。
而且 offset 分页还有个隐患:翻页过程中如果有数据增删,行号会错位,可能漏掉或重复某些数据。
游标分页:从"上次最后一条"接着取
游标分页(Cursor Pagination) 换了个思路:不按"第几页"取,而是按**"上一页最后一条的位置"**接着往下取。
请求长这样:?cursor=9990&size=10,服务端执行的是 WHERE id > 9990 ORDER BY id LIMIT 10。因为 id 上有索引、数据有序,数据库可以直接定位到 id=9990 之后,只读 10 行就返回——扫描行数恒定,和翻多深完全无关。返回时再带上这一页最后一条的 id 作为 nextCursor,调用方拿它请求下一页。
两种分页怎么选:
| offset 分页 | 游标分页 | |
|---|---|---|
| 用法 | page / size,能跳到任意页 | cursor / size,只能顺序往后 |
| 深翻性能 | 越深越慢 | 恒定快 |
| 能跳页吗 | 能(直接到第 50 页) | 不能(只能下一页) |
| 适合 | 后台管理、数据量小、要页码跳转 | 信息流、无限滚动、大数据量、深翻 |
一句话:要"页码跳转"用 offset,要"一直往下刷"用游标。信息流的无限滚动、评论流、消息列表,基本都该用游标分页。
四、错误处理:别只甩一句"系统异常"
接口出错时返回什么,最能看出设计者有没有为调用方着想。反面教材是这样的:不管什么错,一律 HTTP 200 + {"msg": "系统异常"}。调用方拿到这个,既不知道是自己传错了还是服务端崩了,也不知道具体哪儿错了,只能去翻日志、找你对线。
好的错误返回要做到三件事:状态码分清谁的锅、结构统一、信息够定位。
统一的错误结构
整个服务的错误返回,应该长一个样,调用方写一套解析逻辑就能处理所有接口。一个通用结构:
{
"code": "INVALID_ARGUMENT",
"message": "缺少必填参数 username 或 password",
"details": [
{ "field": "password", "issue": "required" }
]
}
code:机器可判定的稳定错误码,用字符串枚举(INVALID_ARGUMENT/INVALID_CREDENTIALS/BALANCE_NOT_ENOUGH)比用数字好——见名知意,且不会和 HTTP 状态码混淆。调用方靠它做if/else分支,所以一旦定下来就别随便改。message:给人看的可读描述,方便联调和打日志。但别把它当判断依据——文案随时会改、还可能要做多语言。details(可选):给定位用的细节,典型是参数校验——哪个字段、错在哪。表单校验场景里,客户端拿它能直接把错误标到对应输入框上。
demo 场景三把"烂返回 vs 好返回"摆在一起跑:同样三种登录失败,烂返回全是
200 + "系统异常",调用方只能盲猜;好返回用400(参数错)/401(密码错)/200(成功)分清大类,再配{code, message, details}—— 调用方一眼就知道该改自己、还是该重试、还是登录成功了。
记一条原则:错误信息要让调用方能"自助定位",而不是每次都来找你。给得清楚,联调效率高一截;给一句"系统异常",等于把活又踢回给对方。
五、其他容易漏的要点
前面四个是大头,下面这些不起眼,但缺了同样会让调用方难受、或埋下安全隐患。
- 版本管理:接口一旦被人用上,就不好随便改了——改了会破坏老调用方。在路径里带版本号(
/v1/users)是最常见的做法:要做不兼容的改动时,新开/v2,让老接口和新接口并存一段时间,给调用方迁移的缓冲。 - 永远别信任客户端:这是最关键的一道安全原则。客户端(浏览器、App、第三方调用)的参数校验只是为了体验好(早点提示用户),绝不能当作安全防线——请求可以被绕过、被伪造、被抓包改包。所以"这个金额不能是负数""这个 id 必须属于当前用户""这个字段必填"——服务端必须重新校验一遍,一个都不能省。客户端校验是锦上添花,服务端校验才是真正拦住坏数据的那道墙。
- 参数校验:进了接口先校验入参——类型对不对、必填缺没缺、范围越界没。校验不过就返
400+ 清楚的details,别让脏数据流进业务逻辑(到了数据库层才报错,排查成本高得多)。 - 限流:对外接口要有限流,保护后端不被打垮——不管是恶意刷还是调用方写了死循环。超过阈值返 429 Too Many Requests,并在响应头告诉对方"还剩多少额度、什么时候恢复"(
Retry-After)。这是把接口当公共资源来保护:宁可拒一部分,也别让所有人一起崩。 - 字段命名与时间格式:一套服务里命名风格要统一(要么全
camelCase要么全snake_case,别混)。时间统一用 ISO 8601 带时区(2026-06-07T10:00:00Z),别返回"今天 10 点"这种依赖时区和语言的字符串——跨时区一定出错。这些是细节,但不统一会让调用方反复踩坑。
六、看看真实的 API 怎么设计
上面这些原则不是拍脑袋——你天天在用的公开 API,基本都这么设计。挑两个最有代表性的:
GitHub API——RESTful 的范本:资源 + 动词(GET /repos/{owner}/{repo}、POST .../issues)、标准状态码(404 不存在、422 校验失败、403 权限/限流)、限流信息放响应头(X-RateLimit-Remaining / X-RateLimit-Reset)、版本走请求头(X-GitHub-Api-Version)。
Stripe API——支付场景的标杆,文章里几个点的"原版"都能在它身上看到:创建支付带 Idempotency-Key 请求头防重复扣款(幂等键)、列表用 starting_after 游标 + has_more(游标分页)、错误统一成 { error: { type, code, message, param } }(结构化错误)、用日期版本(Stripe-Version: 2023-10-16)且老版本长期兼容。
对应起来,正好印证全文:
| 文章原则 | 知名 API 怎么做 |
|---|---|
| 资源 + 动词 + 状态码 | GitHub:GET /repos/{owner}/{repo},标准 404 / 422 / 403 |
| 幂等键 | Stripe:Idempotency-Key 请求头 |
| 游标分页 | Stripe:starting_after + has_more;GitHub:Link 头给 next |
| 统一错误结构 | Stripe:{ error: { type, code, message } } |
| 版本管理 | Stripe 日期版本 / GitHub 版本请求头 |
| 限流 | GitHub:X-RateLimit-* 响应头,超限 403 / 429 |
另外,团队里"接口长什么样"通常用 OpenAPI(Swagger) 来描述——一份 YAML / JSON 定义清楚每个接口的路径、参数、响应、错误码,既能生成交互式文档,也能直接生成各语言的客户端 SDK,省掉手写对接的扯皮。
七、一张表:好接口 checklist
写完一个接口,对着这张表过一遍:
| 维度 | 检查点 |
|---|---|
| 资源 / 动词 | 路径是名词(资源),用 HTTP 动词表达操作,而不是 /getXxx 全 POST |
| 状态码 | 用 2xx / 4xx / 5xx 表达成败大类,不是全 200 再往 body 塞 code |
| 幂等 | 支付 / 下单 / 扣减等写操作,支持 Idempotency-Key,重复请求只生效一次 |
| 分页 | 大列表 / 深翻 / 信息流用游标分页,后台跳页才用 offset |
| 错误结构 | 统一 {code, message, details?};code 机器可判、message 给人看 |
| 版本 | 路径带 /v1,不兼容改动新开版本,别原地改坏老调用方 |
| 限流 | 对外接口有限流,超限返 429,告知何时恢复 |
| 校验 / 不信客户端 | 服务端重新校验所有入参,客户端校验只为体验、不作数 |
| 命名 / 时间 | 字段命名风格统一,时间用 ISO 8601 带时区 |
名词解释
- RESTful:一种 API 设计风格——把接口看成对"资源"(名词)的操作,用 URL 表示资源、用 HTTP 动词(
GET/POST/PUT/DELETE)表示操作。 - HTTP 状态码:HTTP 响应里表示结果的标准数字码;2xx 成功、4xx 调用方错、5xx 服务端错,全行业通用。
- 幂等(Idempotent):同一请求执行一次和多次,对系统产生的效果完全一样;写操作(支付 / 下单)尤其需要。
- 幂等键(Idempotency-Key):调用方为"一次业务操作"生成的唯一标识,放在请求头里;服务端靠它去重、回放结果,实现幂等。
- offset 分页:用
page/size(LIMIT/OFFSET)翻页,能跳任意页,但翻得越深扫描越多、越慢。 - 游标分页(Cursor Pagination):基于"上一页最后一条的 id / 时间"继续往后取,扫描行数恒定、深翻也快,但只能顺序翻。
- 限流(Rate Limiting):限制单位时间内的请求量,保护后端不被打垮;超限通常返回 429。
- 版本管理(API Versioning):在路径(
/v1)或请求头里标接口版本,做不兼容改动时新老并存,给调用方迁移缓冲。 - 状态码 4xx / 5xx:4xx 表示调用方的请求有问题(改了再来),5xx 表示服务端出错(重试可能就好)。
配套 demo:backend-notes/02-features/api-design/api-demo —— 跑一下,三个对照场景看清"能跑通"和"好接口"的差距:幂等(订单数 2 → 1)、分页(扫描行数 10000 → 10)、统一错误结构(全返 200 vs 状态码 +
{code,message})。本文属《研发都要懂的事》·「功能怎么实现」专题。完整代码与系列在 GitHub · backend-notes。
评论(0)
登录后参与评论。
还没有评论,来抢沙发吧。

