自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

RESTful API设计与微服务架构

  1. 01REST 架构基础
  2. 02设计策略、指导原则
  3. 03核心 RESTful API 模式
  4. 04高级 RESTful API 模式
  5. 05微服务架构中的 API 网关
  6. 06RESTful 服务的测试与安全
  7. 07智能应用的 RESTful 服务组合
  8. 08RESTful API 设计建议
  9. 09RESTful 服务范式
  10. 10框架、标准语言与工具集
正在加载课程章节内容
课程编程RESTful API设计与微服务架构REST 架构基础

从单体调用到不可靠的网络

假设你正在做一个订单系统。最开始,订单和库存在同一个进程里,创建订单时,扣减库存只需要一次普通的方法调用:

java
inventoryService.deduct(productId, quantity);

后来,库存规则越来越复杂,还有了独立的发版节奏。团队于是把它拆成库存服务,订单服务也改成发送 HTTP 请求:

java
restTemplate.postForObject(
    "http://inventory-service/reservations",
    request,
    Reservation.class
);

两段代码的业务意图很像,都是“为订单占住一份库存”,但它们的失败模型已经完全不同。本地调用的边界在同一个进程内,在调用者的视角里,方法正常返回,或者抛出异常。网络调用却要经过地址解析、连接、中间节点、下游排队、业务处理和响应返回,任何一段都可能变慢或中断。于是,除了“成功”和“明确失败”,调用者还必须面对第三种结果:不知道。

单体函数调用与跨网络服务调用的边界对比

这门课不会把微服务写成一次免费升级。拆分确实能隔离代码和发布,但它也把原来隐藏在进程内的协作搬到了网络上。你不是把复杂度删掉了,而是把“代码耦合”换成了“远程协作”;理解这次交换,才是理解 RESTful API 和微服务的起点。


超时只说明“我没等到”

我们先把最容易误判的情况讲透。订单服务向库存服务发出扣减请求,并把等待上限设为 180 毫秒;库存服务在 80 毫秒时已经记录扣减,但响应在回程中丢失了。第 180 毫秒,订单服务的定时器到期,它只能得到一个超时异常。现在问一个看似简单的问题:库存扣减成功了吗?答案是,仅凭这个超时,不知道。

库存扣减成功但回执丢失所造成的超时歧义时间线

超时表达的是调用者的等待预算用完了,它没有告诉你请求停在了哪里,也没有替远程服务撤销已经提交的变更。同一个超时异常背后,至少可能有四条不同的时间线:

  • 请求还没离开调用者,连接就失败了。
  • 请求到达下游,但还在队列里,没有开始处理。
  • 下游已经处理并提交,成功响应没能回来。
  • 下游仍在处理,稍后才会成功或失败。

这四种情况在调用者这一侧可能长得完全一样:都是“没收到响应”。

超时不是远程业务的失败回执。它只是本地等待结束的事实。如果把超时直接改写成“扣款失败”,订单状态就可能与支付状态分叉。

那么,超时后要不要重试?如果不重试,一次短暂的网络抖动就会变成用户可见的失败;如果立即重试,而支付服务每收到一次 POST 都新建一笔扣款,用户又可能被扣两次。所以“设置超时”只是起点,后面还必须同时考虑调用语义、幂等性、重试边界和结果对账。

同样的故障也会出现在支付调用中,只是重复副作用从“多扣库存”变成了“多扣一笔款”。为了让重试仍然指向同一个业务意图,教学订单系统给每笔支付传入稳定的幂等键:

js
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,
  },
});

第一次请求如果已经创建支付,第二次带同一个键到达时,支付服务会返回原结果,而不是再扣一次。对应的运行过程在终端中显示为:

text
HTTP 201
orderId: ord-0002
status: COMPLETED
paymentAttempts: 2
secondAttemptReplayedOriginalPayment: true

这里的 paymentAttempts: 2 说明订单服务发起了两次调用,secondAttemptReplayedOriginalPayment: true 则说明第二次没有产生新支付,而是取回了第一次的结果。幂等键不会让网络变可靠,它解决的是“不得不重试时,如何不重复产生副作用”。


拆分是一次复杂度转移

既然拆分会带来这些麻烦,为什么还要拆?因为单体也有自己的成本:当订单、库存、支付和通知全部编译、发布在一起时,一个小改动可能要重新发布整个应用;模块边界不清楚,调用方还可能绕过业务规则,直接修改别人的表或内部对象。拆分后,库存服务可以单独演进,并用接口保护“可用量不能为负数”这类规则。

这份收益不是免费的。原来的一次本地事务,现在变成了多个服务各自提交;原来的方法名和参数类型,现在变成了网络契约、路径、头字段和 JSON 结构。原来的调用栈散落到多个进程的日志里,原来的一个崩溃点也变成了多种局部失败组合。

从单体代码耦合到分布式故障的复杂度转移图

拆分后每得到一份演进空间,几乎都会接手一项新的协作责任。我们可以把这次转移整理成一张对照表:

单体内的问题拆分后获得的空间拆分后新增的问题
模块之间可以随意调用服务用契约隔离内部实现契约版本需要协调
发布单元过大服务可以独立发布跨服务发布需要兼容
资源只能整体扩容热点服务可以单独扩容实例地址和路由会变化
一个本地事务改完数据各服务掌握自己的数据业务状态可能暂时不一致
一个日志文件里查请求服务可以独立运维日志、指标和调用链必须被关联

表中没有哪一列是“免费”的。例如,库存预留成功之后,支付服务却明确拒绝了扣款,订单服务不能让预留一直占着库存。为了收拾这个已经部分执行的流程,它只能发起另一个远程操作,释放预留:

js
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;
}

这段流程执行后,订单明确失败,库存则恢复到预留前的数量。终端中可以看到:

text
HTTP 422
problemCode: PAYMENT_DECLINED
stockBefore: 3
stockAfter: 3

stockBefore 和 stockAfter 相同,说明预留的库存被释放了。但别急着把这叫作“回滚”:本地数据库没有跨过网络倒转时间,这是一次新的补偿请求。补偿请求也可能超时、重复或失败,因此还需要持久化流程状态、重试任务和后续对账。这就是微服务设计的真实口径:我们用服务自治换来了发布和扩展空间,同时接手了协作上的不确定性。


REST 不是 HTTP 方法口诀

转到 API 设计时,初学者常收到一张对照表:GET 查询,POST 创建,PUT 修改,DELETE 删除。这张表有用,但它只是统一接口的一部分;如果设计者仍然只想着“我要暴露哪个方法”,即使把 JSON 装进 HTTP,也没有转向资源思维。我们先看一个常见的动作式路径:

http
GET /getUserOrders?userId=42

这条路径把服务器的动作名放在了中心:执行 getUserOrders。调用者必须先学会这个专用命令,才知道怎么找到数据。再看资源导向的表达,关注点会从“调哪个函数”转向“要访问什么”:

http
GET /users/42/orders

这条路径不再描述处理器的名字,而是标识“用户 42 所关联的订单集合”。客户端可以用 GET 取得这个集合的表述,也可以按契约用 POST 向集合新增成员,而无需再为每个动作发明一个专用动词。

RPC 动作路径与 REST 资源关系路径的对比

当然,改一个 URL 并不会自动改善系统。这个对比真正有用的地方,是它逼我们先回答几个建模问题:

  • 订单是不是一个有稳定身份的业务对象?
  • 订单集合和某个用户的订单集合是什么关系?
  • 客户端看到的状态是什么,允许发生哪些转移?
  • 缓存、网关和通用客户端能否从消息中读懂语义?

这才是 REST 的价值:它用一组约束换取可见、统一和可中介的交互。它不是一份 URL 拼写检查表,也不是将所有业务强行塞进 CRUD 的命名比赛。


资源、表述与状态转移

“资源”这个词很容易被理解成数据库里的一行数据,但更准确的理解是:资源是一个可以被标识、在一段时间内保持稳定语义的概念。/api/orders/ord-0001 标识的是订单 ord-0001,而不是某一串永远不变的 JSON。因此,订单从 PROCESSING 变成 COMPLETED 之后仍然是同一个资源,客户端在某一时刻收到的 JSON,只是这个资源当时的一份表述。

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 告诉客户端新资源在哪里。例如创建订单时,客户端向订单集合提交一份创建意图:

bash
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}'

服务器理解这份意图后,会在集合中建立一个新成员。它不只返回业务结果,还会用响应头给出定位和版本信息:

text
HTTP 201
Location: /api/orders/ord-0001
ETag: "v1"
status: COMPLETED
reservationId: res-0001
paymentId: pay-0001

201 表达新资源已建立,Location 指向它的标识符,ETag 则为后续条件请求提供版本信息。这些信息并不只给人看,浏览器、缓存、网关和通用 HTTP 客户端都能根据统一语义做出行为。这就是“统一接口”的实用价值:各个业务资源不用各自发明一套完全不同的调用规则。

不过,有了统一的交互语义,不等于可以把状态字符串随便改掉。例如,取消订单可以表达为对订单资源的部分修改,但服务器仍然要守住订单的状态机:

http
PATCH /api/orders/ord-0001
If-Match: "v1"
Content-Type: application/json
 
{"status":"CANCELLED"}

服务器依然要检查当前状态是否允许取消,并用 If-Match 防止客户端基于过期版本覆盖新变化。资源导向没有消除业务规则,它只是让“操作哪个对象、希望它怎样转移”变得更可见。

无状态也不是“服务器不存数据”。订单和库存当然要保存。无状态要求的是,每个请求都带齐处理它所需的上下文,服务器不依赖“上一次请求恰好由这个实例处理”这种会话记忆。


六个约束,六组取舍

REST 更适合被理解为一组架构约束。这些约束不是为了让设计看起来规整,而是用限制换取特定的系统属性,并为每份收益接受相应的代价。

REST 六项架构约束及其收益与代价

客户端与服务器分离

客户端处理交互和展示,服务器管理资源与业务规则。两边分离后可以在契约内独立演进,代价是契约本身成了需要长期维护的产品,任何不兼容变更都要考虑对另一边的影响。

无状态

一次请求应该包含理解它所需的信息,这使网关可以把请求转发给任意健康实例,监控系统也更容易从单次消息判断意图。相应的代价是,请求可能重复携带认证和上下文信息,客户端也要管好自己的应用状态。

可缓存

响应需要明确说明能否被重用。缓存可以减少重复交互和平均延迟,但错误的新鲜度策略会把过期数据留在链路中。因此,“加缓存”不是单向的性能优化,它还是一个数据时效性决策。

统一接口

统一接口包括用标识符识别资源、通过表述操作资源、让消息可以自我描述,以及通过链接告知后续可达状态。它让网关、代理和通用工具能看懂交互,也降低客户端与具体实现的绑定;代价是不能针对每个业务场景发明一套最特化、最省字节的调用方式。

分层系统

客户端不需要知道自己直接连到了源服务、网关还是缓存,因此路由、认证、限流和共享缓存都可以放进中间节点。这种透明分层的代价是,每一层都可能增加处理开销和延迟,也可能成为新的故障点。

按需代码

服务器可以向客户端下发可执行代码,在运行时扩展客户端能力。这是唯一的可选约束,它能减少客户端预置能力,但也会降低可见性,并带来执行远程代码的安全边界。

约束要按系统需要来选,不要把“更 REST”当成脱离业务目标的评分游戏。统一接口换来的是通用性和可见性;如果某个内部场景更需要强类型、流式传输或极低延迟,就应该诚实比较其他交互风格。


一笔订单穿过了哪些边界

后续课程会一直沿用同一套小型订单系统,它有四个主要运行节点:API 网关、订单服务、库存服务和支付服务。请求从网关进入,网关处理认证、授权、限流和路由,然后把 /api/orders 转发给订单服务。

订单服务先在订单集合中建立一个 PROCESSING 资源,再请求库存服务创建预留。预留成功后,它才请求支付服务创建支付;支付成功,订单转移到 COMPLETED。如果支付明确拒绝,订单就转移到 REJECTED,并请求库存服务释放预留。

教学订单系统的服务调用链与课程路线

图中的通知服务代表一个可以继续接入的后续分支,下面的运行链路只经过网关、订单、库存和支付四个节点。这条链路并不长,却已经包含微服务中最典型的问题:

  • 网关如何知道订单服务的地址?
  • 订单已建立,但库存不足时,对外应该返回什么?
  • 支付响应超时时,能不能重试?
  • 释放预留的补偿请求也失败时,谁来继续收尾?
  • 用户说“一直转圈”时,怎样知道请求卡在哪个服务?

最后一个问题会引出可观测性。请求进入网关时会获得一个 trace ID,后续服务调用继续携带同一个值;每个服务都记录自己处理的方法、路径、状态码和耗时。排障时,再用这个共同标识把分散日志串起来,一笔成功订单的调用顺序就会呈现为:

text
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 网关、认证与授权、限流、服务路由和契约测试,也会看到超时、幂等重试、熔断、降级、补偿和最终一致分别在回答什么故障。这些机制不应该被孤立背诵:每加一次重试,就要问是否会放大流量;每加一个熔断器,就要问快速拒绝会丢失哪部分能力。

最后回到边界与可运维性

订单和支付是否一定要拆成两个服务?没有一个适用于所有团队的答案。放在一起,一致性更直接,但发布、扩展和权限边界也绑在一起;拆开之后,支付可以用自己的安全规则和发布节奏,但订单必须处理跨服务结果不确定。

所以边界不是从一张标准答案图里抄出来的,它需要结合业务语义、变更频率、一致性需求和团队责任来取舍。不论最后怎样拆,日志、指标、调用链和告警都必须跟上,否则每多一个边界,就多一块无法观察的黑箱。


先做三个判断

1
订单服务等待支付响应超时,此时最准确的结论是什么?
2
下列哪些说法体现了资源思维?
3
REST 的无状态约束要求服务器不得保存订单、库存等业务数据。

再做一个纸上练习:支付服务没有幂等键能力,订单服务发出扣款请求后超时。你会立即重试、立即记为失败,还是记为结果未知并进入查询或对账流程?先写下你的理由。

更稳妥的做法是保留“结果未知”,再通过稳定的业务标识查询支付状态或等待对账。盲目重试可能重复扣款,立即记为失败又可能让订单与已成功的支付分叉。当下游补齐幂等能力后,调用方才有条件设计可控重试。


带着故障模型进入 API 设计

这一章留下的核心认知可以压缩成三句话。第一,网络调用不是普通函数调用,因为调用者可能无法分辨“没执行”与“已执行但回执丢失”。第二,微服务拆分是复杂度的交换:服务边界、独立发布和单独扩展,要用超时、幂等、补偿和可观测性来支付。第三,REST 的起点是资源与约束,不是背诵 HTTP 方法;资源、表述、统一消息语义和状态转移,共同形成了可演进的交互边界。

下一章,我们就把这些认知落到具体 API 决策上。起点仍然不是先写路由,而是先识别订单系统里真正稳定的资源、关系和业务状态。

下一章设计策略、指导原则