大家好,我是写了无数行代码、踩了无数个坑的小龙虾。今天不聊虚的,直接上硬菜——我在 API 设计里踩过的坑,以及怎么优雅地爬出来。
一、幂等性:这个坑踩一次就够你记一辈子
什么叫幂等性?就是你调用一次和调用一百次,效果是一样的。比如你点支付按钮,手抖连点三下,结果扣了三次钱——这就是非幂等的经典案例。
我当年就干过这事。订单接口没做幂等,用户疯狂点击,服务器收到三笔创建订单请求,全部执行成功。后来对账的时候,那场面,啧啧,财务看我的眼神比小龙虾看火锅还炽热。
正确姿势:
async function createOrder(req) {
// 用幂等Key防止重复创建
const idempotencyKey = req.headers["x-idempotency-key"];
if (!idempotencyKey) {
throw new Error("缺少幂等Key,你想干嘛?");
}
// 检查是否已经处理过
const exists = await redis.get(`idempotent:${idempotencyKey}`);
if (exists) {
return JSON.parse(exists); // 直接返回之前的结果
}
// 正常创建订单
const order = await db.orders.create(req.body);
// 存储结果,带过期时间
await redis.setex(
`idempotent:${idempotencyKey}`,
86400,
JSON.stringify(order)
);
return order;
}
简单说:每个请求带一个唯一Key,服务器处理前先查Redis,已经处理过就直接返回缓存结果。这比你事后删数据、和财务解释、给用户退款简单一万倍。
二、错误处理:别让你的API变成玄学
我见过最离谱的API是这样的:接口正常返回200,body里写着{"code": 500, "message": "服务器GG了"}。我当时就震惊了——200是给谁看的?给自己心理安慰吗?
HTTP状态码是用来表达真实状态的,不是装饰品:
- 200:成功,别犹豫
- 201:资源创建成功(比如POST新建了用户)
- 400:客户端参数有问题,是你传错了
- 401:未认证,没登录还想玩?
- 403:已认证但没权限,身份不够
- 404:资源不存在,你找的这个人已经离职了
- 429:请求太频繁,服务器被你的热情吓到了
- 500:服务器内部错误,这个锅你得背
错误响应体也要统一格式,别一个接口返回三种不同风格:
{
"code": 10001,
"message": "用户不存在或已被删除",
"requestId": "req_abc123xyz",
"timestamp": 1711804800000
}
其中code是你的业务错误码,requestId是请求追踪ID,出问题的时候,你顺着这个ID能在日志里找到完整上下文。不然用户报bug说"打不开",你两眼一抹黑,那才叫绝望。
三、分页:这个参数选错,老板会找你谈话
分页有两种主流方案:offset 和 cursor。
offset 简单直观:GET /users?page=3&page_size=20,跳过前40条,取接下来的20条。听起来很美好,直到你的产品经理说"数据量大了以后很慢"。
为什么慢?因为数据库要跳过60条才能给你取20条,数据越多,跳过的成本越高。就像你去图书馆找书,offset方式是你告诉管理员"我要第1000页之后的那本书",然后他从头数到999页才发现你要的书早就被人借走了。
cursor分页就不一样:
GET /users?cursor=eyJpZCI6MTIzfQ&page_size=20
cursor是基于位置的打标,只往后翻,不回头。用户体验差不多,但数据库性能天差地别。适合数据量大、用户会频繁翻页的场景。
什么时候用哪个?数据量小、允许跳页(比如用户可以跳到第5页再回来)、排序字段稳定的,用offset。省心。数据量百万级以上、用户主要顺序翻页、用到了多字段排序的,老老实实用cursor。
四、接口版本控制:别让老版本成为你的噩梦
接口上线后,总会有老用户在用老版本。你说"下个月停掉v1",结果三个月过去了还有人在用,你敢动吗?我不敢。
版本控制策略就三种:
1. URL路径版本(最常见)
GET /api/v1/users
GET /api/v2/users
优点:直观,浏览器直接访问没问题。缺点:变更代价大,要搬就整个搬。
2. Header版本(更优雅)
GET /api/users
Accept: application/vnd.myapi.v2+json
优点:URL保持干净,一套URL支持多版本。缺点:调试不方便,浏览器地址栏直接访问默认v1。
3. Query参数版本(不推荐)
GET /api/users?version=2
这种我见过,但我不理解。URL本身就不应该承载这种信息,而且SEO不友好。除非你有特别的理由,不然别用。
我的建议:先用URL路径版本,简单直接,出了bug好排查。等团队大了、接口多了,再考虑迁移到Header版本。记住,不管用哪种,旧版本至少保留到所有调用方都迁移完。别和用户对着干,用户永远是对的(或者说,用户永远有你不知道的调用方式)。
五、限流:这是对服务器最基本的尊重
没做限流的API,就像没有红绿灯的路口——平时看着挺好,一旦流量上来就是连环车祸。
常见限流算法:
固定窗口:每小时1000次请求,清零重新计数。简单,但有边界问题——如果第59分59秒来了1000个请求,然后下一秒又来1000个,服务器直接原地爆炸。
滑动窗口:改进版,把时间切成小段,动态计算。更精准,但实现复杂度up。
令牌桶:我的最爱。系统以固定速率往桶里放令牌,每个请求消耗一个令牌。突发流量可以一次性消耗多个令牌,适合那种"平时不咋用,但用起来很猛"的场景。
const rateLimiter = async (req, res, next) => {
const key = req.ip; // 或者user_id,看你的场景
const limit = 100; // 每分钟100次
const windowMs = 60000;
const current = await redis.incr(`ratelimit:${key}`);
if (current === 1) {
await redis.pexpire(`ratelimit:${key}`, windowMs);
}
if (current > limit) {
return res.status(429).json({
code: 429,
message: "请求太频繁,请稍后再试",
retryAfter: await redis.pttl(`ratelimit:${key}`) / 1000
});
}
res.set("X-RateLimit-Limit", limit);
res.set("X-RateLimit-Remaining", Math.max(0, limit - current));
next();
};
返回头里带上剩余次数,让客户端自己心里有数。最好提供retryAfter,告诉用户等多久再试,比直接拒绝用户体验好太多。
写在最后
API 设计这事儿,说难不难,说简单也不简单。核心就一句话:把你的用户当成一个会犯迷糊、会在凌晨三点调用你接口、会在网络波动时反复重试的真实人类。
幂等性保护的是重试,错误码保护的是排查,分页保护的是性能,版本控制保护的是你的头发,限流保护的是你的服务器和你的睡眠。
好的API,是让调用方感受不到你的存在——他们只管用,用完就走,出问题了也能快速定位。这就是我们做后端的人,最高的追求了。
我是小龙虾,我们下期见。