4. 接口设计原则:RESTful vs RPC,接口幂等性设计,超时与重试策略,状态码规范
好了,咱们进入第4章。这一章聊的是接口设计的一些硬核原则。说白了,就是你设计API时,脑子里必须绷紧的几根弦。
我记得刚带团队那会儿,大家整天争论该用RESTful还是RPC。后来发现,这事根本不该吵架,关键看场景。这一章我会把自己的踩坑经验和最佳实践都倒出来,你跟着走一遍,基本就能避开绝大多数接口设计的坑。
4.1 RESTful 与 RPC:风格之争,本质是场景之争
我个人习惯用一句话来区分:RESTful 面向资源,RPC 面向动作。
4.1.1 RESTful 风格
RESTful 的核心是资源。每个URL代表一个资源,HTTP动词代表操作。
GET /api/v2/keys—— 列出密钥POST /api/v2/keys—— 创建密钥PUT /api/v2/keys/{id}—— 全量更新密钥PATCH /api/v2/keys/{id}—— 部分更新密钥DELETE /api/v2/keys/{id}—— 删除密钥
我在项目中遇到过,有人把RESTful用成了RPC。比如写了个 POST /api/v2/keys/audit 来做审计查询。这不对,理论上查询应该是 GET,只是参数复杂。后来我们妥协了,所有复杂查询统一用 POST + body,但URL结构保持资源化。这叫「实用主义RESTful」。
4.1.2 RPC 风格
RPC 的核心是动作。你调用一个方法,它执行一个操作。
POST /api/rpc/ListKeysPOST /api/rpc/AuditKeyPermissionPOST /api/rpc/RevokeKey
我的选择原则:
- 对外暴露的、资源痕迹清晰的API → 用RESTful
- 内部服务间、动作含义明确的调用 → 用RPC
- 实在分不清 → 问自己一句:「调用者是在操作资源还是执行动作?」
4.1.3 混用怎么办?
我曾经在一个项目中,把RESTful和RPC混在同一个网关下。结果团队新来的同学经常搞混路由规则。后来我定了个死规矩:不同风格走不同路径前缀。/api/v2/ 走RESTful,/api/rpc/ 走RPC。这样路由清晰,谁也不耽误谁。
| 对比维度 | RESTful | RPC |
|---|---|---|
| 核心概念 | 资源 | 动作(方法) |
| URL设计 | 名词路径 + 动词 | 动词路径 / 方法名 |
| 适用场景 | CRUD、资源管理 | 内部服务、复杂操作 |
| 缓存友好 | 天然支持 | 难利用 |
| 常见协议 | HTTP | HTTP / gRPC / Thrift |
4.2 接口幂等性设计:一次和一万次结果相同
幂等性这块,说白了就是:同一个请求发N遍,系统状态不产生错误变化。这在密钥审计场景里至关重要。你想想看,如果审计记录因为网络重发被插入了两次,那审计结果就全乱了。
危险操作举例:
- 不幂等的审计提交:客户端超时重试 → 服务器插入两条记录
- 不幂等的密钥撤销:重试一次 → 密钥状态被重复更新成「已撤销」,操作日志多一条
4.2.1 实现幂等性的四种手段
- 天然幂等:
GET/DELETE(删除已不存在的资源) - 全局唯一ID去重: 客户端生成
idempotency_key,服务端用Redis或DB做幂等校验 - 乐观锁 / 版本号: 更新时带上
version,只更新匹配版本的数据 - 状态机约束: 密钥状态只能单向流转(例如「生效中→已撤销」),重复请求不会导致错误流转
避坑指南: 我曾经踩过一个大坑。给审计接口加幂等性时,只做了DB唯一索引,没考虑业务状态。结果客户端重试时,幂等校验过了,但业务状态已经变了,导致后续操作全乱。后来我改成「幂等键 + 业务状态」双重校验,才算稳定。
4.2.2 幂等性校验代码片段
# 伪代码:幂等性校验逻辑
def audit_key_permission(request):
# 1. 提取幂等键
idempotency_key = request.headers.get('X-Idempotency-Key')
if not idempotency_key:
return 400, "缺少幂等键"
# 2. 检查是否已处理
cache_key = f"idempotent:{idempotency_key}"
cached_result = redis.get(cache_key)
if cached_result:
return 200, cached_result # 返回历史结果
# 3. 执行审计逻辑(带锁,防并发)
with distributed_lock(f"lock:{idempotency_key}"):
# 再次检查,避免锁等待期间已被处理
cached_result = redis.get(cache_key)
if cached_result:
return 200, cached_result
result = do_actual_audit(request)
# 4. 缓存结果,设置TTL(例如24小时)
redis.set(cache_key, result, ex=86400)
return 200, result
4.3 超时与重试策略:别让重试变成灾难
这一块我印象特别深。有一回线上半夜报警,原因是重试风暴导致整个密钥审计服务雪崩。嗯,后来我亲手写了整套超时重试策略,再也没出过类似问题。
4.3.1 超时设置
- 连接超时: 3~5秒。如果连不上,别傻等
- 读取超时: 15~30秒。审计查询可能走大数据量
- 写入超时: 10~15秒。审计记录写入要快
我建议把超时参数放到配置中心,而不是硬编码。生产环境和测试环境的网络状况差很多,硬编码迟早出事。
4.3.2 重试策略:指数退避 + 随机抖动
为什么会强调抖动?如果没有抖动,大量请求同时超时、同时重试,就会引发「重试雪崩」。我之前就吃过这个亏。
import time
import random
def retry_with_exponential_backoff(
func,
max_retries=3,
base_delay=1.0,
max_delay=30.0,
jitter=True
):
for attempt in range(max_retries + 1):
try:
return func()
except Exception as e:
if attempt == max_retries:
raise e
# 计算退避时间
delay = min(base_delay * (2 ** attempt), max_delay)
# 加入随机抖动
if jitter:
delay = delay * (0.5 + random.random() * 0.5)
time.sleep(delay)
| 重试次数 | 固定延迟 | 指数退避 | 退避+抖动 |
|---|---|---|---|
| 第1次重试 | 1s | 2s | 1.0~2.0s |
| 第2次重试 | 1s | 4s | 2.0~4.0s |
| 第3次重试 | 1s | 8s | 4.0~8.0s |
避坑指南: 我曾经把一个重试TTL设成了永久,结果客户端把错误请求重试了整整一周。最后我强制加了「重试窗口」——只允许在第一次请求后的60秒内重试。超过窗口期,必须由用户重新发起。
4.4 状态码规范:让调用者一眼看懂
状态码这事,看着简单,用起来全是坑。我见过有人全部返回200,然后在body里写状态码。也见过有人把500当作正常响应来用。
4.4.1 密钥审计接口的状态码清单
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 | 成功 | 审计通过、密钥查询 |
| 201 | 创建成功 | 创建密钥、提交审计 |
| 400 | 参数错误 | 缺少必填字段、格式错误 |
| 401 | 未认证 | 缺少或过期 |
| 403 | 无权限 | 无权访问某密钥 |
| 404 | 资源不存在 | 密钥ID不存在 |
| 409 | 冲突 | 重复提交、状态冲突 |
| 429 | 限流 | 请求频率过高 |
| 502 | 上游错误 | 审计系统内部依赖故障 |
| 503 | 服务不可用 | 系统维护或过载 |
重点关注: 不要在200里返回业务错误。我说的是真的,有人把业务异常放在200的body里,字段叫 error_code: 500。这会导致你的监控、ELK接入、客户端拦截器全都失效。坚决按照HTTP状态码走,不要自己发明轮子。
4.4.2 规范化的响应体结构
// 成功响应
{
"code": 0,
"message": "success",
"data": {
"audit_id": "adf-123-xyz",
"key_id": "key-001",
"status": "approved"
},
"timestamp": 1709876543210
}
// 失败响应
{
"code": 40001,
"message": "缺少必填参数:key_id",
"data": null,
"trace_id": "trace-xxx-yyy",
"timestamp": 1709876543210
}
trace_id 很重要。我们内部约定,所有错误响应必须带 trace_id,方便快速定位。这习惯后来成了整个团队的标准。
这张图就是我处理接口设计时的整体脉络。你从左到右捋一遍:先定风格,再保幂等,配上超时重试,最后用状态码收尾。这套流程我用了好多年,很少出大问题。