|

Aimee

Write the Code. Change the World.

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):

  1. 调用方为"这一次业务操作"生成一个全局唯一的 key(比如一个 UUID),放在请求头 Idempotency-Key 里。注意 key 代表"这一次下单"这个意图,重试要带同一个 key,而不是每次新生成。
  2. 服务端收到请求,先拿这个 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

登录后参与评论。

还没有评论,来抢沙发吧。

回到顶部