接口这件事,写第一版的时候觉得没什么可设计的——不就是定个 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——用户看到"系统错误",于是又点了一次。
现在的要求是三条:
- 客户端生成幂等键(
Idempotency-Key: <uuid>),同一业务意图重试时复用同一个键; - 服务端把「幂等键 → 结果」存起来(Redis 24 小时 + DB 落一份),命中就直接返回上次的结果,而不是报错;
- 状态机层面也要幂等:
已支付的订单再收到支付回调,返回成功而不是报"状态非法"。
第 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),老字段保留一个过渡期并在文档里标注废弃时间。
小结
这六条的共同点是:第一版怎么定,后面就很难改。错误码和分页决定调用方的代码形态,幂等和批量决定重试语义,时间和金额决定数据能不能对账,版本策略决定你能不能不中断地演进。
所以接口设计里最值得花的不是实现时间,而是在写第一版之前,把"调用方会怎么用、出错时怎么办、半年后要加什么"想清楚——这三件事想清楚了,上面的六个坑基本都不会踩。