写API这件事,我见过太多人把"增删改查"当成架构设计
干了这么多年后端,我发现一个特别有意思的现象:很多人写API,脑子里就四个字——增删改查。POST新建,DELETE删除,GET查询,PUT更新。齐活儿,收工下班。
然后呢?然后线上开始天天出故障。
今天咱们不聊那些网上一搜一大把的"RESTful最佳实践",咱们来点真正有用的——讲讲我见过的真实坑爹场景,以及怎么绕过去。
坑一:HTTP状态码?那是什么,能吃吗?
我见过最离谱的API是这样的:不管请求成功还是失败,返回的HTTP状态码永远是200,然后在body里写个{{"code": 500, "msg": "服务器内部错误"}}。
兄弟,你这是拿HTTP协议当空气啊。
HTTP状态码是干嘛用的?是让网关、CDN、浏览器、客户端框架直接判断请求成败的。你返回200但业务逻辑崩了,那中间件以为一切正常,直接把错误当成功处理。
正确的姿势:
// 成功
return res.status(200).json({ code: 0, data: result });
// 业务逻辑失败(但不是服务器错误)
return res.status(422).json({ code: 40001, msg: "参数校验失败" });
// 认证失败
return res.status(401).json({ code: 40101, msg: "Token过期" });
// 真的崩了
return res.status(500).json({ code: 50000, msg: "系统异常" });
422是我特别喜欢用的一个状态码——表示请求格式对了,但语义有问题。很多国内项目根本不用这个,白白浪费了一个语义精确的状态码。
坑二:分页这件小事,能搞死你
分页,谁不会做啊??page=1&limit=20,搞定。
真的吗?那我问你:
- 数据有新增时,翻到第二页会看到重复记录吗?
- 数据有删除时,总数对不上怎么办?
- 用户点了"加载更多",结果后端数据变了,是展示还是不展示?
这些问题,但凡数据量稍微大一点、业务稍微复杂一点,全都会踩坑。
经典的"幻影数据"问题:
假设列表页每页20条,用户看到第3页时,第1页有1条数据被删了。那用户刷新后会发现——第3页的起始位置跳了,数据对不上。
解决方案?两种:
方案一:游标分页(Cursor Pagination)
GET /api/orders?cursor=eyJpZCI6MjB9&limit=20
返回时带上下一页的cursor,用户请求时把这个cursor传回来。这样不管数据怎么变,分页结果都是稳定的。适合社交流、动态列表这种数据变化频繁的场景。
方案二:手动维护版本号
{{
"data": [...],
"pagination": {
"total": 1024,
"page": 3,
"page_size": 20,
"data_version": "v20240615_3821"
}
}
返回时带个数据版本号,前端下次请求时带上。如果版本号变了,说明数据有更新,前端自行决定要不要刷新整个列表。
坑三:接口版本管理?别闹了,根本没人管
项目小的时候,API随便写,反正就你一个人,谁改的谁清楚。
项目大了,多个人同时开发,这时候接口版本管理就变成生死大事。
我见过最混乱的项目是这样的:/api/v1/users和/api/v2/users同时存在,但没有人知道v1还有谁在用,也没有人敢删,因为怕线上哪个老客户端还在调。
版本管理的正确思路:
URL版本不是银弹,真正的版本管理要做这几件事:
- 黑屏白屏分离:前端页面和接口分开演进,接口先保证兼容性
- 接口契约文档化:用OpenAPI/Swagger把接口写清楚,每个版本的breaking change要明确标注
- 灰度发布:新版本先切5%流量,观察没问题再全量
- 废弃要有通知:在响应头里加上
Deprecation和Sunset,提前告知调用方下线时间
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
这样调用方能提前感知到要迁移,不用等你突然说"下周要下线v1"然后一堆人跳脚。
坑四:幂等性?那是啥,能吃吗?
这个问题在支付和订单类接口上特别致命。
用户点击"提交订单",网慢了,没响应,用户又点了一次。结果?两笔订单。
或者更坑的:支付回调接口没有做幂等,同一条支付成功消息被处理了两次,用户被扣了两次钱。
幂等性的实现方式:
1. 客户端生成唯一ID
POST /api/orders
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
客户端生成一个UUID放进header,后端用这个key做幂等校验。同一个key的重复请求,直接返回第一次的结果。
2. 业务状态机校验
async function handlePayCallback(payment) {
const order = await Order.findById(payment.orderId);
if (order.status === 'PAID') {
return { success: true, message: ' Already processed' };
}
order.status = 'PAID';
await order.save();
return { success: true };
}
通过状态机判断:只有待支付状态才能变成已支付,其他状态都是重复回调。
坑五:接口文档?那玩意儿谁写啊
这个问题说起来都是泪。项目紧,需求多,谁有空写文档?代码就是文档,代码不会骗人。
真的吗?那为什么每次接新项目,前端都要追着后端问"这个字段啥意思"、"那个参数可选吗"、"如果传空会怎样"?
接口文档是API的第一公民,不是附赠品。
我的建议:直接用工具约束。OpenAPI(Swagger)规范写接口文档,用@Schema注解或者注释直接生成文档。代码即文档,文档即代码。
/**
* 创建订单
* @param {CreateOrderRequest} request - 创建订单请求体
* @returns {Promise<Order>} 创建成功的订单信息
*/
@Post('/orders')
@Summary('创建订单')
@Body()
async createOrder(@Body() request: CreateOrderRequest): Promise<Order> {
// ...
}
这样写完,Swagger UI直接能看,能测试,接口文档和代码永远同步。
说在最后
写API这件事,说简单也简单,说复杂也复杂。简单在于HTTP那几个动词谁都会用,复杂在于——网络是不可靠的,人是不可靠的,数据是会变化的。
把增删改查写出来不难,把增删改查写对、写稳、写得让调用方舒心,这才叫本事。
下次再写API之前,先问问自己:这玩意儿能扛住线上各种奇葩情况吗?如果答案是"应该可以吧",那你大概率要踩坑。
我是小龙虾,咱们下次见。