site logo

Marico's space

那个看起来很干净的 REST API,是怎么在上线后变成噩梦的

服务器技术 2026-04-23 21:38:44 14

纸上谈兵的时候,一切都显得很完美:

  • Endpoints 写得干干净净
  • 资源命名挑不出毛病
  • HTTP 方法用得中规中矩

然后一上线,傻眼了:

  • 客户端开始疯狂重试
  • 数据不一致的问题冒出来了
  • 版本管理乱成一锅粥
  • 改一个地方,三个消费者炸了

这时候才明白一个扎心的真相:

REST API 设计不是为了优雅——而是为了在变化中活下来。


生产环境中 REST API 的真实约束

API生产约束

你设计的不是 endpoints,你设计的是不确定性下的契约

真正影响你 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 在重试面前一败涂地。

现实是:

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

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

真实翻车案例

支付 API:

POST /payments

客户端超时 → 重试 → 重复扣款。

恭喜,用户信任清零。

修复方案:幂等性 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 的死亡之地

naive 做法:

/v1/users
/v2/users

问题:

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

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

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

什么时候真的需要版本控制

  • 破坏性变更
  • 语义层面的变化(而不只是字段变化)

即使这样,也推荐:

基于 Header 的版本控制

Accept: application/vnd.myapi.v2+json

过度获取 vs 获取不足

overfetching underfetching

经典的 REST 问题。

过度获取

GET /users/:id

返回:

  • Name
  • Email
  • Address
  • Preferences
  • Activity logs

但客户端只需要 Name。

浪费:带宽 + 延迟。

获取不足

客户端需要:

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

要发 3 次请求。

延迟直接翻倍。

实用方案:受控展开

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

权衡:

  • 后端更复杂
  • 但客户端效率更高

实战:一个生产级 API 的样子

生产级API实现

Express.js 示例

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

// Middleware: request ID for tracing
app.use((req, res, next) => {
  req.id = crypto.randomUUID();
  next();
});

// Idempotency middleware
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);
});

// Explicit state transition
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 里藏着 error)

或者:

500 for everything

两种都是错的。


你无法逃避的权衡

灵活性 vs 简单性

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

性能 vs 一致性

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

版本控制 vs 演进

  • 版本控制 → 碎片化
  • 演进 → 变更受限

抽象 vs 控制

  • 高抽象 → 容易使用
  • 低抽象 → 更好性能

成熟的 REST API 长什么样

成熟REST API

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

最终现实检查

如果你的 API:

  • 在重试下就崩
  • 无法在不搞乱版本的情况下演进
  • 隐藏业务逻辑
  • 缺乏可观测性

那它就不是生产就绪的。


核心要点

  • REST 不是 CRUD——它是在失败条件下设计契约
  • 幂等性是不可妥协的
  • 状态转换必须是显式的
  • 版本控制是最后手段,不是默认选项
  • 大多数失败来自网络行为,而不是代码本身
  • API 设计是为了处理糟糕情况,而不是理想流程

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