API设计里那些没人告诉你的「潜规则」
写API这件事,写的人觉得自己写得很清楚,看的人觉得看不明白。最后两边互相甩锅,一个说「文档写了你不会看吗」,一个说「你这文档写得跟没写一样」。
这不是沟通问题,这是设计问题。
我见过太多团队把API当作「把后端功能暴露出去」的权宜之计,而不是当作产品来设计。接口签个名就上线,文档靠脑补,出问题就加字段、加版本、加注释。这种API,写的时候爽,维护的时候哭。
今天不聊那些烂大街的「REST最佳实践」和「API设计规范」。聊点真正踩过坑才明白的东西。
规则一:同一个endpoint返回的数据结构必须稳定
这是最容易忽视、也是出问题最多的地方。
你写了一个 /api/user/{id} 接口,返回用户信息。上线第一版:
{
"id": 1001,
"name": "张三",
"email": "zhangsan@example.com"
}
三个月后加了角色功能,变成:
{
"id": 1001,
"name": "张三",
"email": "zhangsan@example.com",
"roles": ["admin", "editor"]
}
看起来没毛病?毛病大了。前端直接拿 result.roles 去做判断,测试环境跑得好好的,线上崩了——因为老数据没有roles字段,undefined 拿来判断就是 false,逻辑直接走了错误分支。
更恶心的情况是:有时候返回数组,有时候返回对象,有时候某个字段是null有时候干脆不返回。客户端写了一大堆 if (result.roles) { ... } 的防御性代码,丑陋程度堪比jQuery时代的DOM操作。
正确的做法:数据结构必须稳定,不存在的字段要么给默认值,要么压根不要出现在响应里(二选一,全团队统一)。如果一个字段是后来加的,必须通过响应结构版本号或者明确的字段声明来管理,而不是靠「应该会有吧」。
规则二:HTTP状态码不是装饰品,乱用的API都是耍流氓
见过最离谱的API:所有请求无论成功失败统统返回200,然后在body里写 { "code": 500, "message": "服务器内部错误" }。
这不是API,这是诈骗。
HTTP状态码是网络协议层给调用方的信号,调用方根据状态码决定要不要重试、要不要提示用户、要不要记录日志。你把500塞进body里,前端代码怎么判断?只能老老实实解析body,然后手动判断code值——那你用200有什么意义?给自己找麻烦?
状态码的正确使用:
- 2xx:成功。数据在body里。
- 4xx:客户端的错误。请求有问题,别傻傻重试。
- 5xx:服务端的问题。要不要重试,你自己看着办。
不要在2xx的body里塞错误信息,不要在4xx的body里假装没事。协议层和规范层各司其职,这个分工不是建议,是规矩。
规则三:深分页是灾难,Offset分页能不用就不用
几乎所有教程教分页都是 LIMIT 10 OFFSET 20 ,简单直接,入门友好。
但如果你的数据量上了百万级别,这个分页方式会慢慢侵蚀掉你的数据库性能。
OFFSET的本质是:数据库先扫描并跳过前20条记录,再取10条。数据量越大,跳过的成本越高。百万数据里OFFSET到第99990页,每一页的查询都是一次全表扫描的代价。
更严重的是:分页场景下数据可能发生变化。你第一页显示20条,用户刚看了两秒,第二页的OFFSET位置的数据已经被删除了——结果要么跳过了数据,要么取到了重复数据。用户体验就是错位。
生产环境的分页策略:
- 数据量小于10万:用带游标的分页(cursor-based),记录上一页最后一条的ID,下一页从这里开始查。性能稳定,不依赖数据总量。
- 搜索类场景:用Elasticsearch这类专门的倒排索引引擎,别用数据库做全文搜索。
- 必须用OFFSET的场景:严格保证数据不变(比如财务对账、报表)。加锁或者事务隔离,否则别用。
Cursor分页的唯一缺点是用户没法跳页。但说实话,能跳页的深分页体验本来就很差——与其给用户一个慢得要死的跳页功能,不如一开始就设计成只能上下翻页。
规则四:API错误信息不要暴露内部实现细节
很多API的错误信息写得跟调试日志一样:
{
"error": "SQL Error: SELECT * FROM orders WHERE user_id = 1",
"detail": "Table 'orders' doesn't exist. Query: SELECT id, total, created_at FROM orders WHERE user_id = $1"
}
这种错误信息直接暴露了:你的数据库表结构、字段名、SQL写法、数据库类型。攻击者拿到这些信息,等于拿到了你系统的完整地图。
对外暴露的错误信息,只应该包含:
- 错误的业务含义(订单不存在、余额不足)
- 对用户有帮助的操作提示(请检查输入内容)
- 错误追踪ID(供技术支持使用)
内部错误堆栈、SQL语句、文件路径、代码行号——这些东西只进日志,不出API。
有一种例外:你是给内部团队用的API,而且明确知道调用方不会暴露在公网上。这种情况下可以适当放宽。但只要你有一丝犹豫,那就按最严格的标准来。安全这件事,多余的谨慎永远好过事后的后悔。
规则五:批量接口不是多个单条接口的循环调用
假设你需要查询100个用户的详情。你有两个选择:调用100次 /api/user/{id},或者调用一次 /api/users/batch?ids=1,2,3...。
直觉上批量接口只是「省事」。但实际上,这是性能上的生死线。
100次HTTP请求,每一次都有网络延迟(DNS解析、TCP握手、TLS握手、请求往返),即使服务器处理时间是0,100次请求的总耗时也可能是单次请求的几十倍。如果是移动端用户,网络差的情况下,这个差异就是「能用」和「卡死」的区别。
更重要的是:如果这100个用户数据需要JOIN多张表才能组装,单条查询会触发经典的「N+1问题」——查一次用户信息,发起100次关联查询,数据库压力直接爆炸。
批量接口的正确设计:
POST /api/users/batch
{
"ids": [1001, 1002, 1003, ...]
}
// 响应
{
"users": [
{ "id": 1001, "name": "张三", ... },
{ "id": 1002, "name": "李四", ... }
]
}
后端实现层面,批量接口应该在一次数据库查询里完成数据获取,而不是在for循环里发多条SQL。如果数据量特别大(比如超过500个),考虑分批处理或者直接拒绝——给调用方一个明确的上限,比默默截断要好。
规则六:API版本不是万能保险丝
很多团队的做法是:接口要改大改,直接加个 /v2,然后声明旧接口deprecated,等什么时候有空再迁移。
这个模式听起来很安全,实际上是技术债的制造机。
v1和v2并存期间,团队需要同时维护两套代码。每次后端逻辑变更,两边都要同步修改——如果没有严格的流程管理,很快就会出现「v1和v2的行为不一致」的诡异bug,前端在两个版本之间跳来跳去,调试地狱。
而且「deprecated」在很多团队就是个空头声明,实际上没人会主动迁移。老接口的调用量慢慢降低,但永远不清零。三年后你发现v1的代码里有一个严重安全漏洞,需要紧急修复——然后你发现v1的代码已经没人记得怎么跑了。
更好的策略:
- 小改动不加版本号。加字段、扩参数、增响应结构——只要不破坏现有字段的语义,旧接口不用动。
- Breaking change必须加版本。删字段、改类型、改必填参数——这才值得一个新版本。
- 设置明确的废弃时间和迁移窗口。不是声明deprecated就完了,必须给调用方一个deadline,并在接口响应里加一个
X-API-Deprecated: true的header,让调用方自己能检测到。 - 废弃不是技术问题,是产品问题。联系你的调用方,告诉他们迁移成本和时间节点。没人喜欢被突然通知接口要下线。
规则七:你的API可能正在泄露你的数据库结构
这是一个经常被忽视的安全问题。
很多REST API的设计是「数据库有什么字段,API就返回什么字段」。数据库里有个 is_deleted 字段,API里就有个 is_deleted 字段。数据库里有个 password_hash 字段,API里……呃,这个一般不会,但类似的逻辑漏洞很常见。
更微妙的是:你的API响应结构本身就是情报源。通过分析字段名、字段类型、嵌套层级,调用方可以推断出你的数据库Schema。一旦Schema泄露,配合其他漏洞,就是SQL注入的温床。
最小暴露原则:API响应只包含业务需要的字段,不多不少。
这不只是安全的问题,也是性能的问题。SELECT * 一次性拉出所有字段,每次数据库Schema变化都可能影响到API响应,增加不必要的网络传输量。每一个字段的返回都应该是深思熟虑后的决定,而不是偷懒的结果。
写在最后
API设计本质上是一种产品设计。你不是在描述你的系统有什么,而是在向别人承诺你的系统会怎么响应。
承诺就要兑现,就要稳定,就要经过思考。不是丢一个接口出去让调用方自己适应,那个不叫设计,那个叫甩锅。
这些规则没有一条是「标准答案」,但每一条都是踩过坑之后的真实教训。如果你的团队正在经历「接口越来越多,信任越来越少」的困境,先从这些基本规则开始,一条一条对齐。对齐完之后,你会发现沟通成本降了,bug少了,连和产品经理吵架都少了。
最后一条建议:找一个人专门对API负责。谁设计谁负责,谁维护谁签字。接口不是集体的,接口是具体的。集体负责最后就是没人负责。
就这样。