从单体调用到不可靠的网络
假设你正在做一个订单系统。最开始,订单和库存在同一个进程里,创建订单时,扣减库存只需要一次普通的方法调用:
inventoryService.deduct(productId, quantity);后来,库存规则越来越复杂,还有了独立的发版节奏。团队于是把它拆成库存服务,订单服务也改成发送 HTTP 请求:
restTemplate.postForObject(
"http://inventory-service/reservations",
request,
Reservation.class
);两段代码的业务意图很像,都是“为订单占住一份库存”,但它们的失败模型已经完全不同。本地调用的边界在同一个进程内,在调用者的视角里,方法正常返回,或者抛出异常。网络调用却要经过地址解析、连接、中间节点、下游排队、业务处理和响应返回,任何一段都可能变慢或中断。于是,除了“成功”和“明确失败”,调用者还必须面对第三种结果:不知道。

这门课不会把微服务写成一次免费升级。拆分确实能隔离代码和发布,但它也把原来隐藏在进程内的协作搬到了网络上。你不是把复杂度删掉了,而是把“代码耦合”换成了“远程协作”;理解这次交换,才是理解 RESTful API 和微服务的起点。
超时只说明“我没等到”
我们先把最容易误判的情况讲透。订单服务向库存服务发出扣减请求,并把等待上限设为 180 毫秒;库存服务在 80 毫秒时已经记录扣减,但响应在回程中丢失了。第 180 毫秒,订单服务的定时器到期,它只能得到一个超时异常。现在问一个看似简单的问题:库存扣减成功了吗?答案是,仅凭这个超时,不知道。

超时表达的是调用者的等待预算用完了,它没有告诉你请求停在了哪里,也没有替远程服务撤销已经提交的变更。同一个超时异常背后,至少可能有四条不同的时间线:
- 请求还没离开调用者,连接就失败了。
- 请求到达下游,但还在队列里,没有开始处理。
- 下游已经处理并提交,成功响应没能回来。
- 下游仍在处理,稍后才会成功或失败。
这四种情况在调用者这一侧可能长得完全一样:都是“没收到响应”。
超时不是远程业务的失败回执。它只是本地等待结束的事实。如果把超时直接改写成“扣款失败”,订单状态就可能与支付状态分叉。
那么,超时后要不要重试?如果不重试,一次短暂的网络抖动就会变成用户可见的失败;如果立即重试,而支付服务每收到一次 POST 都新建一笔扣款,用户又可能被扣两次。所以“设置超时”只是起点,后面还必须同时考虑调用语义、幂等性、重试边界和结果对账。
同样的故障也会出现在支付调用中,只是重复副作用从“多扣库存”变成了“多扣一笔款”。为了让重试仍然指向同一个业务意图,教学订单系统给每笔支付传入稳定的幂等键:
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这里的 paymentAttempts: 2 说明订单服务发起了两次调用,secondAttemptReplayedOriginalPayment: true 则说明第二次没有产生新支付,而是取回了第一次的结果。幂等键不会让网络变可靠,它解决的是“不得不重试时,如何不重复产生副作用”。
拆分是一次复杂度转移
既然拆分会带来这些麻烦,为什么还要拆?因为单体也有自己的成本:当订单、库存、支付和通知全部编译、发布在一起时,一个小改动可能要重新发布整个应用;模块边界不清楚,调用方还可能绕过业务规则,直接修改别人的表或内部对象。拆分后,库存服务可以单独演进,并用接口保护“可用量不能为负数”这类规则。
这份收益不是免费的。原来的一次本地事务,现在变成了多个服务各自提交;原来的方法名和参数类型,现在变成了网络契约、路径、头字段和 JSON 结构。原来的调用栈散落到多个进程的日志里,原来的一个崩溃点也变成了多种局部失败组合。

拆分后每得到一份演进空间,几乎都会接手一项新的协作责任。我们可以把这次转移整理成一张对照表:
表中没有哪一列是“免费”的。例如,库存预留成功之后,支付服务却明确拒绝了扣款,订单服务不能让预留一直占着库存。为了收拾这个已经部分执行的流程,它只能发起另一个远程操作,释放预留:
try {
const payment = await chargeWithRetry(order, traceId);
order.paymentId = payment.id;
order.status = 'COMPLETED';
} catch (error) {
order.status = 'REJECTED';
await releaseReservation(order, traceId);
throw error;
}这段流程执行后,订单明确失败,库存则恢复到预留前的数量。终端中可以看到:
HTTP 422
problemCode: PAYMENT_DECLINED
stockBefore: 3
stockAfter: 3stockBefore 和 stockAfter 相同,说明预留的库存被释放了。但别急着把这叫作“回滚”:本地数据库没有跨过网络倒转时间,这是一次新的补偿请求。补偿请求也可能超时、重复或失败,因此还需要持久化流程状态、重试任务和后续对账。这就是微服务设计的真实口径:我们用服务自治换来了发布和扩展空间,同时接手了协作上的不确定性。
REST 不是 HTTP 方法口诀
转到 API 设计时,初学者常收到一张对照表:GET 查询,POST 创建,PUT 修改,DELETE 删除。这张表有用,但它只是统一接口的一部分;如果设计者仍然只想着“我要暴露哪个方法”,即使把 JSON 装进 HTTP,也没有转向资源思维。我们先看一个常见的动作式路径:
GET /getUserOrders?userId=42这条路径把服务器的动作名放在了中心:执行 getUserOrders。调用者必须先学会这个专用命令,才知道怎么找到数据。再看资源导向的表达,关注点会从“调哪个函数”转向“要访问什么”:
GET /users/42/orders这条路径不再描述处理器的名字,而是标识“用户 42 所关联的订单集合”。客户端可以用 GET 取得这个集合的表述,也可以按契约用 POST 向集合新增成员,而无需再为每个动作发明一个专用动词。

当然,改一个 URL 并不会自动改善系统。这个对比真正有用的地方,是它逼我们先回答几个建模问题:
- 订单是不是一个有稳定身份的业务对象?
- 订单集合和某个用户的订单集合是什么关系?
- 客户端看到的状态是什么,允许发生哪些转移?
- 缓存、网关和通用客户端能否从消息中读懂语义?
这才是 REST 的价值:它用一组约束换取可见、统一和可中介的交互。它不是一份 URL 拼写检查表,也不是将所有业务强行塞进 CRUD 的命名比赛。
资源、表述与状态转移
“资源”这个词很容易被理解成数据库里的一行数据,但更准确的理解是:资源是一个可以被标识、在一段时间内保持稳定语义的概念。/api/orders/ord-0001 标识的是订单 ord-0001,而不是某一串永远不变的 JSON。因此,订单从 PROCESSING 变成 COMPLETED 之后仍然是同一个资源,客户端在某一时刻收到的 JSON,只是这个资源当时的一份表述。
{
"id": "ord-0001",
"productId": "keyboard-001",
"quantity": 2,
"amount": 398,
"currency": "CNY",
"status": "COMPLETED",
"version": 1,
"links": {
"self": "/orders/ord-0001",
"items": "/orders/ord-0001/items"
}
}资源是概念和身份,表述是此刻传输的数据,所以同一个资源的表述可以随状态变化,也可以按客户端能力选择不同媒体类型。消息还要带上自己的解释线索:Content-Type 说明如何解析响应体,ETag 标识表述版本,Location 告诉客户端新资源在哪里。例如创建订单时,客户端向订单集合提交一份创建意图:
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 201
Location: /api/orders/ord-0001
ETag: "v1"
status: COMPLETED
reservationId: res-0001
paymentId: pay-0001201 表达新资源已建立,Location 指向它的标识符,ETag 则为后续条件请求提供版本信息。这些信息并不只给人看,浏览器、缓存、网关和通用 HTTP 客户端都能根据统一语义做出行为。这就是“统一接口”的实用价值:各个业务资源不用各自发明一套完全不同的调用规则。
不过,有了统一的交互语义,不等于可以把状态字符串随便改掉。例如,取消订单可以表达为对订单资源的部分修改,但服务器仍然要守住订单的状态机:
PATCH /api/orders/ord-0001
If-Match: "v1"
Content-Type: application/json
{"status":"CANCELLED"}服务器依然要检查当前状态是否允许取消,并用 If-Match 防止客户端基于过期版本覆盖新变化。资源导向没有消除业务规则,它只是让“操作哪个对象、希望它怎样转移”变得更可见。
无状态也不是“服务器不存数据”。订单和库存当然要保存。无状态要求的是,每个请求都带齐处理它所需的上下文,服务器不依赖“上一次请求恰好由这个实例处理”这种会话记忆。
六个约束,六组取舍
REST 更适合被理解为一组架构约束。这些约束不是为了让设计看起来规整,而是用限制换取特定的系统属性,并为每份收益接受相应的代价。

客户端与服务器分离
客户端处理交互和展示,服务器管理资源与业务规则。两边分离后可以在契约内独立演进,代价是契约本身成了需要长期维护的产品,任何不兼容变更都要考虑对另一边的影响。
无状态
一次请求应该包含理解它所需的信息,这使网关可以把请求转发给任意健康实例,监控系统也更容易从单次消息判断意图。相应的代价是,请求可能重复携带认证和上下文信息,客户端也要管好自己的应用状态。
可缓存
响应需要明确说明能否被重用。缓存可以减少重复交互和平均延迟,但错误的新鲜度策略会把过期数据留在链路中。因此,“加缓存”不是单向的性能优化,它还是一个数据时效性决策。
统一接口
统一接口包括用标识符识别资源、通过表述操作资源、让消息可以自我描述,以及通过链接告知后续可达状态。它让网关、代理和通用工具能看懂交互,也降低客户端与具体实现的绑定;代价是不能针对每个业务场景发明一套最特化、最省字节的调用方式。
分层系统
客户端不需要知道自己直接连到了源服务、网关还是缓存,因此路由、认证、限流和共享缓存都可以放进中间节点。这种透明分层的代价是,每一层都可能增加处理开销和延迟,也可能成为新的故障点。
按需代码
服务器可以向客户端下发可执行代码,在运行时扩展客户端能力。这是唯一的可选约束,它能减少客户端预置能力,但也会降低可见性,并带来执行远程代码的安全边界。
约束要按系统需要来选,不要把“更 REST”当成脱离业务目标的评分游戏。统一接口换来的是通用性和可见性;如果某个内部场景更需要强类型、流式传输或极低延迟,就应该诚实比较其他交互风格。
一笔订单穿过了哪些边界
后续课程会一直沿用同一套小型订单系统,它有四个主要运行节点:API 网关、订单服务、库存服务和支付服务。请求从网关进入,网关处理认证、授权、限流和路由,然后把 /api/orders 转发给订单服务。
订单服务先在订单集合中建立一个 PROCESSING 资源,再请求库存服务创建预留。预留成功后,它才请求支付服务创建支付;支付成功,订单转移到 COMPLETED。如果支付明确拒绝,订单就转移到 REJECTED,并请求库存服务释放预留。

图中的通知服务代表一个可以继续接入的后续分支,下面的运行链路只经过网关、订单、库存和支付四个节点。这条链路并不长,却已经包含微服务中最典型的问题:
- 网关如何知道订单服务的地址?
- 订单已建立,但库存不足时,对外应该返回什么?
- 支付响应超时时,能不能重试?
- 释放预留的补偿请求也失败时,谁来继续收尾?
- 用户说“一直转圈”时,怎样知道请求卡在哪个服务?
最后一个问题会引出可观测性。请求进入网关时会获得一个 trace ID,后续服务调用继续携带同一个值;每个服务都记录自己处理的方法、路径、状态码和耗时。排障时,再用这个共同标识把分散日志串起来,一笔成功订单的调用顺序就会呈现为:
inventory-service POST /reservations 201
payment-service POST /payments 201
order-service POST /orders 201
api-gateway POST /api/orders 201这四行不是四个无关的记录,它们用同一个 trace ID 标记,所以可以重建出请求穿过系统的次序。单体中,一条异常堆栈常常就能指向问题;微服务中,如果没有一致的日志字段、trace ID、延迟指标和错误率,系统可能“能跑”,却无法回答运行时到底发生了什么。
在微服务里,日志、指标和调用链不是上线前有空再补的装饰。它们是跨进程理解系统行为的基础设施,应当与服务边界、API 契约一起设计。
接下来如何拆解这些问题
这门课的主线不是把技术名词按字母顺序排列出来。我们会一直回到故障和设计问题,再从问题倒推所需的机制,让每一个设计选择都对应一个具体后果。
先建立可理解的资源契约
我们会继续设计订单、预留、支付和操作资源,讨论路径、方法、状态码、问题详情和 OpenAPI 契约。这一部分要回答的是:在任何故障发生前,调用双方能否先对“正常意图”形成稳定共识?
再面对重复、并发和长时任务
当用户重复点击,或客户端在超时后重试,服务器要如何识别同一个意图?当两个客户端同时修改订单,ETag 和条件请求怎样防止旧版本覆盖新版本?如果导出任务要跑很久,为什么应该返回 202 Accepted 和操作资源,而不是让 HTTP 连接一直等?这些看似不同的问题,本质上都在检验 API 能否表达请求的身份、版本和处理进度。
然后把单个 API 放进服务协作中
我们会讨论 API 网关、认证与授权、限流、服务路由和契约测试,也会看到超时、幂等重试、熔断、降级、补偿和最终一致分别在回答什么故障。这些机制不应该被孤立背诵:每加一次重试,就要问是否会放大流量;每加一个熔断器,就要问快速拒绝会丢失哪部分能力。
最后回到边界与可运维性
订单和支付是否一定要拆成两个服务?没有一个适用于所有团队的答案。放在一起,一致性更直接,但发布、扩展和权限边界也绑在一起;拆开之后,支付可以用自己的安全规则和发布节奏,但订单必须处理跨服务结果不确定。
所以边界不是从一张标准答案图里抄出来的,它需要结合业务语义、变更频率、一致性需求和团队责任来取舍。不论最后怎样拆,日志、指标、调用链和告警都必须跟上,否则每多一个边界,就多一块无法观察的黑箱。
先做三个判断
再做一个纸上练习:支付服务没有幂等键能力,订单服务发出扣款请求后超时。你会立即重试、立即记为失败,还是记为结果未知并进入查询或对账流程?先写下你的理由。
带着故障模型进入 API 设计
这一章留下的核心认知可以压缩成三句话。第一,网络调用不是普通函数调用,因为调用者可能无法分辨“没执行”与“已执行但回执丢失”。第二,微服务拆分是复杂度的交换:服务边界、独立发布和单独扩展,要用超时、幂等、补偿和可观测性来支付。第三,REST 的起点是资源与约束,不是背诵 HTTP 方法;资源、表述、统一消息语义和状态转移,共同形成了可演进的交互边界。
下一章,我们就把这些认知落到具体 API 决策上。起点仍然不是先写路由,而是先识别订单系统里真正稳定的资源、关系和业务状态。