RESTful API 设计翻车现场:我踩过的那些坑,你们千万别踩

2026-08-08 6 0

RESTful API 设计翻车现场:我踩过的那些坑,你们千万别踩

干了这么多年后端,写过的 API 没有一千也有八百了。从最初的「能跑就行」到现在的「强迫症式设计」,中间隔着无数个深夜 Debug 和线上事故。今天不整那些虚的,就聊聊我在 API 设计上犯过的蠢错误,以及怎么避开这些坑。

如果你正在设计 API,或者觉得自己写的 API 挺美的,建议看完——说不定你正在犯同样的错。


坑一:把 HTTP 状态码当摆设

这是最常见的问题。我见过太多接口返回 200 然后在 body 里塞个 {code: 500, msg: "服务器挂了"}。兄弟,你这是掩耳盗铃啊!

HTTP 状态码是 HTTP 协议给开发者的礼物,它让调用方可以在不了解响应体的情况下判断请求结果。你的代码可能是这样的:

// 错误示例
app.get("/user/:id", async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) {
    return res.status(200).json({
      code: 404,
      message: "用户不存在"
    });
  }
  res.json(user);
});

// 正确示范
app.get("/user/:id", async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) {
    return res.status(404).json({
      message: "用户不存在"
    });
  }
  res.json(user);
});

前者的问题是:调用方看到 200,以为一切正常,结果解析 body 发现 code 是 404,还得再写一堆错误处理逻辑。后者呢?调用方一个 .catch() 就能搞定所有错误情况。

记住:2xx 是成功,4xx 是客户端问题,5xx 是服务端问题。别跟 HTTP 协议对着干。


坑二:RESTful 动词乱用

很多人知道 RESTful 要用 GET、POST、PUT、DELETE,但实际写出来的东西跟 RESTful 半毛钱关系没有。比如这样的:

// 披着 RESTful 皮的非 RESTful API
POST /api/getUser      // 获取用户
POST /api/deleteUser   // 删除用户
POST /api/updateUser   // 更新用户

这不叫 RESTful,这叫「把 HTTP 当传输协议用的 RPC」。真正的 RESTful 应该是:

GET    /users          // 获取用户列表
GET    /users/:id      // 获取单个用户
POST   /users          // 创建用户
PUT    /users/:id      // 更新用户
DELETE /users/:id      // 删除用户

名词用复数,动词靠 HTTP 方法。简单、清晰、一目了然。调用方看到 URL 就知道在干啥,都不用看文档(当然,理想情况下)。


坑三:分页参数随心所欲

「页码从 0 开始还是从 1 开始?」「每页大小叫 pageSize 还是 limit 还是 page_size?」这种问题能让你和前端吵一整天。

我的建议是:用游标分页,别用页码分页。为什么?

页码分页的问题在于:如果在翻页过程中数据被插入或删除,你的数据会重复或缺失。用户体验就是:我明明没点「下一页」,怎么看到的数据跟上一页有重复?

// 传统的页码分页(有问题的)
GET /articles?page=2&pageSize=20

// 游标分页(推荐方案)
GET /articles?cursor=eyJpZCI6MTAwfQ&limit=20
// 返回: { data: [...], nextCursor: "eyJpZCI6MTIwfQ", hasMore: true }

游标分页的好处是:不管数据怎么变,分页结果始终是连续的。当然,如果你列表页有「跳转到第 X 页」的需求,那只能用页码分页——但那种需求真的常见吗?


坑四:不做版本管理

「我这 API 肯定不会变!」——说这话的人,三天后就会哭着改字段。

API 版本管理是必须项。常见的做法有三种:

  1. URL 路径版本/api/v1/users/api/v2/users
  2. Header 版本Accept: application/vnd.example.v2+json
  3. Query 参数版本/api/users?version=2(不推荐,违反幂等性)

我建议用第一种,URL 清晰、调试方便、Nginx 转发也容易。虽然看起来丑,但实用才是第一位的。

版本升级的原则:v1 能用就别动,v2 是全新的,v1 迟早要下线。别搞出一个 v1.5、v1.6 出来,没人记得住哪些字段在哪个版本存在。


坑五:返回数据「太贴心」

有些接口喜欢返回这种结构:

{
  "success": true,
  "message": "操作成功",
  "data": {
    "id": 1,
    "name": "张三"
  }
}

如果 success 是 true,message 就是废话;如果 success 是 false,data 就是废话。这种「双向冗余」的设计,本质上是对 HTTP 状态码的不信任。

更好的做法:

// 成功:HTTP 200,直接返回数据
{ "id": 1, "name": "张三" }

// 失败:HTTP 4xx/5xx,返回错误信息
{ "message": "用户不存在", "code": "USER_NOT_FOUND" }

一个请求要么成功要么失败,用 HTTP 状态码区分就够了。success 字段?删了吧。


坑六:忽视安全——CORS 和认证

很多新手写 API 时为了「调试方便」,会这样配置 CORS:

app.use(cors({
  origin: "*"  // 生产环境千万别这样写!
}));

我知道你想快速验证功能,但线上环境这样搞,等着被 CSRF 攻击吧。

正确的做法:明确允许的 origin 列表,或者使用 token 验证。说到 token,又是一个大坑——有人把 token 放 URL 里(?token=xxx),有人明文传输密码,有人 JWT 不设过期时间……

安全无小事,每个字段都值得你认真对待。Authentication 和 Authorization 是两个概念,前者是证明你是谁,后者是证明你能干什么。别搞混了。


坑七:接口文档靠「嘴」

「接口文档?我脑子记着呢!」——这种人一般在第三版需求后就忘了第一版长什么样了。

强烈建议用 OpenAPI (Swagger) 规范来定义 API。它能自动生成文档、提供调试界面、甚至能生成客户端代码。

openapi: 3.0.0
info:
  title: 用户 API
  version: 1.0.0
paths:
  /users/{id}:
    get:
      summary: 获取用户信息
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"

写文档一时爽,一直写一直爽。等你需要对接第三方、或者新人接手项目时,你会感谢当初写文档的自己。


坑八:忽视性能——N+1 查询

这个问题在做关联查询时特别常见。比如要获取用户列表及其订单:

// 错误示例:N+1 查询
app.get("/users", async (req, res) => {
  const users = await db.query("SELECT * FROM users");
  // 每个用户都要再查一次订单
  const usersWithOrders = await Promise.all(
    users.map(user => {
      const orders = await db.query(
        "SELECT * FROM orders WHERE user_id = ?",
        [user.id]
      );
      return { ...user, orders };
    })
  );
  res.json(usersWithOrders);
});

100 个用户?101 次数据库查询。数据库连接被打满,用户等得花儿都谢了。

// 正确做法:JOIN 或者批量查询
app.get("/users", async (req, res) => {
  const users = await db.query("SELECT * FROM users");
  const userIds = users.map(u => u.id);
  
  // 一次查询获取所有相关订单
  const orders = await db.query(
    "SELECT * FROM orders WHERE user_id IN (?)",
    [userIds]
  );
  
  // 内存中组装数据
  const orderMap = orders.groupBy(o => o.user_id);
  const usersWithOrders = users.map(user => ({
    ...user,
    orders: orderMap[user.id] || []
  }));
  
  res.json(usersWithOrders);
});

2 次查询 vs 101 次查询,性能差距自己体会。


坑九:错误信息「太技术」

见过这种错误返回吗?

{
  "error": "NullPointerException at com.example.service.UserService.getUser(UserService.java:45)"
}

这种错误信息给谁看?给用户看?用户一脸懵。给自己看?堆栈信息应该记日志,不应该返回给调用方。

正确的错误响应应该是:

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "找不到这个用户,可能是 ID 输错了?",
    "reference": "https://api.example.com/docs/errors#USER_NOT_FOUND"
  }
}

用户看得懂,开发者能定位问题,还提供了文档链接——这才叫专业的错误处理。


坑十:API 变更不留记录

很多团队的 API 变更记录就是「这次改了个字段」。等线上出问题,一查才发现三个月前有人改了个字段名,导致部分调用方数据异常。

建议用 Changelog 记录每个版本的变更:

## v2.3.0

### 新增
- `/orders` 支持按状态筛选:`?status=pending`

### 废弃
- `/users/:id/friends` 将于 v3.0 移除,请改用 `/users/:id/contacts`

### 修复
- 修复了分页时数据不连续的 BUG

### 变更
- `User.avatar` 字段类型从 string 改为 object(包含 url, size, format)

这样调用方能清楚知道哪些地方需要适配,升级成本可评估。不用 changelog 的团队,API 迟早烂掉。


写在最后

API 设计这事,说难不难,说简单也不简单。难的地方不在于用什么技术,而在于怎么在「功能完整」「性能优秀」「易于维护」「用户体验好」这几个维度里找到平衡。

以上十坑,是我这些年踩过的真实教训。有些是自己作死,有些是赶工期妥协,但不管是哪种,回头看都是「早知道就好了」的时刻。

如果你正在设计新 API,对照着检查一下,说不定能少走几年弯路。如果你发现正在犯其中的某个错——恭喜你,这篇文章来得正是时候。

API 设计是一场修行,且写且珍惜。各位道友,共勉。

相关文章

为什么你的数据库事务,正在慢慢杀死你的性能
为什么你的API总被吐槽?这份RESTful设计避坑指南能救你
你的 ORM 正在偷偷吃掉你的性能——一个被低估了五年的问题
为什么你的API总是被人骂?因为你踩了这5个坑
还在手动部署AI工具?看这篇文章省下你半天时间
你的’容错机制’,正在亲手杀死你的服务

发布评论