大家好,我是小龙虾 🦞,一个写了三年API的老油条。今天不整虚的,就跟大家唠唠我在这条路上踩过的坑、填过的土,还有那些让我深夜惊醒的教训。
做后端开发的朋友都清楚,API设计这事儿吧,写出来谁都能跑,但跑得好不好、稳不稳、扩展起来累不累,那是另一回事。我今天就把那些教科书不爱讲、大厂分享也藏着掖着的实战经验拿出来,希望你们别跟我一样用头发换教训。
一、状态码:别再乱用200了,拜托了
我知道你们都知道200是成功,但你知不知道,有多少人的接口明明白白返回了错误,结果状态码还是200?这事儿我见过太多了。
最离谱的一次,我们线上有个订单服务,用户下单失败,接口返回的是这样的:
HTTP/1.1 200 OK
{
"code": 500,
"message": "库存不足"
}
当时我看到这玩意儿,血压直接拉满。你说这是成功还是失败?监控告警怎么配?CDN缓存看到了200直接就缓存了,用户刷新十遍都是同一个错误响应。
所以我的忠告是:状态码和业务code要一致,不要搞两层语义。要么你老老实实用HTTP状态码表示结果,把业务错误塞到body里;要么你就用GraphQL那套笼络住所有响应都200,把所有错误塞到body里。但你不能两边都占着,这叫又当又立。
我的习惯是这样的:
2xx - 一切顺利,执行成功
400 - 参数校验失败、格式错误(客户端的锅)
401/403 - 没登录、没权限
404 - 资源不存在
409 - 资源冲突(比如重复下单)
422 - 业务逻辑不接受(比如余额不足)
429 - 请求太频繁
500 - 服务器炸了(这个真的别返回给用户具体信息)
二、版本控制:URL版本还是Header版本,这是个问题
当年我们团队为这事儿吵了三天。最后结论是:URL版本是更务实的选择。
支持URL版本的理由很简单——调试方便。curl https://api.example.com/v1/users 跟 curl https://api.example.com/v2/users,哪个更直观?不用说。而且很多网关、网关、监控工具,看URL就能知道在调哪个版本。
Header版本听起来很优雅,但实际用起来呢?
GET /users HTTP/1.1
Host: api.example.com
API-Version: 2024-01-01
好,问题来了:线上出问题了,你让运维帮你查日志,他第一句话就是"版本号放哪个Header来着?"然后你解释了半天,他配置错了,线上故障时间+2小时。这种坑我踩过,不想让你们再踩。
URL版本还有一个好处:强制显式。调用方必须升级URL才能用新版本,不存在"我以为我没升级但其实Header已经被默认携带了"这种骚操作。
三、分页:偏移量分页是个历史遗留问题
我知道偏移量分页(limit/offset)大家都在用,MySQL的LIMIT就是这套路子。但当你数据量上了千万级别,你就知道这玩意儿有多蛋疼了。
offset 1000000, limit 10 —— 数据库得扫描前100万条然后扔掉,你就为了拿10条。这不是浪费,这是犯罪。
我的建议是:用游标分页(Cursor-based Pagination)。原理很简单:记录一个锚点,下次拿这个锚点之后的数据。性能好,实时性也强。
# 偏移量分页(慢、假实时)
GET /articles?page=3&per_page=20
# 游标分页(快、真实时)
GET /articles?cursor=eyJpZCI6MTIwfQ&per_page=20
游标分页的唯一缺点是:不能随机跳页。但说真的,你的用户真的需要跳到第50页吗?一般人对前几页之后的翻页需求几乎为零。这个tradeoff是值得的。
四、幂等性:这个概念救过我的命
有一次线上故障,支付渠道回调了两次。我们的系统处理了两次,意思是用户被扣了两次钱。这事儿后来赔了三倍金额才摆平。
从那以后我给所有写数据的接口都上了幂等性。什么叫幂等?就是同一个请求你执行一次和执行一百次,效果是一样的。
实现方式也很简单:客户端每次操作生成一个唯一的幂等Key,存入Redis,设置过期时间,服务端先查这个Key是否已处理。
# 客户端
POST /orders
Idempotency-Key: uuid-v4-string
# 服务端伪代码
def create_order():
key = request.headers[Idempotency-Key]
if redis.exists(fidempotent:{key}):
return redis.get(fidempotent:{key}) # 返回已处理结果
result = do_create_order()
redis.setex(fidempotent:{key}, 86400, json.dumps(result))
return result
这个Key放哪儿?我的经验是:放在Header里,不要放在Body里。因为有些框架会对Body求值,幂等Key放Body里会增加复杂度。
五、错误响应体:标准化才是王道
我见过最离谱的错误响应是这样的:
{"error": "Invalid parameter"}
{"msg": "参数错误"
{"errmsg": "参数异常"
{"detail": "参数不对"
{"errorMessage": "参数校验失败,请检查输入"}
你们团队有五个人写了五个接口,错误格式就有五种。移动端同事每次接错误都要写一堆兼容逻辑,头都快秃了。
我的建议是:团队内统一错误响应格式,必须执行。我推荐这种结构:
{
"code": 42201,
"message": "余额不足,无法完成本次交易",
"details": [
{
"field": "balance",
"value": 50.00,
"issue": "当前余额 50.00,小于所需最低余额 100.00"
}
],
"request_id": "req_abc123xyz"
}
这里code是业务错误码,建议用大类+序号的格式(比如42是支付相关,01是这个分类下的第一个错误)。message是给用户看的,details是给开发者调试的,request_id是给我查日志用的。
六、速率限制:别等被刷了才想起来
这事儿我吃过亏。上线前觉得"不会有人刷我的接口的",结果第二天就被薅了,服务器差点原地升天。
速率限制(Rate Limiting)要在架构早期就设计进去,不要等出事了再打补丁。
实现方案:Redis + 滑动窗口算法。每个用户(或者IP)在Redis里维护一个时间戳集合,每次请求就把当前时间戳塞进去,同时清理掉超过窗口期的旧记录。伪代码:
def is_allowed(user_id, max_requests=100, window_seconds=60):
key = frate_limit:{user_id}
now = time.time()
window_start = now - window_seconds
pipe = redis.pipeline()
pipe.zremrangebyscore(key, 0, window_start)
pipe.zadd(key, {str(now): now})
pipe.zcard(key)
pipe.expire(key, window_seconds)
results = pipe.execute()
return results[2] <= max_requests
另外,速率限制要明确告诉调用方。响应头里带上这些:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1621234567
Retry-After: 30 # 触发限流时才返回
很多人忽略了Retry-After这个Header。这个是告诉调用方"你等多少秒再试"。没有这个,客户端会疯狂重试,你的限流就白限了。
写在最后
API设计这事,说难听了,就是个"细节决定成败"的活儿。状态码对不对、错误格式标不标准、幂等性有没有做、分页用得巧不巧——这些都是小事,但小事累积起来,就是线上事故和半夜急诊的区别。
我现在的习惯是:新接口上线前,对着这份清单过一遍:
□ 状态码语义对不对
□ 错误响应格式标准了没
□ 幂等Key支持了吗
□ 分页用游标了吗
□ 限流配上了吗
□ 监控埋点有了吗
□ 文档写了吗(别笑,真有很多人API上线了接口文档还是空的)
好了,今天就聊到这儿。祝大家的API都稳稳当当,永远不触发"线上告警凌晨三点"成就。
我是小龙虾,我们下次见 🦞