目录

RESTful API 最佳实践

很多 REST API 文章都停留在“GET 查、POST 增、PUT 改、DELETE 删”。这类内容对新人扫盲有用,但对已经上线、有多个客户端、还得扛几年演进的 API 没什么帮助。

真正麻烦的地方从来不是 HTTP 方法本身,而是:接口怎么设计才不容易烂,怎么改才不把老客户端打死,出问题时怎么定位,几年后还能不能收拾。

下面这套东西,更像是我做接口评审时会盯的点。

先建模资源,再写接口

很多 API 看起来像 REST,骨子里其实还是 RPC。

典型坏味道:

  • /createOrder
  • /getUserProfile
  • /updateUserStatus
  • /searchProductsByCategory

这种命名反映的是后端 handler,而不是业务对象。短期开发很顺手,长期一定越长越歪:每来一个新需求,就再长一个动词接口。

更稳的做法是先想清楚系统里的稳定资源:

  • /users
  • /orders
  • /products
  • /invoices
  • /shipments

然后再决定哪些操作适合用标准 HTTP 语义,哪些确实需要显式命令接口。

GET    /v1/orders/ord_123
POST   /v1/orders
PATCH  /v1/orders/ord_123
POST   /v1/orders/ord_123/cancel

最后这个 cancel 不算“纯粹 REST”,但没关系。业务里有些动作就是命令,强行套用成 CRUD 反而更牵强。

我的建议很简单:默认按资源设计,但不要为了教条主义把接口搞得难懂。

URL 要稳定、无聊、可猜

好的 URL 不需要解释太多,调用方大致能猜出来。

基本约定:

  • 集合用复数:/users/orders
  • 全小写
  • 需要分隔就用连字符
  • 资源标识放 path,筛选条件放 query
  • 不要在 URL 里带文件扩展名
  • 不要把业务动作偷偷塞进 query 参数

例如:

GET /v1/users/usr_42
GET /v1/orders?customer_id=cus_9&status=paid&limit=50
GET /v1/audit-events?actor_id=usr_42&sort=-created_at

同一个概念不要出现三套写法:

/users/42
/user/42
/getUser?id=42

这种不一致,客户端一多,维护成本会非常高得多。

版本策略别等出事故了再补

很多团队上线初期觉得“先不做版本,后面再说”。通常等到“后面再说”的那一天,接口已经被几个客户端、几个团队、几个脚本绑死了。

如果是对外或者生命周期较长的 API,路径版本依然是最省心的方案:

/v1/orders
/v2/orders

它不一定最优雅,但好排路由、好看日志、好做缓存、好查问题、好和客户端沟通。

Header 版本也能做,但调试成本更高,日志和网关治理也更麻烦。不是不能用,只是不建议默认用。

真正关键的不是版本号放哪,而是什么算 breaking change。

常见破坏性变更包括:

  • 删除字段
  • 字段改名
  • 字段类型变化
  • 枚举值含义变化
  • 分页语义变化
  • 之前能过的请求现在校验不过
  • 错误结构变化
  • 状态码语义变化

相对安全的非破坏性变更包括:

  • 新增可选响应字段
  • 新增接口
  • 新增可选请求字段
  • 新增枚举值,前提是客户端被明确告知枚举不是封闭集合

最后一条经常被低估。后端以为“只是多了一个状态”,客户端如果写死 switch,其实照样会炸。

兼容性是约束,不是愿望

接口容易失控,通常不是因为没人知道兼容性重要,而是团队没有把它写成规则。

至少把这些问题说清楚:

  • 一个大版本支持多久?
  • 破坏性变更提前多久通知?
  • 响应对象是否默认前向兼容?
  • 客户端能不能依赖字段存在、默认值、排序?
  • 哪些 header 属于稳定契约,哪些只是实现细节?

有两个规则,建议直接立下来:

  1. 客户端必须忽略未知字段。
  2. 服务端在同一版本内不能删除或重定义已有字段。

这两条看着普通,实际上能挡掉大量低级事故。

状态码别贪多,少而准就够了

很多文章会把 HTTP 状态码 1xx 到 5xx 全贴一遍。没必要。大多数团队真正会稳定用好的,也就十来个。

够用的一套通常是:

  • 200 OK:成功读取,或者成功更新且有响应体
  • 201 Created:成功创建
  • 202 Accepted:已接收,但异步任务还没完成
  • 204 No Content:成功,但没有响应体
  • 400 Bad Request:请求格式有问题
  • 401 Unauthorized:未登录或凭证无效
  • 403 Forbidden:已认证,但无权限
  • 404 Not Found:资源不存在,或者不想暴露是否存在
  • 409 Conflict:资源状态冲突
  • 412 Precondition Failed:并发更新条件不满足
  • 422 Unprocessable Entity:业务校验不通过
  • 429 Too Many Requests:触发限流
  • 500 Internal Server Error:服务端非预期错误
  • 503 Service Unavailable:过载或依赖不可用

关键不在于记全,而在于团队内部要统一:

  • 哪些错误一定是 422
  • 哪些场景用 409
  • 权限问题返回 403 还是 404
  • 限流时响应长什么样

最怕的是所有失败都返回 400。这样客户端根本没法做像样的处理。

错误响应要给机器看,也要给人看

错误结构的目标有两个:

  • 客户端程序能据此处理
  • 人在排查问题时能快速定位

一个比较实用的结构:

{
  "error": {
    "code": "validation_failed",
    "message": "One or more fields are invalid.",
    "details": [
      {
        "field": "email",
        "reason": "invalid_format"
      },
      {
        "field": "age",
        "reason": "must_be_greater_than_or_equal",
        "value": 15,
        "min": 18
      }
    ],
    "request_id": "req_01ht9z6r8m2f"
  }
}

几个原则:

  • code 要稳定,可文档化
  • message 给人看,可以微调
  • details 要尽量可操作,能定位字段或约束
  • 带上请求 ID,方便日志关联
  • 不要把堆栈、SQL、主机名这种内部信息直接吐给客户端

如果要做多语言,业务分支判断依赖 code,不要依赖英文文案。

校验要分层,别混成一锅

很多 API 的“参数校验”其实把几类东西全搅在一起了。

建议至少分三层看:

  • 传输层校验:JSON 格式错误、Content-Type 不对、必要字段缺失
  • 结构校验:类型不匹配、长度超限、格式非法
  • 业务校验:业务规则不满足、资源当前状态不允许、额度不足

例如 POST /v1/payouts

{
  "account_id": "acc_123",
  "amount": -50,
  "currency": "USD"
}

可能的结果:

  • JSON 非法 -> 400
  • amount 类型不对 -> 400422,看团队约定
  • 金额必须大于 0 -> 422
  • 账户存在但已冻结 -> 409422
  • 调用者无权操作该账户 -> 403

边界怎么划,不同团队可以有差异;但同一个 API 里前后不一致,基本就是坑。

幂等性要提前设计,不要指望“客户端别重试”

线上一定会重试。

  • 客户端会因为超时重试
  • 网关可能会重试
  • job worker 会因为 crash 重试
  • 用户自己会手抖点两次

如果接口不能容忍重复请求,迟早会造出重复订单、重复扣款、重复任务。

GETPUTDELETE 按语义应该天然幂等。POST 通常不是,但很多关键写操作最好把它做成幂等。

例如支付创建这类接口,建议支持幂等键:

POST /v1/payments
Idempotency-Key: 8b2f6f2a-4d60-4b42-9d4f-1a59a1b5d6aa

服务端规则一般是:

  • 第一次收到,正常处理并记录结果
  • 相同 key、相同 payload,再次到达时返回第一次结果
  • 相同 key、不同 payload,直接拒绝

例如:

{
  "error": {
    "code": "idempotency_key_reused",
    "message": "The idempotency key was already used with a different request.",
    "request_id": "req_01ht9zz8k1ad"
  }
}

不要把“不要重试”当设计前提。现实世界不会配合你。

分页、筛选、排序要统一

列表接口最容易越写越乱。

分页

内部系统、数据量不大时,offset 分页够用:

GET /v1/orders?limit=50&offset=100

优点是简单,缺点是数据频繁变动时容易跳页、重复、漏项。

高并发或大数据量场景,更推荐 cursor 分页:

GET /v1/orders?limit=50&after=ord_01ht...

响应可以类似:

{
  "data": [
    {
      "id": "ord_1001",
      "status": "paid"
    }
  ],
  "page": {
    "next_cursor": "ord_1001",
    "has_more": true
  }
}

如果用了 cursor,要明确:

  • 排序规则是什么
  • cursor 是否透明
  • cursor 什么时候失效
  • 默认顺序是什么

筛选

筛选条件放 query,但表达能力别过度设计。

这样比较正常:

GET /v1/users?status=active&team_id=team_42
GET /v1/invoices?created_at_gte=2025-01-01T00:00:00Z

这种就开始失控了:

GET /v1/search?q=status:active team:42 sort:created

除非你明确在做搜索 DSL,不然这种“半套查询语言”只会让文档、校验、调试都变难。

排序

排序语法最好全站统一。

例如:

GET /v1/orders?sort=-created_at,total_amount

文档里要说清楚:

  • 哪些字段支持排序
  • 默认排序是什么
  • 空值怎么处理
  • 多字段排序是否稳定

认证和授权必须分清

这两个词说了很多年,很多接口还是混着用。

  • 认证:你是谁
  • 授权:你能做什么

在 API 表现上要尽量清楚:

  • token 无效 -> 401
  • token 有效,但没有权限 -> 403
  • token 有效,但当前租户下资源不存在,且不想暴露资源存在性 -> 404

权限判断也别散落得到处都是。比较稳的做法通常是:

  • 中间件负责身份校验
  • handler 或 service 层做资源级授权
  • 授权失败返回统一错误结构

还有一点很实际:不要因为 JWT 用起来方便,就把一堆授权信息硬塞进 claim。claim 一旦过期策略、刷新策略、角色变更处理不好,线上会出现非常拧巴的问题。

缓存语义要明确,不要靠猜

缓存不是 CDN 专属,它本质上是 API 契约的一部分。

可缓存的读取接口,建议明确返回:

Cache-Control: public, max-age=60
ETag: "usr_42:v17"

如果是用户私有或敏感数据:

Cache-Control: private, no-store

如果允许缓存,但每次使用前必须回源校验:

Cache-Control: no-cache

常见问题:

  • 什么都不写,默认交给中间层自己猜
  • 用户私有数据却标成 public
  • ETag 只是随便拼个值,和实际响应内容对不上
  • 文档不写缓存语义,客户端各自理解

如果你不希望任何缓存参与,那就明确表达出来。

并发写入要有乐观锁方案

允许多人或多系统同时更新资源的 API,如果没有并发控制,最后经常演变成“谁最后写谁赢”,然后悄悄覆盖别人数据。

一个简单有效的模式:

  1. 客户端先读资源
  2. 服务端返回 ETag
  3. 客户端更新时带 If-Match
  4. 版本不一致就拒绝

例如:

GET /v1/users/usr_42
ETag: "user-42-v7"

之后更新:

PATCH /v1/users/usr_42
If-Match: "user-42-v7"
Content-Type: application/json

如果中间别人已经改过:

HTTP/1.1 412 Precondition Failed

这套东西特别适合:

  • 控制台编辑类资源
  • 配置类资源
  • 库存、额度、状态流转类资源
  • 多系统并发写入的记录

如果资源更新成本高,别偷懒用静默覆盖。

异步任务要当成正式接口设计

有些操作本来就不该同步阻塞:

  • 报表生成
  • 大文件导入
  • 视频转码
  • 大批量回填
  • 调用外部系统开通资源

这种时候,不要做成一个超长请求,然后赌超时没问题。

更稳的做法是:

POST /v1/report-jobs

返回:

HTTP/1.1 202 Accepted
Location: /v1/report-jobs/job_123
{
  "id": "job_123",
  "status": "queued"
}

然后客户端轮询:

GET /v1/report-jobs/job_123

状态一般至少要有:

  • queued
  • running
  • succeeded
  • failed
  • canceled

如果异步任务最终产出另一个资源,也要给清晰链接,不要让客户端自己猜流程。

可观测性不是后补项

一个 API 如果线上出问题时你答不出下面这些问题,那它就还不算准备好:

  • 哪个接口在报错?
  • 哪个租户、哪个客户端受影响?
  • 延迟是整体变慢还是尾延迟飙升?
  • 是哪个下游依赖挂了?
  • 这条错误日志对应哪次请求?

至少建议有这些东西:

  • 响应里带请求 ID
  • 结构化日志
  • 按路由分维度的延迟指标
  • 状态码计数
  • 下游依赖的调用指标
  • 跨服务调用时做 tracing

例如:

X-Request-ID: req_01ht9z6r8m2f

还有个很现实的提醒:别为了“方便排查”把整份用户请求原样 dump 到日志里。日志应该对排障有用,而不是把隐私和噪音一起放大。

限流要让客户端看得懂

限流不是单纯返回个 429 就完事。

至少让客户端知道:

  • 是什么维度在限:用户、token、租户还是 IP
  • 什么时候可以再试
  • 是否有不同接口的不同配额

一个实用点的响应:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests.",
    "request_id": "req_01ht9zzzz321"
  }
}

最糟糕的是那种“有时候会限,但规则不公开”的系统。客户端只能靠主观判断退避,最后两边都难受。

废弃策略要正式,不要靠口头通知

任何活得够久的 API,都会背历史包袱。问题不在于会不会积累旧字段、旧接口,而在于你怎么把它们退场。

一个能落地的废弃策略通常包括:

  • 在 changelog 和文档里明确声明
  • OpenAPI 标记 deprecated
  • 合适时返回 deprecation 相关 header
  • 给出 sunset 时间
  • 提供迁移文档
  • 下线前先看真实流量,确认还有谁在用

例如:

Deprecation: true
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: </docs/migrations/orders-v2>; rel="deprecation"

不要凭感觉说“这字段应该没人用了”。线上流量经常会教人做人。

这些反模式,评审时直接拦

1. 一个大接口包打天下

POST /v1/action

然后 body 里塞个 "type": "create_user""type": "cancel_order"

这种设计会把鉴权、校验、监控、文档全部搞复杂。

2. 直接暴露数据库模型

响应长得像 ORM dump,一开始开发很快,后面 schema 一改,API 跟着一起爆。

API 返回的应该是稳定资源视图,不是内部存储结构的镜像。

3. 可空语义混乱

同一个字段一会儿缺省,一会儿 null,一会儿空字符串。客户端最后只能满地 if。

一开始就定好规则。

4. 静默部分成功

批量接口部分成功、部分失败时,必须给逐项结果。只回一个 200 加一句模糊描述,基本等于没设计。

5. 把基础设施错误直接暴露出去

客户端不应该通过 "duplicate key value violates unique constraint" 才知道邮箱重复了。

应该翻译成业务层能理解的错误码和错误信息。

6. 为了“纯 REST”强行套用语义

有些业务动作就是动作,比如取消订单、重试发货、确认对账。用 /orders/{id}/cancel 往往比发明一套牵强状态流转更清楚。

一份够用的接口评审清单

我做 API 评审时,通常会快速过这些问题:

  • 资源建模是否稳定、命名是否一致?
  • 版本策略是否明确,而不是以后再补?
  • 错误结构是否统一、可文档化?
  • 客户端是否能安全重试?
  • 列表接口的分页、筛选、排序是否统一?
  • 认证和授权边界是否清楚?
  • 缓存与并发控制是否有明确语义?
  • 异步任务是否被当成正式契约设计?
  • 线上是否具备足够的可观测性?
  • 废弃旧能力时是否有明确路径?

如果这些问题里一半答案都是“先这么做,后面再说”,那接口其实还没设计完,只是已经部署了。

一个 REST API 能不能长期好用,核心从来不是它看起来像不像 REST,而是团队有没有把接口契约当回事。