接口设计:让我返工最多的 6 个决定

错误码只用一个 code 字段、分页用 offset、幂等靠唯一索引、批量接口语义含糊、时间和金额没定单位、版本演进当成加字段——这六个「顺手那么写」的决定,后来都让我返工了。逐条给出我现在的做法。

接口这件事,写第一版的时候觉得没什么可设计的——不就是定个 URL、定个 JSON 结构吗?等接口被三个端依赖、线上跑了一年之后再回头看,真正让我返工的都是当初"顺手那么写"的决定。这篇把踩过的六个坑按返工代价从大到小排一排。

一、错误码:只用一个 code 字段,是最大的坑

我最早的设计是这样的:

{ "code": 0, "message": "ok", "data": { ... } }

一切顺利时挺好,但很快出现三个问题:HTTP 状态码全返回 200(网关、监控、CDN 都看不出错误);code 只有业务错误码,没有分类(调用方没法判断"该重试还是该改参数");message 直接给用户看,于是文案的修改变成了一次后端发版。

现在的写法是分三层:

层载体谁在用
传输语义HTTP 状态码(400/401/403/404/409/429/500)网关、监控、日志聚合、客户端框架的拦截器
业务分类业务 code(如 order.not_found)调用方判断分支、埋点
展示文案message(可被前端覆盖)人看

关键结论:HTTP 状态码表达"这类请求出问题了没有",业务 code 表达"具体是哪一种"。两者都留着,谁也别替代谁。

二、分页:用 offset 迟早会遇到"翻页重复"

?page=2&size=20 看着直观,但在有写入的表上会出问题:你在翻第二页的时候后台插入了 5 条新数据,第二页的窗口整体后移,有 5 条数据你永远看不到,另外 5 条你会看两遍。

列表接口我现在统一用游标:

GET /api/v1/messages?cursor=<last_id_or_seq>&limit=20
→ { "items": [...], "nextCursor": "...", "hasMore": true }

什么时候仍可以用 offset?数据几乎不变、且需要跳页的场景(后台管理列表、报表)。这两种需求同时满足才用,否则一律游标。

顺带一个细节:游标不要用"页码加密"糊弄,客户端只要把它当成一个不透明字符串回传就行,别让它自己拼。

三、幂等:不是"加个唯一索引"就完了

「创建订单按了两次」是每个后端都会遇到的。我一开始的做法是给订单号加唯一索引,重复插入报错返回 500——用户看到"系统错误",于是又点了一次。

现在的要求是三条:

  1. 客户端生成幂等键(Idempotency-Key: <uuid>),同一业务意图重试时复用同一个键;
  2. 服务端把「幂等键 → 结果」存起来(Redis 24 小时 + DB 落一份),命中就直接返回上次的结果,而不是报错;
  3. 状态机层面也要幂等:已支付 的订单再收到支付回调,返回成功而不是报"状态非法"。

第 2 条里的"返回上次的结果"很关键——调用方要的是同一个响应,不是"你没重复创建"这种解释。

四、批量接口:小心"部分成功"的语义

POST /batch 传 100 个 id,其中 3 个不合法,返回什么?

  • 全成功才 200?那 97 个合法的白做了;
  • 200 + 部分失败清单?调用方必须逐个判断,稍不注意就把失败当成功。

我现在的规则写死在接口文档里:批量操作要么全成功,要么返回 207 风格的明细,并且响应里必须有 succeeded / failed 两个数组(失败项带原因)。同时限制单批上限(我给的 100),并且把"校验全部通过再执行"作为默认行为——先校验后执行,避免"做了一半"。

五、时间与金额:写清楚单位和时区,比什么都重要

两个真实事故:

  • 时间戳字段一会儿是秒、一会儿是毫秒,客户端把秒当毫秒渲染,显示成 1970 年;
  • 金额用浮点数,0.1 + 0.2 之后对不上账。

现在的约定:

{
  "created_at": "2026-09-23T08:00:00+08:00",   // 带时区的 RFC3339,不用裸时间戳
  "amount": { "currency": "CNY", "cents": 1999 } // 整数最小单位,不用小数
}

数据库里同样:DATETIME(不带时区歧义)或统一存 UTC,金额用 BIGINT 存分。这两条改起来代价极大(要动所有上下游),所以第一版就必须定死。

六、版本演进:加字段是兼容的,改语义不是

我曾经以为"加个字段不影响老客户端",然后加了一个 status 字段,语义从"是否删除"变成了"业务状态",老客户端把它当布尔值用……线上立刻出现数据被隐藏。

可以放心做的:

  • 新增可选字段(老客户端忽略);
  • 新增枚举值的追加(前提是客户端有 default 分支)。

必须当成破坏性变更的:

  • 改字段名、改类型、改单位(秒变毫秒);
  • 改字段语义(同一个字段两种含义);
  • 把可选改必填、把返回的数组改成对象。

破坏性变更的处理:开新版本路径(/v2/...)或者给字段换名字(deleted → status),老字段保留一个过渡期并在文档里标注废弃时间。

小结

这六条的共同点是:第一版怎么定,后面就很难改。错误码和分页决定调用方的代码形态,幂等和批量决定重试语义,时间和金额决定数据能不能对账,版本策略决定你能不能不中断地演进。

所以接口设计里最值得花的不是实现时间,而是在写第一版之前,把"调用方会怎么用、出错时怎么办、半年后要加什么"想清楚——这三件事想清楚了,上面的六个坑基本都不会踩。

返回博客列表