site logo

Marico's space

你以为 REST API 设计得很优雅?上线第一天就崩给你看

服务器技术 2026-04-23 21:12:20 13

写在前面:这篇文章的原文标题很扎心——「当你那『干净』的 REST API 变成生产环境噩梦」。读完之后发现说的就是我自己踩过的坑,所以忍不住转写了一下,原文链接附在顶部,感兴趣可以直接去看英文版。


纸面上看,一切都完美得不像真的:

  • 端点干干净净
  • 资源命名优雅
  • HTTP 方法用得标准

然后一上线,傻眼了:

  • 客户端开始疯狂重试
  • 数据不一致悄悄冒出来
  • 版本管理乱成一锅粥
  • 改一个地方,三个调用方集体爆炸

这时候才明白一个扎心的道理:

REST API 设计不是什么优雅活儿——本质上是在「不确定性下的契约设计」。


生产环境下,REST API 真正要面对的约束

你以为在设计端点?

实际上你在设计的是——一份在重重不确定性下的契约

真正影响你 API 设计的因素有哪些:

  • 多端客户端(Web、移动端、第三方)
  • 网络不可靠
  • 向后兼容的压力
  • 局部失败
  • 延迟预算
  • 数据归属边界

忽视这些,API 在规模面前就是纸糊的。


资源建模,才是大部分人翻车的地方

人人都知道 /users/orders

但这只是表面。

真正要问的问题是:

你的资源,生命周期是什么?

❌ 糟糕设计(CRUD 思维)

POST   /orders
GET    /orders/:id
PUT    /orders/:id
DELETE /orders/:id

看起来很标准。放在真实系统里,完全不对。

为什么?

  • 订单不是你想改就能改的
  • 状态流转有讲究(创建 → 已支付 → 已发货)
  • 业务规则完全被无视了

把状态转换建模清楚,比什么都重要

好一点的写法:

POST   /orders
POST   /orders/:id/pay
POST   /orders/:id/ship
POST   /orders/:id/cancel

这样做的好处:

  • 业务逻辑直接编码进 API
  • 无效的状态转换被拦截掉
  • 减少调用方出 bug 的概率

幂等性:那个让你免于混乱的东西

大部分 API 在重试面前一击即溃。

现实是:

  • 客户端会重试
  • 代理服务器会重试
  • 负载均衡器也会重试

如果你的接口不具备幂等性 → 重复操作。

真实的失败场景

支付接口:

POST /payments

客户端超时 → 重试 → 扣了两次钱。

恭喜,用户信任-1。

解决方案:幂等 Key

POST /payments
Idempotency-Key: 8f3a-xyz-123

服务端逻辑:

if (exists(idempotencyKey)) {
  return previousResponse;
}

processPayment();
storeResult(idempotencyKey);

局部失败处理:那个沉默的杀手

你的 API 调用了:

  • 数据库
  • 缓存
  • 外部服务

其中一个挂了。

然后呢?

大多数 API:

返回 500,然后祈祷。

这不是策略,这是放弃。

更好的做法:显式失败语义

  • 能返回部分成功就返回部分成功
  • 使用补偿操作
  • 记录关联 ID

例子:

{
  "status": "partial_success",
  "data": {...},
  "failed_dependencies": ["inventory-service"]
}

版本管理:API 的墓地

很多团队的做法:

/v1/users
/v2/users

问题来了:

  • 你现在要永远维护两套系统
  • 而且客户端永远不会主动迁移

更好的策略:演进优先于版本

  • 只加字段,不删字段
  • 使用默认值
  • 渐进式废弃

什么时候确实需要版本

  • 契约发生了破坏性变更
  • 语义层面的变化,而不只是字段变化

即使到了那个地步:

优先用 Header 做版本控制

Accept: application/vnd.myapi.v2+json

Overfetching vs Underfetching

REST 的经典难题。

Overfetching(过度获取)

GET /users/:id

返回了:

  • 姓名
  • 邮箱
  • 地址
  • 偏好设置
  • 活动日志

客户端其实只需要姓名。

浪费了:带宽 + 延迟。

Underfetching(获取不足)

客户端需要:

  • 用户信息
  • 订单
  • 支付记录

结果发了 3 次请求。

延迟直接乘以 3。

务实的解法:可控扩展

GET /users/:id?include=orders,payments

代价:

  • 后端逻辑更复杂
  • 但调用方效率大幅提升

一个生产级 API 应该长什么样

来一个 Express.js 示例:

const express = require('express');
const app = express();

// 中间件:每个请求带上 ID,方便追踪
app.use((req, res, next) => {
  req.id = crypto.randomUUID();
  next();
});

// 幂等性中间件
const store = new Map();

app.post('/payments', async (req, res) => {
  const key = req.headers['idempotency-key'];

  if (store.has(key)) {
    return res.json(store.get(key));
  }

  const result = await processPayment(req.body;

  store.set(key, result);
  res.json(result);
});

// 显式状态转换
app.post('/orders/:id/ship', async (req, res) => {
  const order = await getOrder(req.params.id);

  if (order.status !== 'paid') {
    return res.status(400).json({ error: 'Invalid state' });
  }

  await shipOrder(order);
  res.json({ status: 'shipped' });
});

那些会把 API 搞死的常见错误

❌ 把 REST 当 CRUD 用

你忽略了:

  • 业务逻辑
  • 状态转换
  • 不变式

❌ 无视超时和重试

你的系统平时挺好……直到网络抖动来敲门。

❌ 没有任何可观测性

没有:

  • 请求 ID
  • 结构化日志
  • 链路追踪

调试就变成了猜谜。

❌ 和数据库 schema 强耦合

改数据库 → API 跟着挂

正确的认识是:

API 是一份契约,不是数据库的镜子

❌ 滥用 HTTP 状态码

有人这样做:

200 OK(body 里其实装着错误)

或者:

所有情况都返回 500

两种都是反模式。


那些你逃不掉的权衡

灵活性 vs 简单性

  • 灵活的 API → 维护成本高
  • 简单的 API → 适用场景有限

性能 vs 一致性

  • 强一致性 → 更慢
  • 最终一致性 → 更复杂

版本化 vs 演进

  • 版本化 → 碎片化
  • 演进 → 对变更有约束

抽象 vs 控制

  • 高抽象 → 用起来简单
  • 低抽象 → 性能更好

一个成熟的 REST API 真正长什么样

  • 显式的状态转换
  • 幂等操作
  • 向后兼容的变更
  • 内置可观测性
  • 可控的数据获取
  • 对失败有感知的响应

最后的现实检查

如果你的 API:

  • 在重试面前会挂
  • 稍微演进一下就陷入版本混乱
  • 把业务逻辑藏着掖着
  • 没有可观测性

那它根本不是生产就绪的。


核心要点

  • REST 不是 CRUD——本质上是在失败条件下设计契约
  • 幂等性是必须的,不是可选项
  • 状态转换必须显式建模
  • 版本化是最后手段,不是默认选项
  • 大部分故障来自网络行为,不是代码本身
  • API 设计要处理的是「异常情况」,不是理想路径

如果你设计 API 的时候假设一切都会正常运行,那你的系统在它不正常运行的那一刻,就会崩溃。