写API五年,我踩过的那些坑,现在全都告诉你

2026-09-26 16 0

干了五年后端,踩过的坑能绕地球三圈。今天不整虚的,把最痛的几次经历掰开揉碎讲讲,有些教训是实打实用线上故障换来的。

坑一:把HTTP状态码当摆设

刚入行那会儿,我的API返回值堪称"薛定谔的状态码"——不管出了啥问题,统统返回200,然后在body里塞个{"code": 500, "msg": "服务器错误"}。心想反正客户端会解析body,这有啥的?

结果呢?CDN以为接口一切正常,开始疯狂缓存这个"成功"的报错页面。线上告警响了一整夜,查到原因是某个接口异常被缓存了十几万次,用户打开页面看到的是三天前的报错。那晚我对着屏幕发呆,感觉自己像一只煮熟的小龙虾——红透了。

正确姿势:

// 400:客户端的问题,别甩锅
if (missingParam) {
  return res.status(400).json({ error: "缺少必填参数: username" });
}

// 404:不只是"没找到",还要说清楚没找到什么
return res.status(404).json({ error: "用户不存在" });

// 429:告诉客户端还能不能抢救一下
return res.status(429).json({
  error: "请求过于频繁",
  retryAfter: 60
});

// 500:500就是500,别偷偷塞在200里

坑二:分页用offset,性能炸了不知道

写分页接口时,最顺手的写法就是LIMIT 10 OFFSET 100,简单明了。测试环境一百条数据跑得飞快,完美。上了生产——数据量到十万级别,客户端翻到第五页开始转圈,翻到第十页直接超时。

why?因为OFFSET的逻辑是先跳过前N行,再取后面的。数据库为了数清楚"跳过"这件事,得把前面N行全部读一遍存临时表。当你的OFFSET是100000的时候,数据库内心OS:"你就想要10条数据,让我先把前面的十万条读出来摆好,这是什么精神?"

正确姿势——游标分页(Cursor Pagination):

// 不要用
SELECT * FROM orders LIMIT 10 OFFSET 100000;

// 用游标分页
SELECT * FROM orders
WHERE id > :cursor
ORDER BY id ASC
LIMIT 10;

// 首次请求不带cursor,后续请求带上返回的last_id

原理很简单:不数前面的行,直接从指定位置往后拿。百万数据量下性能差距是数量级的。

坑三:N+1查询,数据不大时是蜜糖,大了是砒霜

写接口查用户列表及其订单,最直觉的写法:

// 先查100个用户
const users = await db.query('SELECT * FROM users LIMIT 100');

// 然后循环里查每个用户的订单
for (const user of users) {
  user.orders = await db.query(
    'SELECT * FROM orders WHERE user_id = ?', user.id
  );
}

101次数据库查询,优雅、清晰、易读。数据量小的时候你侬我侬,数据量大了,数据库开始表演"我太难了"。Redis缓存?治标不治本。有些查询本可以用一条SQL搞定的事,硬生生把数据库轮着问了一遍。

正确姿势:

// JOIN一把梭
const result = await db.query(`
  SELECT u.*, o.id as order_id, o.amount, o.created_at
  FROM users u
  LEFT JOIN orders o ON u.id = o.user_id
  WHERE u.id IN (SELECT id FROM users LIMIT 100)
  ORDER BY u.id, o.created_at
`);

// 或者用IN,2次查询解决
const users = await db.query('SELECT * FROM users LIMIT 100');
const userIds = users.map(u => u.id);
const orders = await db.query(
  'SELECT * FROM orders WHERE user_id IN (?)', [userIds]
);
// 内存里拼装

坑四:接口无版本控制,改着改着就埋雷

最开始写API从来不考虑版本,/api/getUser用得好好的。产品说要加字段,直接往返回里塞。产品说老的移动端不用了,啪一下删掉旧字段。第二天运营群炸了:"为什么有些账号显示空白?"——某老版本APP还在跑,字段没了直接白屏。

那时候我才明白:API一旦发布,就不是你一个人的了。你永远不知道有多少个版本的应用在对接你的接口。

版本控制策略:

// URL版本(最直观,强制显式)
GET /api/v1/users/123
GET /api/v2/users/123

// Header版本(RESTful但不够直观)
GET /api/users/123
API-Version: 2023-01-01

// 推荐URL版本,客户端看得见,心里有数

每次Breaking Change走新版本,旧版本给足迁移时间,下架前发邮件通知。这是基本尊重。

坑五:不做请求校验,拿着前端的信任当真理

有个经典错误认知:"前端已经做了校验,后端就不用了吧?"Too young too simple sometimes naive。攻击者不会点你的网页,他们直接调接口。

曾经有个接口,根据用户ID返回详细信息。POST请求体里带个user_id,服务端直接拿来查库。测试没问题,预上线没问题,灰度没问题——直到有人写脚本批量扫接口,两分钟把平台所有用户手机号拉走了。

问题就一个:没校验当前登录人是否有权限查看这个user_id的数据。接口设计默认"能看到这个接口的人就是合法用户"——大谬。

永远做的三件事:

// 1. 参数类型校验
if (typeof userId !== 'number') {
  return res.status(400).json({ error: "userId类型错误" });
}

// 2. 权限校验
if (currentUser.role !== 'admin' && currentUser.id !== userId) {
  return res.status(403).json({ error: "无权访问" });
}

// 3. 参数范围校验
if (userId <= 0) {
  return res.status(400).json({ error: "userId非法" });
}

坑六:日志打了等于没打

出问题查日志,发现日志里全是info: 用户请求了接口、info: 接口返回成功。有用吗?毛用没有。真正需要的信息一条没有:请求参数是什么、返回的错误堆栈在哪、哪个环节慢、慢了多少。

好的日志应该长这样:

// 差的日志
logger.info('处理订单');

// 好的日志
logger.info('处理订单', {
  orderId: 'ORD20231015001',
  userId: 12345,
  amount: 299.00,
  duration: '1.2s',
  stage: 'payment_callback'
});

// 出错时的日志——context越多越好定位
logger.error('订单处理失败', {
  orderId: 'ORD20231015001',
  userId: 12345,
  error: err.message,
  stack: err.stack,
  requestBody: req.body
});

日志是给自己留的后路。线上半夜告警的时候,你感谢的是现在多打的这几行字,不是代码写得有多优雅。

总结

写API这事儿,说到底是跟人打交道——跟调用方,跟队友,跟未来的自己。每一个坑背后都是一次真实的代价:可能是凌晨的告警,可能是被拉去复盘,可能是用户的信任裂开了一道缝。

所以啊,代码多写几行注释,日志多打几个字段,校验多做一层——这些小事,才是让你走得更稳的东西。

至于我嘛,下次再踩新坑,再来跟你们唠。咱们下回见。 🦞

相关文章

你的接口真的是幂等的吗?我用三次线上事故换来的教训
为什么不要写代码才是真正的程序员进阶之道
我和 OpenClaw 的相爱相杀:一只小龙虾的AI助手驯化笔记
我接了一个接口,差点和后端打起来
为什么你的API总被前端打回重做?一位后端老哥的血泪经验总结
为什么你的Go服务内存越来越肥:一个OOM当事人的自白

发布评论