上一节我们已经把业务从“调用一个动作”改写成了“操作一个资源”,现在要把这套想法真正落到请求上。我们继续使用订单系统:客户端经过 API 网关创建订单,订单服务先向库存服务创建预留,再向支付服务创建支付。如果一切都在一个进程里,这段流程很像三次普通函数调用;拆成服务以后,任何一步都可能出现“事情已经办完,调用方却没有收到回执”的结果。
所以这一节不按 GET、POST、PATCH 的词典顺序背规则,而是沿着一笔订单可能经历的故障来设计接口:重复提交怎么办,查询不到怎么办,缓存过期怎么办,两个人同时取消怎么办,批量操作只成功一半又怎么办。HTTP 状态码、Location、幂等键和 ETag 并不是接口上的装饰,它们都在帮助客户端回答同一个问题:我现在知道了什么,又该安全地做什么?
假设产品经理给出四个需求:下单、查看订单、取消订单、查看订单项。很自然的第一反应,是为每个需求各设计一个动作地址:
POST /createOrder
GET /getOrder?id=ord-0001
POST /cancelOrder
GET /getOrderItems?id=ord-0001这种接口不是不能工作,问题是每出现一个业务动作,客户端就要再记一个动词、参数位置和返回约定,久而久之,同一类资源会散落在许多互不相干的入口里。资源模型换了一个观察角度:先找出系统里长期存在、可以被标识的对象,再把创建、读取和修改交给 HTTP 方法表达。
POST /api/orders
GET /api/orders/ord-0001
PATCH /api/orders/ord-0001
GET /api/orders/ord-0001/items这里有三个层次:
/api/orders 是订单集合,POST 表示往集合中加入一个成员,GET 表示读取集合的一个页面。/api/orders/ord-0001 是单个订单,GET 读取它,PATCH 修改它的一部分状态。/api/orders/ord-0001/items 是订单项集合,它只有放在订单上下文里才有明确含义。订单取消后,历史订单依然存在,只是 status 变成了 CANCELLED。因此取消不是删除订单,更不是凭空调用一个 cancelOrder 函数。这个判断来自业务事实:我们仍要查询这笔订单、退款记录和取消后的状态。

这张表并不意味着所有业务都能硬塞进 CRUD。订单导出要持续很久时,“导出进度”应该成为操作资源;付款本身有编号、状态和退款生命周期时,“支付”也应该成为资源。资源化的价值正在这里:我们描述的是系统中可以查询、链接和审计的事实,而不是为每个新需求继续增加一次性的动作地址。
教学项目的 openapi.yaml 把路径、参数、请求体和响应写在同一份机器可读契约里。开发者可以阅读它,工具也可以据此生成文档、客户端或测试输入。下面是创建订单的核心轮廓:
paths:
/api/orders:
post:
summary: 创建订单
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrder'
responses:
'201':
description: 订单已创建
OpenAPI 不会替我们做业务决策,它的作用是把已经做出的决定明确写出来:幂等键是否必填、数量能否小于 1、成功时有哪些响应头、错误体是什么结构。如果实现返回 201,文档却只声明 200,客户端生成器和测试工具面对的就是另一套接口。契约的价值来自它与实现一起演进,而不是文件名叫不叫“规范”。
我们先从一笔最普通的创建请求开始。客户端明确给出商品、数量和金额,同时用 Idempotency-Key 标识这一次下单意图。即使稍后因为超时重发,请求体和这个键也要保持不变:
curl -i http://127.0.0.1:43100/api/orders \
-H 'Authorization: Bearer demo-admin-token' \
-H 'Idempotency-Key: reader-example-001' \
-H 'Content-Type: application/json' \
-d '{"productId":"keyboard-001","quantity":2,"amount":398}'请求穿过网关后,订单服务依次创建订单、库存预留和支付记录。流程完成时,客户端会收到新订单的位置、当前版本和资源表述:
HTTP/1.1 201 Created
Location: /api/orders/ord-0001
ETag: "v1"
Content-Type: application/json; charset=utf-8
{
"id": "ord-0001",
"productId": "keyboard-001",
"quantity": 2,
"amount": 398,
"currency": "CNY",
"status": "COMPLETED",
"version"
201 Created 明确告诉通用 HTTP 客户端,这次请求产生了新资源;Location 接着给出新资源的地址 /api/orders/ord-0001,客户端不必猜 ID 的生成规则,也不用把响应体里的 id 拼回路径。响应体返回完整订单,则让客户端立即拿到服务端生成的字段。如果客户端不需要表述,也可以采用没有正文的成功响应,但创建资源时,201 加 Location 通常把结果交代得更完整。
现在把网络抖动放进这条链路:
201 响应在返回客户端的路上丢了。超时并不等于订单创建失败。客户端此时至少面对三种可能:请求没有到达、请求执行到一半、请求全部完成但响应丢了,仅凭“我没收到响应”无法判断是哪一种。如果它直接再发一次普通 POST,服务端就可能创建第二笔订单,再扣一次款。分布式调用与本地函数调用的差别,正藏在这个无法确定的结果里。
解决办法是让客户端在第一次请求前生成一个业务操作级别的唯一键,并在重试同一次下单时始终携带原值。这个键标识的是业务意图,不是某一次 TCP 连接,因此必须在首次发送前就生成并保存,不能等到超时后临时补一个新值:
Idempotency-Key: reader-example-001订单服务收到请求后先查这个键:已经处理过,就返回它对应的订单;还没处理过,才进入创建流程。这样,网络层可以重复投递同一个请求,业务层却只接受一次创建效果。检查与记录幂等键还要围绕同一份持久化状态设计,否则并发请求仍可能同时穿过检查。
const orders = new Map();
const idempotency = new Map();
async function createOrder(req, res, traceId) {
const key = req.headers['idempotency-key'];
if (!key) {
throw new HttpError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'缺少幂等键'
同一个请求再次到达时,服务端会把它认回原来的业务意图,返回同一个订单而不是另建一笔。响应头也会明确标出这是一次重放:
HTTP/1.1 200 OK
Location: /api/orders/ord-0001
Idempotency-Replayed: true
ETag: "v1"
{
"id": "ord-0001",
"status": "COMPLETED",
"reservationId": "res-0001",
"paymentId": "pay-0001"
}首次创建返回 201,重放返回 200,两者表达的事实不同:第一次产生了新资源,第二次只是把已有结果交回客户端。调用方既能确认订单 ID 没有变化,也能通过 Idempotency-Replayed 判断这次响应来自重放路径。

幂等键必须和“一次业务意图”绑定。重试同一次下单要复用原键,用户真的又买一次则要生成新键。生产实现还应保存请求摘要;同一个键若带来不同请求体,应拒绝而不是悄悄返回旧订单。教学实现为了突出主线,只记录了键与订单 ID 的关系。
订单入口有幂等键还不够,因为订单服务调用支付服务时,同样可能遇到“扣款成功但回执丢失”。如果只有最外层请求能够去重,订单服务重试下游调用时仍可能产生第二笔扣款。教学实现因此使用订单 ID 派生支付幂等键,让下游也能认出重复请求:
const response = await requestJson(`${urls.payment}/payments`, {
method: 'POST',
timeoutMs: 180,
headers: {
'x-trace-id': traceId,
'idempotency-key': `payment:${order.id}`,
},
body: {
orderId: order.id,
amount: order.amount,
currency: order.currency,
},
});下面的故障场景里,第一次支付已经入账,但响应延迟到订单服务超时之后。订单服务复用同一个键发起重试,支付服务认出原请求并返回原支付记录,因此执行次数增加了,支付效果却仍然只有一次。诊断字段把这段过程完整保留下来:
HTTP 201
orderId: ord-0002
status: COMPLETED
paymentAttempts: 2
secondAttemptReplayedOriginalPayment: true这组结果里最有用的不是“重试两次”,而是第二次没有产生另一笔支付。如果下游根本不支持幂等,订单服务就不能看到超时便盲目重试扣款,而要查询支付状态、等待异步通知或进入对账流程。重试不是可靠性的同义词;只有能识别重复效果的重试,才可能是安全的。
创建失败也要让客户端知道该改哪里。缺少幂等键时,请求还没有满足接口前提,服务端会在进入订单流程之前拒绝它:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"title": "缺少幂等键",
"status": 400,
"detail": "创建订单时必须提供 Idempotency-Key。",
"code": "IDEMPOTENCY_KEY_REQUIRED",
"traceId": "trace-create-001"
}另一种失败发生在内容层:请求体是合法 JSON,服务端也认识这些字段,但 quantity 不是正整数,因而无法按业务规则处理。此时错误应指向输入语义,而不是笼统地说 JSON 损坏,客户端也能据此把错误定位到表单字段:
HTTP/1.1 422
Content-Type: application/problem+json; charset=utf-8
{
"title": "参数校验失败",
"status": 422,
"detail": "productId 必须是字符串,quantity 必须是正整数,amount 必须大于 0。",
"code": "VALIDATION_ERROR",
"traceId": "trace-create-002"
}如果相同幂等键对应的请求仍在处理,直接开启第二条执行链会放大并发问题。此时用 409 Conflict 告诉客户端“当前资源状态与这次操作冲突”,并可用 Retry-After 建议稍后再查。
创建完成后,客户端通常会做两种读取:读取一个确定订单,或读取满足条件的一批订单。两种请求都可以返回 200 OK,但前者描述单个资源的当前状态,后者描述集合在某组筛选和分页条件下的表述。把二者分清,才能正确处理空集合、资源不存在和缓存命中。
curl -i http://127.0.0.1:43100/api/orders/ord-0001 \
-H 'Authorization: Bearer demo-read-token'HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/json; charset=utf-8
{
"id": "ord-0001",
"status": "COMPLETED",
"version": 1,
"reservationId": "res-0001",
"paymentId": "pay-0001"
}这里返回 200,因为响应体就是目标订单的当前表述。如果客户端把 ID 换成 ord-9999,目标从一开始就不是一个可用的订单资源,服务端会返回 404,并用问题详情说明找不到哪一个路径:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{
"title": "资源不存在",
"status": 404,
"detail": "找不到资源 /orders/ord-9999。",
"code": "RESOURCE_NOT_FOUND",
"traceId": "trace-read-404"
}404 不只是给人看的“没有”。客户端可以据此停止刷新详情页、清理本地失效关联,或者回到集合重新选择。
再看集合读取。客户端可以用状态和页大小修饰订单集合,下面的请求只查询已取消订单,并把单页数量限制为 10:
curl 'http://127.0.0.1:43100/api/orders?status=CANCELLED&limit=10' \
-H 'Authorization: Bearer demo-read-token'即使此时没有任何匹配项,/orders 集合仍然存在,筛选也已经成功执行。适合的结果是 200 加一个结构完整的空集合:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [],
"page": {
"limit": 10,
"nextCursor": null,
"total": 0
},
"links": {
"self": "/orders?status=CANCELLED&limit=10"
}
}不要因为没有数据就返回 404,因为集合资源 /orders 仍然存在,只是这次筛选结果为空。这里也不适合返回 204:虽然它能表达成功且没有正文,但空数组、分页信息和链接本身就是有效表述。使用 200 和结构稳定的空集合,客户端无需为“零条结果”再写一套特殊分支。
status=COMPLETED 没有把请求变成另一个资源类型,它只是要求服务端观察订单集合时只保留已完成订单。教学实现还同时解析 limit 与 cursor,先筛选,再从游标指向的位置截取一页:
const requestedLimit = Number(url.searchParams.get('limit') || 20);
const limit = Math.max(
1,
Math.min(Number.isFinite(requestedLimit) ? requestedLimit : 20, 50)
);
const cursor = Math.max(0, Number(url.searchParams.
limit 默认是 20,最大限制为 50。这个上限不是为了为难客户端,而是避免一个无界请求把整张表、过多内存和过长响应一起拖进来。服务端还应在契约中写明默认值和最大值,让客户端能在发请求前安排自己的页面大小。
假设筛选后还有下一页,响应不能只交回当前的 items,否则客户端不知道从哪里继续。教学接口会同时给出下一位置和可直接跟随的链接:
{
"items": [
{ "id": "ord-0001", "status": "COMPLETED" },
{ "id": "ord-0002", "status": "COMPLETED" }
],
"page": {
"limit": 2,
"nextCursor": "2",
"total": 5
},
"links": {
"self": "/orders?status=COMPLETED&limit=2"
客户端既可以读取 nextCursor 自己构造查询,也可以直接跟随 links.next;更推荐把链接当成服务器给出的下一步导航。这样能减少客户端对参数拼接规则的依赖,以后即使服务端调整游标编码,只要继续返回正确的 next,客户端仍然能够向后翻页。

教学实现中的订单服务生成服务本地路径,例如 /orders/ord-0001;API 网关对响应头里的 Location 会补上 /api。生产接口应进一步统一响应体链接的外部规范地址,避免客户端一部分地址带网关前缀、一部分不带。
总数 total 并不总是免费。在大表或复杂筛选上,精确计数可能比取一页数据更慢。可以按产品需求选择精确总数、估算值,或者只返回 hasMore 与 nextCursor,但契约要明确,不能让客户端猜一个数字究竟是不是精确结果。
订单完成后,前端可能每隔几秒轮询详情。如果每次都传回同一份 JSON,网络传输和序列化会反复做无用功。第一次读取仍然正常返回正文,但服务器同时给这份表述附上实体标签;客户端保存正文时也保存标签,供下一次请求比较:
GET /api/orders/ord-0001 HTTP/1.1
Authorization: Bearer demo-read-token
HTTP/1.1 200 OK
ETag: "v1"
{ "id": "ord-0001", "status": "COMPLETED", "version": 1 }下一次读取时,客户端不用先猜订单是否变化,只要在 If-None-Match 中带上自己已有的标签。这个请求的含义是:“如果当前表述不同于 v1,再把新正文发给我;如果仍然相同,只确认缓存可用即可”:
GET /api/orders/ord-0001 HTTP/1.1
Authorization: Bearer demo-read-token
If-None-Match: "v1"如果订单仍是版本 1,条件没有要求服务端重传正文,响应就只需确认缓存仍然有效:
HTTP/1.1 304 Not Modified
ETag: "v1"304 没有响应体,但它的意思不是“没有订单”,而是“你手里的表述仍可使用”。客户端把本地保存的正文与这次响应的元数据合起来,就得到当前读取结果,服务端也避免重复发送同一份 JSON。若版本已经变化,服务器则走普通 200 路径并返回新表述与新 ETag。
const current = etag(order);
if (req.headers['if-none-match'] === current) {
return sendEmpty(res, 304, {
etag: current,
'x-trace-id': traceId,
});
}
return sendJson(res, 200, orderRepresentation(order), {
etag: current,
'x-trace-id': traceId,
});
不要把 304 和 204 混在一起:
304 回答一个条件读取,告诉客户端复用已有表述。204 回答一次成功操作,告诉客户端没有额外正文可接收。200 通常带回本次请求对应的表述。订单已经完成,客户端准备取消。它只想改变状态,不会重新提交商品、金额、支付记录等完整表述,因此请求使用 PATCH,并带上刚才读到的 ETag。请求体表达要改什么,请求头则表达允许基于哪个版本修改:
PATCH /api/orders/ord-0001 HTTP/1.1
Authorization: Bearer demo-admin-token
Content-Type: application/json
If-Match: "v1"
{
"status": "CANCELLED"
}这里使用 PATCH,因为请求体描述的是部分变化,不是用一份完整文档替换整个订单。不过,方法语义只能说明“怎么改”,并不能保证客户端看到的版本仍是最新版本;要避免并发覆盖,还需要把读取时的版本带回服务端。
想象客服甲和客服乙几乎同时读取订单,都拿到 ETag: "v1"。客服甲先提交取消,订单变成 CANCELLED,版本随之升级为 v2;客服乙稍后仍拿着 v1 提交,如果服务端不检查条件,就可能在已经变化的订单上执行旧决定。
If-Match 把客服乙的前提写进请求:“只有当前版本仍等于 v1,才执行这次修改。”服务端因此必须先比较标签,再决定是否进入退款和释放库存流程。版本检查要发生在产生业务副作用之前,否则即使最后返回冲突,退款也可能已经执行:
async function cancelOrder(req, res, traceId, order) {
const current = etag(order);
if (!req.headers['if-match']) {
throw new HttpError(
428,
'PRECONDITION_REQUIRED',
'缺少前置条件',
'取消订单时必须用 If-Match 带上最新 ETag。'
);
}
if (req.headers['if-match'
此时当前版本已经是 v2,客服乙携带的前提不成立。服务端不会执行取消,而是把最新 ETag 连同 412 问题详情交回客户端。这个响应既阻止了旧决定覆盖新状态,也告诉客户端应从哪个版本重新开始:
HTTP/1.1 412 Precondition Failed
ETag: "v2"
Content-Type: application/problem+json; charset=utf-8
{
"title": "资源版本冲突",
"status": 412,
"detail": "当前 ETag 是 \"v2\",请重新读取订单。",
"code": "VERSION_CONFLICT",
"traceId": "trace-cancel-stale"
}
客户端收到 412 后,不应该把旧请求原样无限重试,因为前提在下一次请求中仍会失败。正确动作是重新读取订单,展示最新状态,再决定用户的原意是否仍然适用;若用户确认继续,再用新的 ETag 发出新请求。
这两个状态码经常一起出现,但它们回答的问题不同。412 Precondition Failed 表示请求头里的显式条件失败,例如客户端要求“只在 v1 上执行”,服务器却已经是 v2;客户端应重新读取,再判断原操作是否仍然成立。409 Conflict 表示请求与资源当前业务状态冲突,例如一个 REJECTED 订单不允许取消,或者库存只剩 1 件却想预留 3 件:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
{
"title": "库存不足",
"status": 409,
"detail": "keyboard-001 只剩 1 件,无法预留 3 件。",
"code": "INSUFFICIENT_STOCK",
"productId": "keyboard-001",
"requested": 3,
"available": 1,
"traceId": "trace-stock-conflict"
这套修改规则还有两个入口校验。如果请求根本没带 If-Match,教学接口返回 428 Precondition Required,强迫客户端先读到一个已知版本,而不是默认允许无条件修改。如果 JSON 合法、订单也存在,但请求把状态写成了不允许的值,失败原因来自请求内容本身,因此返回 422:
HTTP/1.1 422
{
"title": "状态转换无效",
"status": 422,
"detail": "这个演示接口只允许把订单状态改为 CANCELLED。",
"code": "INVALID_STATE_TRANSITION"
}当版本条件和业务状态都通过后,教学接口执行退款与库存补偿,再返回更新后的订单。客户端可以直接用响应中的新版本刷新界面:
HTTP/1.1 200 OK
ETag: "v2"
Content-Type: application/json; charset=utf-8
{
"id": "ord-0001",
"status": "CANCELLED",
"version": 2,
"reservationId": "res-0001",
"paymentId": "pay-0001"
}这里选择 200,是因为响应体让客户端立即拿到新状态和新版本。如果接口不打算返回更新后的表述,204 No Content 也可以清楚表达“操作成功,没有正文”,并且仍可在响应头里给出新的 ETag。选择的关键不在于哪个状态码看起来更 REST,而在于客户端成功后是否需要一份新表述;规则一旦确定,整个 API 就应保持一致。
订单资源不只交回自身字段,还通过 links 告诉客户端相关资源在哪里。订单项依赖订单存在,所以它的入口自然落在当前订单之下。这样,客户端拿到订单表述后就能沿链接导航,不必在每一处代码里重复拼接路径:
{
"links": {
"self": "/orders/ord-0001",
"items": "/orders/ord-0001/items",
"collection": "/orders",
"cancel": "/orders/ord-0001"
}
}/orders/ord-0001/items 使用嵌套路径,是因为订单项在这个模型里依赖订单存在,而且通常只会在某笔订单的上下文中读取。客户端沿着链接发起 GET,就能得到这个订单的项目集合和返回订单的导航:
GET /api/orders/ord-0001/items HTTP/1.1
Authorization: Bearer demo-read-token
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [
{
"productId": "keyboard-001",
"quantity": 2,
"unitAmount": 199
}
],
"links": {
"order": "/orders/ord-0001"
库存预留和支付则没有被压成 /orders/{id} 里的普通字段修改,因为它们各自有独立身份和生命周期:预留可以被读取和释放,支付可以被读取和退款。把它们建模成独立资源后,订单只需保存关联 ID,库存与支付服务仍能分别维护自己的状态转换:
POST /reservations
GET /reservations/res-0001
DELETE /reservations/res-0001
POST /payments
GET /payments/pay-0001
DELETE /payments/pay-0001创建订单时,订单服务会依次跨过订单、库存和支付三个状态边界。正常情况下,这条同步编排链从处理中开始,以订单完成结束:
创建订单 PROCESSING
↓
创建库存预留 RESERVED
↓
创建支付 CAPTURED
↓
订单变为 COMPLETED图里的每一次向下游发请求,都可能超时或重复;更麻烦的是,这三份状态不在同一个本地事务里,库存预留成功之后,支付仍然可能明确拒绝。教学实现只有在拿到明确的支付失败时,才把订单标记为 REJECTED,并调用 DELETE /reservations/{id} 释放刚才的预留:
try {
const payment = await chargeWithRetry(order, traceId, paymentMode);
order.paymentId = payment.id;
order.status = 'COMPLETED';
} catch (error) {
order.status = 'REJECTED';
order.failure = error.code || error.kind || 'PAYMENT_FAILED';
try {
await releaseReservation(order, traceId);
} catch {
order.failure
场景脚本会在请求前后各读取一次库存。支付被明确拒绝后,补偿把已经预留的数量释放回去,因此可用库存前后保持一致:
HTTP 422
problemCode: PAYMENT_DECLINED
stockBefore: 3
stockAfter: 3
这里的 DELETE 表达“释放预留资源”,并且库存服务把重复释放设计为可重放。即使订单服务没有收到第一次释放的响应,再次请求也不会把库存加回两遍。补偿端点同样需要这种性质,因为释放请求本身也会经过不可靠网络。
本地数据库事务回滚后,其他参与者通常看不到中间状态,跨服务补偿却不是这样。预留已经真实存在过,支付也可能已经把结果交给外部系统;补偿是一个新的业务动作,它可能成功,也可能再次超时。因此,代码会把补偿失败记成 COMPENSATION_PENDING,而不是假装一切已经回到从未发生过的状态。
这套顺序编排为了教学保持得很小,但它的恢复边界必须说清楚:服务若在“预留成功、订单尚未记录预留 ID”之间崩溃,仅靠内存代码无法恢复。更完整的系统还需要持久化流程状态、可靠消息、定期对账或工作流机制。这正是拆分的代价——代码边界独立了,我们也失去了一个进程里一次事务就能完成全部修改的确定性。
客户端不能只根据 status >= 400 弹一句“请求失败”,因为不同错误对应的后续动作完全不同:有时要修 JSON,有时要修改字段,有时要重新读取版本,也可能需要等待重试或直接停止请求。教学接口因此统一使用 application/problem+json,让通用状态与业务原因出现在同一个稳定结构里:
{
"type": "https://demo.local/problems/insufficient-stock",
"title": "库存不足",
"status": 409,
"detail": "keyboard-001 只剩 1 件,无法预留 3 件。",
"instance": "/reservations",
"code": "INSUFFICIENT_STOCK",
"traceId": "trace-order-003"
}这些字段各有职责:
status 保留 HTTP 层的通用分类。code 给客户端一个稳定的业务分支键,不要让程序解析中文句子。detail 解释这一次为什么失败,适合展示给开发者或经过处理后展示给用户。traceId 把客户端看到的错误与多个服务的日志串起来。type 标识问题类型,instance 标识这次问题发生在哪个请求目标上。这张表提供的是判断线索,不能脱离上下文机械套用。例如库存不足适合 409,因为冲突来自库存当前状态;数量写成 -2 适合 422,因为请求内容本身无法按业务规则执行;旧 ETag 则适合 412,因为失败的是客户端明确写出的前提。状态码选得准确,客户端才能选择正确的恢复动作。
问题详情应该帮助调用方修正接口使用方式,但不应把堆栈、数据库语句、内部主机名或密钥写出去。traceId 提供了一条更安全的排障路径:客户端提交这个号码,服务端再到受控日志中查内部细节。在微服务里,这比返回一大段异常文本更有用,因为一个请求可能经过网关、订单、库存和支付四个进程,而同一个 trace ID 能把散落的记录重新拼成一条调用链。
当客户端要创建 500 笔订单时,增加一个“批量接口”看起来很诱人,毕竟一次网络往返就够了。但先问一个更实际的问题:第 237 笔库存不足,其余 499 笔怎么办?一个 HTTP 状态码只能描述整次响应,无法自动替你决定每个子项的事务语义。当前教学项目没有实现批量删除或批量创建,这个边界是刻意保留的,因为把一个数组塞进 POST /orders 并不会自动得到可靠批处理。
客户端提交 20 个项目,服务端只在 20 个都能完成时提交结果,这种语义对客户端最简单:看到成功就全部存在,看到失败就全部不存在。但跨订单、库存和支付服务很难获得真正原子的全局事务,批次越大,占用资源越久,超时后的结果也越难确认。
另一种做法是让每个项目独立执行,再在响应体中逐项给出成功或失败。此时顶层响应只说明批次已经处理,真正的业务结果要看每个条目。为了让响应项能和原输入一一对应,请求还应为每项提供客户端侧标识:
{
"results": [
{
"clientItemId": "line-001",
"status": 201,
"location": "/api/orders/ord-0101"
},
{
"clientItemId": "line-002",
"status": 409,
"code": "INSUFFICIENT_STOCK"
}
]
}客户端因此能精确重试失败项,但也必须理解部分成功,并为每一项提供稳定标识和幂等键。如果整个批量响应在返回途中丢失,客户端更不能换一批新键全部重发,否则已经成功的条目会再次执行;可靠的批量接口要同时处理批次级重放和条目级去重。
对于数量大、耗时长或需要排队的操作,可以把“批量处理过程”本身建模为资源。客户端先提交输入来源和幂等键,得到的不是所有订单结果,而是一个可持续查询的任务。即使连接中断,客户端也能重新读取任务,而不是重新启动整批工作:
POST /api/order-imports HTTP/1.1
Idempotency-Key: import-2026-08-18-001
Content-Type: application/json
{
"sourceFileId": "file-0008"
}服务端可以立即返回任务位置,客户端随后读取进度和逐项结果。这样既把“处理是否结束”变成了可查询状态,也避免让一条 HTTP 连接长时间等待,代价是服务端需要增加任务存储、结果清理和重试策略。下一节会继续展开这种异步资源模式。
批量删除还多了一层不可恢复风险。不建议把大量 ID 塞进很长的查询字符串,也不要假设 DELETE 请求体能被所有客户端和中间设施一致处理。如果业务确实需要它,可以把“删除作业”建模为资源,并在契约中明确以下边界:
接口数量少不是目标。客户端能确认每一项发生了什么,才是批量设计是否可靠的判断标准。
状态码写在文档里还不够,重构一次路由或更换一次网关,就可能把响应头、链接或错误体悄悄改掉。教学项目把这些关键契约写进集成测试,让每次修改都重新穿过网关和真实子进程;创建订单的断言会同时检查资源地址、版本标签和链接:
await test('资源型 URL 可创建订单并返回 Location、ETag 和链接', async () => {
const response = await api('/api/orders', {
method: 'POST',
headers: {
'idempotency-key': 'test-order-1',
'x-trace-id': 'trace-test-create',
},
body: {
productId: 'keyboard-001',
quantity: 2,
amount: 398,
创建成功只是第一层契约,网络重放和缓存命中同样需要自动验证。下面两组断言分别确认重复请求仍返回原订单,以及 ETag 命中时没有重复传输正文。它们验证的不是函数内部写法,而是客户端从网关外部真正看见的行为:
assert.equal(replayed.status, 200);
assert.equal(replayed.headers['idempotency-replayed'], 'true');
assert.equal(replayed.body.id, firstOrder.id);
assert.equal(notModified.status, 304);把这些场景放在一起,整套检查就覆盖了健康聚合、认证、创建、重放、条件读取、支付回执丢失、补偿、并发取消、异步操作、熔断、限流和链路追踪。运行测试时,每一个行为都会给出独立结果,失败时也能直接看到是哪一条接口承诺发生了变化:
✓ 聚合健康检查会确认三个下游服务
✓ 未携带令牌时返回统一 401 问题详情
✓ 资源型 URL 可创建订单并返回 Location、ETag 和链接
✓ 重复的幂等键返回同一个订单
✓ If-None-Match 命中时返回 304
✓ 支付已入账但回执超时时,相同幂等键的重试不会重复扣款
✓ 支付被拒后会释放已预留库存
✓ If-Match 防止旧版本覆盖,取消后执行退款和库存补偿
✓ 长时间任务先返回 202,再通过操作资源查询
✓ 两次超时后网关打开熔断器
✓ 限流超额时返回 429 和 Retry-After
✓ 同一 trace ID 会穿过网关、订单、库存和支付服务
12/12 项测试通过。测试通过不代表这份小型实现已经适合直接上线:数据、幂等记录、限流计数和熔断状态都在单进程内存里,服务重启后会清空,生产系统还必须考虑持久化、多实例协调和流程恢复。不过,这些测试已经守住了本节最重要的接口承诺:重复请求不会产生第二个订单,旧版本不会覆盖新状态,明确失败会执行补偿,缓存命中不会重复传输正文。
现在回头看,一笔订单已经把核心模式串成了一条完整路径。我们可以从集合入口开始,沿着创建、读取、修改和跨服务协作重新走一遍。下面每个箭头都对应一个客户端能够观察和验证的 HTTP 契约:
POST 集合
→ 201 + Location 标识新资源
→ Idempotency-Key 抵抗重复创建
→ GET 单体与集合读取状态
→ cursor 与 links 导航大集合
→ ETag + If-None-Match 避免重复传输
→ PATCH + If-Match 防止旧版本覆盖
→ 409 / 412 / 422 告诉客户端不同的修复动作
→ 子资源和独立资源表达不同生命周期这些模式没有消除网络故障,它们做的是把“不确定”暴露出来,并给客户端一条能够继续安全行动的路径。至此,短时同步请求已经有了较完整的资源契约,但仍有一类问题没有解决:导出需要一分钟怎么办,批量任务如何报告进度,服务端怎样通知状态变化,两个服务又如何在不长期占用连接的情况下继续协作。
下一节会把任务、事件和链接也建模为资源,继续处理异步操作、并发控制和超媒体导航。到那时你会发现,所谓高级模式并不是多学几个状态码,而是承认一次请求的生命周期可能远长于一条 HTTP 连接,再为这段更长的生命周期安排可查询、可重试、可追踪的状态。