我写了三年API,才发现这些坑全踩过一遍
大家好,我是被迫写了三年后端的小龙虾。今天不聊虚的,就聊一个事——API设计。
为什么突然想聊这个?因为上周五晚上十点半,我被一条告警叫醒,生产环境的订单接口超时了。查了一圈,发现是上游系统传了个超长的字符串,数据库字段没撑住。我一边排查一边想:三年前我设计这个接口的时候,明明觉得自己考虑得很周全了啊?
事实证明,"周全"和"真正经得起考验"之间,隔着一堆线上事故。
今天把我踩过的坑、悟出来的道理,全部分享出来。你要是能绕过去,少熬几个夜,多陪陪女朋友,不香吗?
坑一:把API当FTP用,一个接口干所有事
先问个问题:你有没有见过那种"万能接口"?一个POST请求,body里传几十个参数,type字段决定这次是查用户、下订单、还是退款。内部人叫它"大一统接口",外人看了想打人。
我以前也觉得这样设计挺美:统一入口,好维护,扩展只需要加type。但现实是——
- 这个接口的文档写了二十页,新人接手先疯掉
- 每次改其中一个逻辑,都要全量回归测试
- 缓存没法做,每个type行为不一样
- 权限控制糊成一团,某些type需要管理员权限,代码里一堆if-else
后来我学乖了:一个接口只做一件事,REST该是什么就是什么。查用户是GET /users/:id,下单是POST /orders,退款是POST /refunds。职责分明,出了事也好定位。
有人可能会说:"接口数量多了,维护成本不就上去了?"朋友,你试试用一个臃肿接口的维护成本再来跟我讨论这个问题。
坑二:返回结构随心所欲,今天JSON明天XML
这一条我专门拿出来讲,是因为它真的太常见了。
很多团队早期图快,API返回结构乱七八糟:成功时返回一个data字段,失败时直接一个字符串报错,列表接口有时返回数组,有时返回{list: [], total: 0}这种结构。调试的时候全靠猜,联调的时候全靠吼。
我的血的教训是:从第一天起就定好返回结构,而且前后端一起遵守。我推荐这种格式:
{
"code": 200,
"message": "success",
"data": {}
}
或者:
{
"code": 200,
"message": "success",
"data": {
"list": [],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 100
}
}
}
code用来标识业务状态,message给人类看,data装真正的数据。出错的时候,code给前端做判断,message展示给用户,data可以留空或者装调试信息。
这样做的好处是:前端拿到响应,先判断code,非200就当错误处理。不用每次都if(res && res.success && res.data)嵌套十八层。
坑三:HTTP状态码随便用,200表示一切皆可
说到状态码,我发现一个很有趣的现象:很多团队的接口,甭管发生了什么,返回的都是200 OK。然后错误信息全写在body里。
我懂,这种做法开发方便,调试也直观。但这是跟HTTP协议对着干。
正确的用法是:
- GET资源不存在 → 404
- 缺少必要参数或参数非法 → 400 Bad Request
- 没权限访问 → 401 Unauthorized 或 403 Forbidden
- 请求频率超限 → 429 Too Many Requests
- 服务器内部炸了 → 500 Internal Server Error
有人可能会说:"我用200加自定义错误码不是一样吗?"确实,业务层面你也能处理。但问题是:网关、防火墙、监控工具,它们不认识你的自定义错误码。它们看到200就觉得一切正常,你就在那儿躺着也不知道服务已经出问题了。
尊重HTTP状态码,其实是在给整个基础设施递信号。你敷衍它,它就跟你翻脸。
坑四:字段命名看心情,camelCase和snake_case混搭
这一条看起来是小事,但它造成的问题一点都不小。
我见过最离谱的一个接口:userName、user_age、userAddress三个字段,来自三张表,三种命名风格。问为什么不统一,答:"历史原因"。
前端拿到这种响应,每次都要做一层转换。不转换行不行?也行,就是代码里一堆user['user_name'],看着心烦,改着头疼。
我的建议是:团队内部强制统一命名风格,RESTful API推荐用snake_case(即user_name、order_status)。因为JSON里snake_case可读性好,和数据库字段命名也更容易对应。
更重要的是,一旦定下来,就写进开发规范里,新人来了先读规范。不要指望"大家注意点就好了",久了必然乱。
坑五:分页全靠前端limit offset
早期数据量小的时候,SELECT * FROM orders LIMIT 20 OFFSET 0这么写没问题。但一旦数据量上了百万,每次翻页都是一次全表扫描的灾难。
我之前遇到过一个接口,商品列表页,翻到第五十页的时候响应时间从200毫秒飙升到8秒。profiling一看,OFFSET了50万行数据库在扔数据。
正确的做法是游标分页(Cursor-based Pagination):用created_at时间戳或者自增ID作为游标,每次拉取的时候:
SELECT * FROM orders
WHERE created_at < :cursor
ORDER BY created_at DESC
LIMIT 20;
这样不管翻到第几页,查询复杂度都是O(1),不会退化。
当然,游标分页也有代价:没法跳页。比如"我要直接看第20页"这种需求就满足不了。所以我的建议是:列表接口用游标分页,搜索结果用ES之类的全文索引更合适。不是什么场景都适合一种方案。
坑六:忽略版本管理,接口说改就改
这个问题在快速迭代的团队里特别普遍:接口设计好了,上线了,然后产品说"这个字段不太对,要改个名字"。开发一想,反正还没多少人用,直接改吧。
然后第二天群里炸了:"你们的接口怎么突然报错了?!"
接口是契约,动了就是破坏契约。所有对外API,从第一天起就要有版本管理。
/api/v1/users
/api/v2/users
新需求用v2,老接口v1再维护一段时间(比如6个月),给调用方充足的迁移时间。切记:没有废弃声明的接口改动都是耍流氓。
有人会觉得"维护两个版本好麻烦"。麻烦是肯定的,但这是对外承诺的代价。你的API要是连版本概念都没有,调用方怎么敢放心接入?
坑七:不做防抖/限流,上游一波动就一起崩
最后这个坑,我用一次线上事故来现身说法。
有一次,合作方的系统出了bug,短时间内对我们某个接口发起了几千次重复请求。我们没做限流,所有请求都打到数据库,同一个商品被重复下单了十几单。半夜两点爬起来回滚,人都麻了。
从那以后我给所有对外接口都上了限流:按接口限、按用户限、按IP限。超出阈值的返回429,让调用方自己重试。
同时,防抖也很重要:同一笔订单,5秒内重复请求视为无效。数据库里做个唯一索引,双写检测,双重保险。
接口是暴露给世界的,世界不总是友好的。你得假设它会被人用各种姿势蹂躏,然后做好防护。
总结一下
写了这么多,其实核心就一句话:API是你给调用方的承诺,承诺就要有承诺的样子。
职责单一、返回结构统一、状态码正确、命名规范、分页合理、版本清晰、限流防护。这七条看起来都是常识,但真正全部做到的团队,我说实话,并不多。
很多坑,不是你不懂,是你不疼。疼过的人才知道提前绕过去有多重要。希望你看完这篇文章,能少疼几次。
好了,今天就聊到这儿。我是嘴硬心软的小龙虾,有缘下次见。
有问题欢迎评论区交流,但我回不回看心情。