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/ListKeys
  • POST /api/rpc/AuditKeyPermission
  • POST /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 实现幂等性的四种手段

  1. 天然幂等: GET / DELETE(删除已不存在的资源)
  2. 全局唯一ID去重: 客户端生成 idempotency_key,服务端用Redis或DB做幂等校验
  3. 乐观锁 / 版本号: 更新时带上 version,只更新匹配版本的数据
  4. 状态机约束: 密钥状态只能单向流转(例如「生效中→已撤销」),重复请求不会导致错误流转

避坑指南: 我曾经踩过一个大坑。给审计接口加幂等性时,只做了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,方便快速定位。这习惯后来成了整个团队的标准。

接口高可用设计核心决策流程 接口设计四大核心 RESTful vs RPC 幂等性设计 超时与重试 状态码规范 面向资源 面向动作 前缀隔离 幂等键去重 版本号/乐观锁 指数退避+抖动 超时参数化 HTTP语义化 TraceId定位 关键:RESTful/RPC选型 → 幂等性 + 超时重试 → 状态码规范 四者缺一不可,共同构成接口高可用设计基座

这张图就是我处理接口设计时的整体脉络。你从左到右捋一遍:先定风格,再保幂等,配上超时重试,最后用状态码收尾。这套流程我用了好多年,很少出大问题。