先别急着背 CRUD,RESTful API 的第一目标是让失败可预测
我去年给一个 SaaS 做开放平台,日请求大概 180 万,晚高峰 QPS 到 1200。上线第 3 天,一个客户写了个脚本,把 GET /v1/orders?offset=0&limit=100 里的 offset 一路翻到 100 万。MySQL 扫了 4.2 秒,连接池被打满,整个开放平台 502。那天之后我把分页从 offset 改成 cursor,同一个接口 P99 从 1.8s 降到 240ms。所以我现在看 RESTful API,不太看 URL 漂不漂亮,我看三件事:客户端超时后能不能安全重试,代理层能不能缓存,出问题时能不能只靠状态码和 header 定位。
很多团队把 REST 当成「用名词做 URL + 用 HTTP 方法」。这没错,但不够。REST 的核心约束其实是统一接口、无状态、可缓存、分层系统。翻译成人话:每个请求都带够信息,服务端不靠 session 猜;响应能明确告诉调用方能不能缓存、要不要重试、下一次去哪儿拿。对比 GraphQL,REST 更容易被 CDN 和 Nginx 缓存,但字段多了容易 over-fetching。对比 gRPC,REST+JSON 体积更大、序列化更慢,可调试性却更好,curl 就能打。我的判断是:公开 API、Webhook、给第三方调的接口,优先 REST;内部服务之间延迟敏感、字段稳定,gRPC 更舒服;客户端要自由拼 40 个字段、又不想发 6 个请求,GraphQL 值得上。别把 REST 当宗教,把它当契约。
状态码别全返 200,这几个数字能省掉一半扯皮
我见过最离谱的接口,不管成功失败都返 200 OK,body 里写 {code: 500, msg: '系统错误'}。结果监控全绿,客户端 SDK 得自己解析业务码,重试逻辑也没法写。我的做法是让 HTTP 状态码承担传输和语义层,业务码只做细分。具体映射:创建成功用 201,并带 Location: /v1/orders/12345;异步任务用 202,返回 Location: /v1/tasks/task_abc;删除成功不返 body 用 204;参数格式错 400;没登录 401;登录了但没权限 403;资源不存在 404;唯一键冲突、版本冲突 409;字段校验失败 422;限流 429;服务端未知错误 500;维护或依赖挂了 503。
这里有个容易吵的点:404 到底该不该暴露资源存在性。公开 API 里,如果查别人的订单,我倾向返 404 而不是 403,避免枚举。内部后台可以返 403,方便排查。还有 401 和 403 别混:401 是「你是谁我不知道」,响应带 WWW-Authenticate: Bearer realm=api;403 是「我知道你是谁,但你不能干这个」。429 必须带 Retry-After: 30,单位秒,也可以带 HTTP-date。客户端看到 429 就退避,别立刻重试。我们线上把 429 比例压在 1% 以下,5xx 压在 0.1% 以下,超过就告警。
深分页、幂等、缓存:三个最容易上线后爆炸的地方
先讲分页。limit 默认 20,最大 100,超过 100 直接 400,别让一个请求拖垮数据库。offset 分页在 page 小于 100 时没问题,但 offset 到 100 万,MySQL 要扫描并丢弃前 100 万行。改成 cursor 分页:按 created_at desc, id desc 排序,返回 next_cursor,客户端下次带上。SQL 大概长这样:where (created_at, id) < (?, ?) order by created_at desc, id desc limit 20。cursor 用 base64 编码 created_at|id,并加签名,防止客户端乱改。响应里放 has_more: true,别让客户端靠返回条数猜。
再讲幂等。支付、下单、发券这类接口,客户端超时后一定会重试。你如果不做幂等,就会出两笔订单。我们要求客户端带 Idempotency-Key,服务端在 Redis 或 MySQL 存 24 小时,唯一索引 (merchant_id, idempotency_key)。第一次请求创建订单,返回 201;相同 key 再来,直接返回第一次的响应,状态码可以是 200。重试策略别拍脑袋:连接超时 3 秒,读超时 10 秒,最多重试 5 次,指数退避 100ms、200ms、400ms、800ms、1600ms,再加 0 到 100ms 的 jitter。只重试 429、502、503、504 和网络错误;400、401、403、404、422 不要重试,重试也不会变对。
缓存这块,GET 接口能上 ETag 就上。服务端返回 ETag: 'v1-abc123',客户端下次带 If-None-Match: 'v1-abc123',没变就返 304,body 为空。我们一个配置接口 QPS 8000,加了 304 后带宽降了 62%。Cache-Control 可以写 public, max-age=60, s-maxage=300, stale-while-revalidate=30。但用户私有数据别乱用 public 和 s-maxage,除非你确定 Vary: Authorization 配对了,否则 CDN 会把 A 用户的订单返给 B 用户。
版本化、限流、可观测:上线后才是真正的设计开始
版本化我站 URL 大版本:/v1/orders、/v2/orders。Header 版本如 Accept: application/vnd.myapp.v1+json 更优雅,但调试和网关路由更麻烦,团队小的时候容易忘。字段演进遵守「只加不删,新增可选,默认值明确」。要删字段先标废弃:响应加 Sunset: Wed, 31 Jan 2025 23:59:59 GMT,文档加 Deprecation: true,至少留 90 天。别今天发邮件明天就下线,第三方客户会炸。
限流别只做单机内存计数,网关或 Redis 做分布式。我们按 API key 限 1000 次/分钟,突发 50。响应头带 X-RateLimit-Limit: 1000、X-RateLimit-Remaining: 999、X-RateLimit-Reset: 1712345678。被限流返 429,带 Retry-After: 30。客户端要做熔断:连续 10 次 5xx 或 429,暂停 60 秒。服务端要做日志关联:每个请求生成 X-Request-Id,跨服务透传 W3C traceparent。日志采样 10%,错误日志 100%。指标至少看 QPS、P50、P95、P99、429 比例、5xx 比例。没有这些,你根本不知道接口是慢还是错。
最后给一个检查清单,大概 12 条:资源用名词复数;GET 不改变状态;POST 创建;PUT 全量替换;PATCH 局部更新;DELETE 删除;201 带 Location;202 异步任务;204 无内容;400/401/403/404/409/422/429/500/503 别乱用;分页 cursor 优先;幂等键 24h;ETag 304;限流头;版本 URL;废弃 Sunset;请求 ID。RESTful API 不是把 CRUD 写成 HTTP,而是让调用方在超时、重试、缓存、灰度、被限流的时候,仍然知道下一步该干什么。这个目标达到了,URL 长一点短一点,真没那么重要。