接口评审上的争执很少落在实现,几乎都落在约定:这个操作该用 PUT 还是 POST,这个失败该返回 400 还是 422,分页该给 offset 还是 cursor。约定定错一次,后面每个调用方都要替它付代价——前端为 200 + body.code 写一层判断,运维在日志里分不清"业务失败"和"服务挂了",DBA 在深页 offset 上扛全表扫描。
这篇把接口设计拆成八个决策点:资源建模 → 动词语义 → 状态码 → 查询参数 → 批量操作 → 错误结构 → 幂等键 → 版本兼容。每个点给判据,不给口号。
1. 资源建模:判据是"它有没有身份"
REST 的资源是名词,是能被单独寻址的东西。判断一个候选资源是否成立,问三个问题:
| 判据 | 成立 | 不成立 |
|---|---|---|
| 能否被单独寻址并用 ID 取回 | /orders/1001 |
/getOrderById |
| 是否有独立生命周期(创建、变更、删除) | Order、Invoice |
OrderSummaryOfToday |
| 客户端是否需要对它做多种操作 | 一个资源多个动词化路径 | 每加一种操作就加一个 URL |
按这三条,/api/v1/orders/{id}/items 成立,/api/v1/getOrderItems 不成立。/api/v1/search 这类动名词混用的路径,如果它本质是"在订单集合上按条件检索",更应该是 GET /api/v1/orders?keyword=...;只有当检索横跨多个资源类型、无法归属单一集合时,才单独开一个查询资源。
# 好的形态
GET /api/v1/tenants/{tenantId}/orders?status=PAID&pageSize=20
GET /api/v1/orders/{orderId}
POST /api/v1/orders
PATCH /api/v1/orders/{orderId}
# 需要重新考虑的形态
GET /api/v1/queryOrderList
POST /api/v1/order/delete
GET /api/v1/tenant/order/detail
聚合边界:资源粒度按聚合根划,不按数据库表划。订单和订单行是一张主表和一张子表,但对外只暴露 Order 一个聚合根,行项目通过 /orders/{id}/items 访问。如果每张子表都开一套 CRUD,调用方就要自己维护一致性——而这本该是服务端的责任。
2. 动词的语义与幂等性
| 动词 | 安全(不改状态) | 幂等 | 典型语义 | 用错的后果 |
|---|---|---|---|---|
GET |
是 | 是 | 读取 | 浏览器/网关/爬虫预取会重复触发副作用 |
HEAD |
是 | 是 | 取元数据 | 少见,但适合探活 |
POST |
否 | 否 | 创建、触发动作 | 重试即重复下单 |
PUT |
否 | 是 | 整体替换 | 并发写互相覆盖;未提供的字段被清空 |
PATCH |
否 | 取决于实现 | 部分更新 | 用 +1 语义做 PATCH 时重试会多加 |
DELETE |
否 | 是 | 删除 | 重试返回 404 不等于失败 |
判断幂等的标准很朴素:同一个请求发两次,服务端状态是否相同。PUT /orders/1001 带完整表示发两次,最终状态一致,所以幂等;PATCH 带 {"status":"PAID"} 也是幂等的,但带 {"amountDelta": 10} 就不是。
// Spring Boot 3.3.x:用语义化注解表达动词,不要用 method 参数硬塞
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@GetMapping("/{id}") // 安全 + 幂等
public OrderView get(@PathVariable Long id) { ... }
@PostMapping // 非幂等:需要 Idempotency-Key 兜底
@ResponseStatus(HttpStatus.CREATED)
public OrderView create(@Valid @RequestBody CreateOrderCommand cmd) { ... }
@PutMapping("/{id}") // 幂等:整体替换
public OrderView replace(@PathVariable Long id,
@Valid @RequestBody ReplaceOrderCommand cmd) { ... }
@PatchMapping("/{id}") // 部分更新:语义由字段决定
public OrderView patch(@PathVariable Long id,
@RequestBody JsonPatch patch) { ... }
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 幂等:删两次结果一样
public void delete(@PathVariable Long id) { ... }
}
PATCH 的两种媒体类型要分清:application/json-patch+json(RFC 6902,一组 op/path/value 操作)和 application/merge-patch+json(RFC 7386,直接给要改的字段)。前者能做数组插入删除,后者更贴近多数业务代码的直觉。选一个,别同时支持两套再让调用方猜。
3. 状态码:4xx 是调用方的问题,5xx 是服务端的问题
这是唯一需要背下来的分界线。业务失败不是 5xx:库存不足、余额不够、状态机不允许,都是"请求合法但当前不被接受",属于 4xx。
| 码 | 何时用 | 何时不该用 |
|---|---|---|
200 OK |
同步读、同步改并返回结果 | 创建资源时 |
201 Created |
创建成功,配 Location 头 |
不返回新资源时可有可无 |
202 Accepted |
已受理、异步处理 | 实际同步做完了 |
204 No Content |
成功但无响应体(删除、无返回更新) | 有响应体却硬返回 204 |
301 / 302 |
域名迁移、路径永久/临时改 | API 版本内部跳转 |
304 Not Modified |
配合 ETag / If-None-Match |
没做缓存协商时 |
400 Bad Request |
语法错、参数类型错、缺必填 | 语义校验失败(用 422) |
401 Unauthorized |
未认证:没带或带了无效凭证 | 已登录但没权限 |
403 Forbidden |
已认证但无权限 | 未登录场景 |
404 Not Found |
资源不存在,或调用方无权知道它存在 | 用它表达业务失败 |
409 Conflict |
状态冲突:重复创建、乐观锁版本不符 | 参数不合法 |
422 Unprocessable Content |
语法对、语义不合法:字段校验失败 | 单纯语法错误 |
429 Too Many Requests |
限流,配 Retry-After |
服务过载(那是 503) |
500 / 502 / 503 / 504 |
未捕获异常 / 上游坏响应 / 过载或维护 / 上游超时 | 任何可预期的业务拒绝 |
401 与 403 混用是最常见的错。客户端的处理逻辑依赖这个区分:401 应跳登录并刷新 token,403 应提示"没有权限"并停止重试。全用 403 的结果是前端在 token 过期时反复弹"无权限"。
资源存在但对当前调用方不可见时返回 404 而非 403,是一种有意为之的信息隐藏,避免通过状态码探测资源是否存在。这是取舍,不是错误,但要在接口文档里统一,不能一半 403 一半 404。
// Spring Boot 3.3.x:ProblemDetail 是内置的 RFC 9457 载体,不必自己造错误体
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail onNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://errors.example.com/order-not-found"));
pd.setTitle("订单不存在");
pd.setProperty("bizCode", "ORDER_NOT_FOUND");
return pd;
}
@ExceptionHandler(OptimisticLockException.class)
ProblemDetail onConflict(OptimisticLockException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "资源已被他人修改,请重新获取");
}
4. 查询参数:过滤、排序、分页分开管
三组独立参数,不要塞进一个 query 字符串让服务端去解析。
GET /api/v1/orders
?status=PAID,SHIPPED # 过滤:集合用逗号
&createdFrom=2026-01-01T00:00:00Z
&createdTo=2026-02-01T00:00:00Z
&sort=-createdAt,id # 排序:- 前缀表示降序,id 做稳定排序键
&pageSize=20 # 分页
&cursor=eyJpZCI6MTAwMSwi... # 游标式
分页的三种形态,差别在数据变动时的表现:
| 形态 | 参数 | 优点 | 数据变动下的问题 |
|---|---|---|---|
| offset | page=3&pageSize=20 |
简单、可跳页 | 深页要 LIMIT 20 OFFSET 40000,数据库得扫过前 4 万行;期间有插入则元素重复或漏 |
| cursor | cursor=<不透明令牌> |
稳定、深页也快 | 不能跳页;必须基于唯一有序键 |
| search_after | search_after=[ts,id]&size=20 |
ES 中深分页的标准解 | 只适用于能提供唯一排序键的引擎;同样不能跳页 |
search_after 在 Elasticsearch 里替代 from/size,原因和 SQL 里的深 OFFSET 一样:from+size 需要每个分片各自取前 from+size 条再归并,from 越大,协调节点要处理的数据越多。按 Elasticsearch 8.x 的默认设置,index.max_result_window 是 10000,超过直接报错——这个上限不是为难人,是在提示该换 search_after 或 PIT + search_after。
游标要不透明:客户端拿到 base64 编码的服务端排序键,不是明文 ID。这样排序键改了不用改协议,客户端也无法构造非法游标。
-- offset:深页代价随 page 增大
SELECT * FROM orders
WHERE tenant_id = ? AND status = ?
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40000;
-- cursor:代价与页码无关,只依赖排序键上的索引
SELECT * FROM orders
WHERE tenant_id = ? AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
排序一定要带唯一键兜底。只按 created_at DESC 排,同一毫秒内创建的多条记录在两次查询间顺序可能不同,翻页就会重复或漏。sort=-createdAt,id 里的 id 不是可选装饰。
5. 批量操作:接口形状取决于语义
| 形态 | 路径 | 原子性 | 适用 |
|---|---|---|---|
| 批量创建 | POST /api/v1/orders/batch |
通常部分成功 | 导入、批量下单 |
| 批量改状态 | PATCH /api/v1/orders/batch-status |
通常部分成功 | 批量发货、批量关闭 |
| 循环单条 | 客户端循环调用 | 由客户端决定 | 条数少且需要逐条反馈 |
判据是条数与一致性需求:几十条以内、每条结果都要独立反馈,循环单条更简单,还省掉一个要长期维护的批量接口;上千条以上,网络往返本身成了主要成本,必须做批量。但批量接口必须明确回答:中间某条失败,其余算成功还是全回滚?
推荐默认"部分成功 + 逐条结果",它对调用方更有用,也避免长事务:
POST /api/v1/orders/batch
{
"items": [
{ "clientKey": "a1", "sku": "S-1", "qty": 2 },
{ "clientKey": "a2", "sku": "S-2", "qty": 1 }
]
}
→ 207 Multi-Status(或 200,二选一并在文档里固定)
{
"succeeded": [{ "clientKey": "a1", "id": 1001 }],
"failed": [{ "clientKey": "a2", "code": "OUT_OF_STOCK", "message": "库存不足" }]
}
业务确实要求全或无时,就用 422/409 整体失败并配事务;不要既说"批量"又让人猜原子性。批量接口还要有上限:items.size() <= 100,在 @Valid 里用 @Size(max = 100) 卡住,超限返回 422。没有上限的批量接口等价于一次可被外部控制的雪崩入口。
6. 错误响应:业务码是 HTTP 码的补充,不是替代
- HTTP 状态码给通用客户端(网关、监控、SDK、浏览器)用,决定重试、告警、缓存行为;
- 响应体里的业务码给业务代码用,决定提示什么文案、走哪条分支。
// RFC 9457 ProblemDetail + 扩展字段
{
"type": "https://errors.example.com/validation-failed",
"title": "参数校验失败",
"status": 422,
"detail": "请求中有 2 个字段不合法",
"instance": "/api/v1/orders",
"bizCode": "VALIDATION_FAILED",
"traceId": "3f6a1c9e8b2d4a10",
"errors": [
{ "field": "items[0].qty", "code": "MIN", "message": "数量必须大于 0" },
{ "field": "receiver.phone", "code": "PATTERN", "message": "手机号格式不正确" }
]
}
字段级错误的 field 用路径表达式(items[0].qty)而不是字段名,前端才能定位到具体那一行输入框。traceId 是必需的:没有它,日志和用户截图对不上。
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onValidation(MethodArgumentNotValidException ex) {
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of(
"field", fe.getField(),
"code", fe.getCode() == null ? "INVALID" : fe.getCode(),
"message", String.valueOf(fe.getDefaultMessage())))
.toList();
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, "请求中有 " + errors.size() + " 个字段不合法");
pd.setProperty("errors", errors);
return pd;
}
}
7. 幂等键:给非幂等动词补一层保护
POST 天生不幂等,但客户端超时后无法区分"没到"和"到了但响应丢了"。Idempotency-Key 解决的就是这个不确定窗口。
| 要点 | 做法 |
|---|---|
| 谁生成 | 客户端生成 UUID,同一次业务重试复用同一个键 |
| 存多久 | 至少覆盖客户端最大重试窗口,典型量级 24 小时 |
| 存什么 | 键 → 状态(处理中/已完成)+ 首次响应体 |
| 并发同键 | 用 SET key value NX 抢占,第二个请求返回 409 或短暂等待 |
| 请求体不一致 | 同键不同体应拒绝(422),防止键被复用去改语义 |
| 键作用域 | 键 + 租户 + 路径 组成实际 key,避免跨租户撞键 |
@Component
class IdempotencyFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String key = req.getHeader("Idempotency-Key");
if (key == null || !"POST".equals(req.getMethod())) { chain.doFilter(req, res); return; }
String tenant = String.valueOf(req.getAttribute("tenantId"));
String storeKey = "idem:" + tenant + ":" + req.getRequestURI() + ":" + key;
Boolean first = redis.opsForValue()
.setIfAbsent(storeKey, "IN_PROGRESS", Duration.ofHours(24));
if (Boolean.FALSE.equals(first)) {
res.setStatus(409);
res.setContentType("application/problem+json");
res.getWriter().write(
"{\"status\":409,\"title\":\"重复请求\","
+ "\"detail\":\"该 Idempotency-Key 已在处理或已完成\"}");
return;
}
chain.doFilter(req, res);
}
}
注意 setIfAbsent 之后的动作:真正要"返回首次响应体",需要在业务完成后把响应写回同一个 key,并让后到请求读它。如果只做"去重"不做"重放原响应",客户端拿到的 409 里没有业务结果,仍要再查一次——这已经是可接受的降级,但要在文档里写清楚。
8. 版本与兼容(回顾式)
只记三点结论:URL 主版本(/api/v1/)用于不兼容变更;新增可选字段、放宽校验、新增枚举值属于向后兼容;删除字段、收紧校验、改字段语义属于破坏性变更。响应里多返回字段永远比少返回安全,客户端必须忽略未知字段——这一条要写进对接规范,否则每次加字段都要全员沟通。
9. 常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
用 GET 做有副作用的操作 |
预取/重试/缓存把数据改了;爬虫触发批量删除 | 改 POST,需要幂等就配幂等键 |
所有错误都 200 + body 里的 code |
网关、监控、SDK 的重试与告警全部失效 | HTTP 码与业务码分层,见第 6 节 |
用 404 表示业务不存在 |
资源明明存在(订单在,只是已关闭),客户端以为 URL 写错 | 状态冲突用 409,业务规则拒绝用 422 |
| offset 深页扫全表 | 第 2000 页响应时间随页码线性上涨 | 换 cursor / search_after |
| 排序没有唯一键 | 翻页出现重复或缺记录 | sort=-createdAt,id |
| 批量接口没有条数上限 | 单个请求能拖垮服务 | @Size(max=...) 卡住 |
PUT 当 PATCH 用 |
没传的字段被置空 | 整体替换用 PUT,部分更新用 PATCH |
| 错误体里没有 traceId | 用户报障时无法定位日志 | 全局过滤器注入 traceId |
| 批量接口直接循环单条 SQL | 上千条时整体超时 | 分批 batch 提交或合并语句 |
10. 排错
| 现象 | 排查方向 |
|---|---|
| 客户端说传了参数但服务端收到空 | 检查 Content-Type;@RequestBody 缺失时 Spring 按表单解析 |
| 创建接口返回了两个资源 | 客户端超时重试;查是否有 Idempotency-Key,查网关是否自动重试了 POST |
401 后前端死循环跳登录 |
刷新 token 的接口本身也返回 401,形成回路;把它排除在拦截器外 |
PATCH 后字段被清空 |
merge-patch+json 里 null 的语义是"删除该字段",不是"不改" |
分页 total 与实际条数不符 |
count 与 list 的 where 条件不一致 |
| 时间区间过滤少数据 | 时间戳带不带时区;服务端统一 UTC 存储并显式带 Z |
| 并发更新后数据丢失 | 用 PUT 整体替换导致覆盖;加版本号或改 PATCH |
11. 边界
| 场景 | 更适合的做法 |
|---|---|
| 内部服务间高频调用、字段高度一致 | 可放宽 REST 严格性,甚至用 gRPC,路径语义不那么重要 |
| 纯动作型接口(发短信、触发对账) | 动名词路径 + POST 是合理的,不必强行找名词 |
| 文件上传/下载 | POST /files + 返回 ID;不要用 PUT 传二进制 |
| 实时推送 | SSE / WebSocket,不属于请求-响应模型 |
| 图查询、复杂检索 | POST /search 带复杂 body 比把条件全塞进 query string 更现实 |
| 超大结果集导出 | 异步任务 + 任务资源轮询,不要同步返回几十万行 |
12. 自测
- 一个"取消订单"的操作,资源该怎么建模,用哪个动词,返回什么状态码?
401和403混用会让客户端哪段逻辑失效?- 为什么
sort=-createdAt单独作为排序键会导致翻页漏数据? - 用
PUT实现"只改地址"的接口会有什么后果? Idempotency-Key命中时返回409和返回首次响应体,各自对客户端意味着什么?- 批量接口在什么条件下应该做成"部分成功",什么条件下必须全或无?