做后端开发这些年,我写过数不清的API接口,修过无数让人头秃的bug,也接手过一些"祖传代码"——那种看一眼就想辞职、但为了生活只能硬着头皮维护的那种。今天不整虚的,跟大家聊聊API设计中那些我踩过的坑、流过的泪、以及终于想通了的道理。
1. 状态码不是随便写的,你的200可能是个灾难
刚入行那会儿,我对HTTP状态码的态度就是:能返回200就返回200,反正前端能拿到数据就行。
结果呢?用户登录失败,返回200;库存不足,返回200;参数校验失败,还是返回200。前端小哥每次都要多写一堆判断逻辑,脾气好的时候吐槽两句,脾气不好的时候直接在群里@我:"你到底会不会写接口?"
正确的做法是什么?
// 登录成功
return 200 OK { "code": 0, "message": "登录成功", "data": {...} }
// 登录失败 - 用户名错误
return 401 Unauthorized { "code": 10001, "message": "用户名或密码错误", "data": null }
// 库存不足
return 409 Conflict { "code": 20003, "message": "库存不足", "data": { "available": 2, "requested": 5 } }
// 参数校验失败
return 400 Bad Request { "code": 30001, "message": "参数校验失败", "data": { "errors": [{"field": "phone", "msg": "手机号格式不正确"}] } }
教训:状态码是给调用者看的,它比你的response body更重要。 404表示资源不存在,401表示没权限,422表示参数有问题。乱用状态码的API,就像在高速公路上逆行——迟早出事。
2. 签名验签那点事:别把安全做成"防君子不防小人"
做过支付相关接口的童鞋肯定对签名不陌生。我见过太多所谓的"安全方案",说白了就是:
// 某项目的签名算法(真实案例,已脱敏)
String sign = MD5("appId=" + appId + "×tamp=" + timestamp);
好家伙,没有密钥、没有排序、没有时间戳校验。攻击者只需要把appId改成别的,照样能调通。这不是签名,这是皇帝的新装。
我目前用的一套相对靠谱的签名方案:
/**
* 签名生成算法
* @param params 所有业务参数(不含sign)
* @param secret 密钥
* @param timestamp 请求时间戳(毫秒)
* @return 签名
*/
public static String generateSign(Map<String, String> params, String secret, long timestamp) {
// 1. 按字典序排序所有参数
Map<String, String> sorted = new TreeMap<>(params);
// 2. 拼接key=value格式
StringBuilder sb = new StringBuilder();
sorted.forEach((k, v) -> sb.append(k).append("=").append(v).append("&"));
// 3. 追加密钥和时间戳
sb.append("secret=").append(secret);
sb.append("×tamp=").append(timestamp);
// 4. SHA256签名
return DigestUtils.sha256Hex(sb.toString());
}
/**
* 验签(带时间戳防重放)
*/
public static boolean verifySign(Map<String, String> params, String sign,
String secret, long timestamp, long expireMs) {
// 时间戳校验 - 超过5分钟视为无效请求
if (Math.abs(System.currentTimeMillis() - timestamp) > expireMs) {
return false;
}
String expectedSign = generateSign(params, secret, timestamp);
return expectedSign.equals(sign);
}
教训:安全无小事,每一个"应该没人会这么做"的想法,最后都会变成生产事故。
3. 分页这件小事,做不好就是大灾难
很多人觉得分页有什么难的,不就是limit offset嘛。但当你的表有几千万条数据的时候,问题就来了:
-- 经典翻页,大偏移量时性能爆炸
SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 1000000, 20;
-- 这条SQL跑了8秒,用户以为系统挂了
我踩过这个坑之后的解决方案:游标分页(Cursor-based Pagination)
-- 首次查询
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 下一页:传入上次查询的最后一条的created_at和id
SELECT * FROM orders
WHERE user_id = 12345
AND (created_at < :last_cursor_time
OR (created_at = :last_cursor_time AND id < :last_cursor_id))
ORDER BY created_at DESC, id DESC
LIMIT 20;
游标分页的好处:无论翻到第几页,性能都稳定如一。当然,它也有代价:不能跳页。所以要不要用,得看你的业务场景。
4. 接口版本管理:你的v1可能是个定时炸弹
很多项目一开始设计接口的时候,压根没考虑版本管理。等后来需要breaking change的时候,就傻眼了:
"这个字段不能删,线上有人用了!"
"这个参数不能改类型,会导致老版本App崩溃!"
"这个接口到底哪些人在用,谁也说不清!"
我现在的做法是:URL版本化 + 强制的版本兼容策略
https://api.example.com/v1/users
https://api.example.com/v2/users
https://api.example.com/v3/users
规则:
- 大版本(比如v1到v2)做breaking change,兼容性由调用方负责
- 小版本在URL里体现(v2.1, v2.2),只做增量功能和非breaking修改
- 每个版本有明确的生命周期和下线日期
- 旧版本下线前三个月发邮件/公告通知
教训:不要相信"这个接口永远不会被废弃"这种鬼话,要么从一开始就规划好版本管理,要么迟早要为历史债务买单。
5. 日志记录:出问题的时候你就知道它多重要了
我见过太多项目,出问题的时候查日志,查出来的信息是:
2026-06-18 07:00:01 INFO - start
2026-06-18 07:00:02 INFO - process
2026-06-18 07:00:03 INFO - end
好家伙,和"早上起床、刷牙、出门"一样详细,但毛用没有。
好的日志应该长这样:
// 包含请求追踪ID、关键业务参数、处理耗时、异常信息
2026-06-18 07:00:01.234 [XNIO-1 task-5] INFO [traceId:7f8a9b2c3d4e5f]
OrderService.createOrder - START | userId=12345, items=[{skuId:"SKU001", qty:2}], totalAmount=299.00
2026-06-18 07:00:01.567 [XNIO-1 task-5] INFO [traceId:7f8a9b2c3d4e5f]
OrderService.createOrder - SUCCESS | orderId=ORD2026061870001234, duration=333ms
一个请求从进来开始,全程有traceId串联,任意一个环节出问题,直接grep traceId就能看到完整的调用链。这才是日志该有的样子。
总结:好的API设计,其实就是"让人用着舒服"
说了这么多,其实核心就几点:
- 状态码要对:它是调用者的指路明灯
- 安全要真:别做表面功夫,安全漏洞不会挑时间
- 分页要稳:大偏移量是性能杀手,游标分页了解一下
- 版本要清:从第一天就规划好,别到时候一团乱麻
- 日志要准:出问题的时候你就知道日志有多重要了
API设计这件事,没有银弹,但有坑可避。希望我踩过的这些坑,能让你少踩几个。
当然,如果你看完觉得"道理我都懂,但就是懒得改"——没关系,等你维护的代码出问题的时候,你就会想改了。🙂
更多技术分享,欢迎关注。有任何问题或者不同的看法,欢迎留言交流!