上一节里,我们把订单、异步任务和后续动作都建模成了资源,也确定了客户端应当围绕资源状态与服务端交互。
如果整个应用还在一个进程里,请求找到对应的控制器,事情大体就结束了。
可一旦把订单、库存和支付拆成三个服务,同一个 POST /orders 背后就多出了一段网络旅程。
现在假设用户点击“提交订单”,库存服务恰好没有响应。 订单入口应该继续等、立即失败、自动重试,还是先答应用户“已经创建”再慢慢处理? 客户端又该去找订单服务,还是自己直接调用库存服务和支付服务? 这些问题没有靠“拆成微服务”自动消失。 原来挤在一个进程里的代码耦合少了,服务地址、网络超时、重复调用、局部故障和排障链路却一起出现了。
你不是获得了免费的午餐,而是拿一种复杂度换了另一种复杂度。 这一节就围绕教学项目的 API 网关,处理一个具体故障:库存服务挂了,订单入口如何把影响限制在可控范围内。

先把问题放进一个具体流程:假设移动端知道三个内部地址,并按下面的顺序下单。这里没有统一编排者,任何一步超时后,都由客户端自己判断前一步是否已经生效;不同客户端一旦给出不同答案,业务状态就会从入口开始分叉:
1. 调用库存服务预留两件商品
2. 调用支付服务完成扣款
3. 调用订单服务保存订单这看起来省掉了一个中间层,实际上把服务编排塞进了每一个客户端。 当库存服务扩成三个实例时,移动端要不要知道三个地址?其中一个实例正在发布,客户端又该怎么避开它?已经安装在用户设备上的旧版本无法随着一次服务发布立即更新。
更棘手的是,库存预留成功、支付也成功,但保存订单失败时,究竟由网页、iOS 还是 Android 负责退款和释放库存? 只要不同版本的客户端仍在用户设备上运行,旧的调用顺序就很难被统一收回。 服务的内部拓扑本来是服务器端的部署细节,现在却变成了公开契约。 这正是入口层首先要解决的问题:给客户端一个稳定地址,把变化频繁的内部拓扑留在服务端。
教学项目把统一入口放在 43100 端口,三个服务继续使用各自端口。客户端只保存一个外部基地址,路径与服务地址的对应关系由网关集中维护,内部实例变动时无需发布新客户端;路由配置也能独立检查和回滚:
客户端
└─ http://127.0.0.1:43100/api/*
├─ /api/orders → 订单服务 43103
├─ /api/inventory → 库存服务 43101
└─ /api/payments → 支付服务 43102网关在这里是反向代理。 客户端只认识网关,网关替客户端选择上游、发送请求、接收响应,再把结果返回。 “反向”指的是客户端不直接选择后端实例,它只说明代理的位置,不代表网关天然就有认证、限流或熔断能力。这些仍是需要明确实现和配置的策略。
统一入口减少的是客户端对内部拓扑的认知,不是网络调用本身。请求经过网关后仍可能超时、重复或只完成一半。多加一层还会增加一次处理开销,并让网关本身进入可用性链路。
如果库存服务已经挂了,网关没有办法凭空造出可信库存,继续猜测只会把技术故障变成错误的业务承诺。 它能做的是不再把更多请求送进故障点,控制等待时间,给客户端一个稳定、可判断的错误,并留下足够的排障信息。 换句话说,网关的价值不是“消灭故障”,而是让故障有边界。
订单创建属于会改变业务状态的操作。 库存不可确认时,最诚实的降级通常是拒绝创建,或者把请求接成一个有明确状态的异步任务。 返回一份虚构的“创建成功”订单,会把暂时不可用变成库存超卖和后续对账。 推荐列表挂了还可以返回热门商品;库存判断挂了却不能随便猜。 降级策略必须服从业务语义,不能把“始终返回 200”当成高可用。
网关不是看到 URL 就立刻转发。 一个外部请求通常会按一条短而明确的管线前进:建立追踪上下文、认证、做粗粒度授权、限流、匹配路由、选择健康上游、受超时约束地转发,最后规范响应。这条管线的顺序决定了无效请求会在多早的位置被挡住,也决定了下游需要承担多少无用工作。

教学项目的入口处理顺序可以从 gateway.js 里直接看出来。健康检查和指标走各自分支,普通 /api/ 请求则必须依次通过身份、权限和限流判断,最后才进入代理逻辑;这也让每个拒绝发生在哪一层一目了然:
async function route(req, res, traceId) {
metrics.requests += 1;
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/health') {
// 聚合三个下游的健康状态
}
if (url.pathname === '/metrics') {
authenticate
这个顺序不是随手排列的。 认证失败的请求在调用下游前就返回,既避免无效流量占用服务资源,也不会把未经验证的身份交给内部服务。 限流同样发生在转发前。 如果已经确定某个客户端超过配额,就没必要先访问库存数据库再告诉它“请求太多”。
路由阶段不能靠猜测服务名称,而要用明确、可测试的规则把外部资源路径映射到上游。项目按资源族选择订单、库存或支付服务,无法匹配的路径不会被随意转发;规则发生重叠时也应有确定的优先级:
function routeTarget(pathname) {
if (/^\/(orders|order-exports|operations)(\/|$)/.test(pathname)) {
return { name: 'order', baseUrl: urls.order };
}
if (/^\/(inventory|reservations)(\/|$)/.
/api/orders/ord-0001 在网关处去掉 /api,再转成订单服务内部的 /orders/ord-0001。
外部前缀和内部路径可以不同,但资源含义没有改变。
网关不应该因为承担了路径改写,就把资源型接口重新包装成下面这种动作集合,否则内部部署变化会连带破坏外部资源契约:
/api/createOrder
/api/getOrder
/api/cancelOrder上一节建立的“资源”思维仍然有效。
网关只是让资源有一个稳定入口,不是把 REST 又改回远程函数目录;POST /orders 仍是在订单集合里创建成员,而不是调用一个名为“创建订单”的远程函数。路径改写前后,方法与状态码的语义也要保持一致。
路由选中了服务名称之后,还要把名称解析成可以连接的地址。演示项目的 src/config.js 是一张很小的静态注册表,借它可以直接看到“服务身份”和“进程地址”是两层概念;静态地址一旦过期,正确的路由也会连接失败:
urls: {
inventory: `http://127.0.0.1:${basePort + 1}`,
payment: `http://127.0.0.1:${basePort + 2}`,
order: `http://127.0.0.1:${basePort + 3}`,
}这种写法适合四个固定进程的课程项目。 生产环境中的实例会因为扩缩容、重启和发布不断变化,静态地址很快会失真。 网关需要通过注册中心、集群 DNS 或控制面获得当前可用的服务入口。 这里要区分两个概念:服务发现回答“有哪些候选实例”,健康检查回答“哪些候选现在适合接请求”。 发现列表里有一个地址,不等于这个地址一定能正确完成业务。
在 Kubernetes 一类编排环境里,Pod 可以不断被创建和销毁,这种变化本来就不适合进入客户端配置。 稳定的 Service 名称把客户端与变化的 Pod 地址隔开,端点集合再随实例变化而更新。 网关可以把请求交给这个稳定入口,也可以读取端点信息后自己选择实例。 无论使用哪种方式,客户端都不应该记住某个 Pod 的 IP。
发现系统给出多个库存实例后,网关或平台负载均衡器还要决定本次请求落到哪一个。假设三个实例都已通过就绪检查,但当前并发量不同,请求分配策略就会直接影响排队时间;“都健康”并不表示处理余量完全相同:
inventory-a 当前请求 18
inventory-b 当前请求 7
inventory-c 当前请求 11轮询会依次选择三个实例,适合处理能力相近、请求耗时相近的情况。
最少请求会倾向 inventory-b,更适合耗时差异明显的请求。
加权策略则允许性能更强或刚扩容完成的实例承担不同流量。
算法名称不是重点。
真正要确认的是:它依据什么状态选择、状态更新有多快、失败实例何时摘除、恢复实例是否会立刻吃满流量。

主动健康检查按固定间隔访问一个轻量端点。 它能在没有真实用户请求时发现进程已经无法服务。 被动健康判断观察真实转发中的连接失败、超时和错误响应。 它更贴近用户正在经历的故障,却要等故障请求发生后才有证据。 两者配合时,服务发现给出候选集合,健康状态再把不合格的实例从负载均衡池中排除。
教学项目用一个短超时访问每个下游的 /health,把连接错误、超时和非成功状态统一折成 DOWN。这段聚合检查展示的是入口视角,不替代服务实例自己的就绪探针;短时限还避免健康接口本身长时间挂起:
async function serviceHealth(name, url, traceId) {
try {
const response = await requestJson(`${url}/health`, {
timeoutMs: 180,
headers: { 'x-trace-id': traceId },
});
return { name, status: response.status === 200 ? 'UP' : 'DOWN' };
} catch {
三个服务都正常时,入口把自身状态和依赖状态一起返回。这样值班人员能区分“网关进程还活着”和“订单链路所需依赖都可达”这两个判断,也能迅速看出降级来自哪个依赖:
{
"status": "UP",
"service": "api-gateway",
"dependencies": [
{ "name": "inventory", "status": "UP" },
{ "name": "payment", "status": "UP" },
{ "name": "order", "status": "UP" }
]
}只要一个依赖为 DOWN,整体状态就变成 DEGRADED,HTTP 状态改为 503。
这个响应对值班人员很有用,但不要把“健康接口返回 200”理解成每笔订单都必然成功。
健康探针通常只验证进程与关键依赖是否达到服务条件。
它不可能穷举每一条业务路径,更不能替代真正的订单场景测试。
存活检查回答“进程是否还需要被重启”。 就绪检查回答“现在是否应该接收流量”。数据库短暂不可用时,服务可能仍然活着,但已经不适合承接创建订单请求;此时摘除流量比重启进程更符合故障事实。
如果把暂时的依赖故障直接判为进程死亡,编排系统可能反复重启原本正常的进程,让抖动扩大。 如果把进程活着直接当成就绪,负载均衡器又会持续把用户请求送进故障实例。 因此,摘除流量和重启进程应有各自的判断,恢复也不宜只看一次成功。 常见做法是连续失败若干次再摘除,连续成功若干次再恢复,并让刚恢复的实例逐渐增加流量。 这样可以减少边缘抖动造成的来回切换。
服务发现、健康检查和负载均衡是一条连续决策链。只做发现会把请求发给已经故障的实例;只做健康检查却没有动态端点,扩容出来的新实例又永远接不到流量。
网关是外部请求的必经入口,很适合统一验证令牌,这样每个服务不用各自解析一套外部凭据。教学项目接受两个演示令牌:管理员令牌可读写,读者令牌只能执行 GET 和 HEAD;这组简化身份只用于展示边界,不代表生产令牌的签名、轮换与撤销设计。
function authenticate(req) {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (token === 'demo-admin-token') {
return { subject: 'course-admin', role: 'admin' };
}
if (token === 'demo-read-token') {
return
没有有效身份时返回 401。
身份已经确认,但没有执行当前操作的权限时返回 403。
这两个结果不应混在一起,因为客户端下一步动作不同:前者需要重新认证,后者换同一份凭据再试也不会获得权限。清楚区分还能避免客户端把权限不足误判为令牌过期并反复登录。
“读者不能发起非只读请求”可以在入口判断,因为它只依赖方法和角色。
“用户能否取消 ord-0001”则可能依赖订单归属、订单状态、商户关系和售后规则。
这些数据由订单服务掌握,把整套判断复制进网关会产生两份业务规则。
规则更新不一致时,入口说可以,订单服务说不可以,排障会变得很难。
更稳妥的边界是把“身份是否可信”和“业务资源是否允许操作”分开:入口处理前者和粗粒度范围,拥有资源数据的服务处理后者。落实到请求链路,可以按下面三条职责划分:
演示项目用固定字符串表示令牌,便于看清流程。 真实系统还要处理签名验证、签发者、受众、有效期、密钥轮换和撤销策略。 固定字符串不能直接用于生产,也不能仅凭令牌存在就相信其中的身份字段。
入口还适合限制请求体大小、拒绝非法 JSON、规范主机与转发头、终止外部 TLS,并给跨域策略一个统一位置。 但“统一”不等于下游可以删除全部防线。 内部网络也可能被错误配置或被绕过。 服务至少要校验输入、执行业务授权,并确认调用身份来自可信链路。 如果使用服务间双向 TLS,解决的是通信双方身份与传输保护;它也不能替代订单归属判断。
库存服务已经慢下来时,如果入口仍允许无限请求排队,连接、内存和事件循环会先被等待中的请求占满。 随后,原本健康的订单服务和网关也会一起变慢。 限流是在系统耗尽资源前主动拒绝一部分流量。 这会降低眼前的成功率,却保住剩余请求的响应能力。
项目按客户端维护一秒窗口,每个窗口允许 12 个请求。它先更新当前客户端的计数和剩余额度,只有超过阈值时才返回 429,因此正常请求也能从响应头看到剩余配额:
const rateBuckets = new Map();
const rateWindowMs = 1000;
const rateLimit = 12;
function applyRateLimit(req, res) {
const key = req.headers['x-client-id'] || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const current
为了验证限制确实发生在入口,可以在同一秒内连续发送 13 个同客户端请求。前 12 个会被转发,第 13 个不再访问库存服务,而是在网关处得到下面的响应头与问题详情:
HTTP/1.1 429 Too Many Requests
content-type: application/problem+json; charset=utf-8
x-ratelimit-limit: 12
x-ratelimit-remaining: 0
retry-after: 1
x-trace-id: trace-rate-013{
"type": "https://demo.local/problems/rate-limit-exceeded",
"title": "请求过于频繁",
"status": 429,
"detail": "每个客户端每秒最多发送 12 个请求。",
"instance": "/api/inventory/keyboard-001",
"code": "RATE_LIMIT_EXCEEDED",
"traceId": "trace-rate-013"
}429 明确表示调用方发送得太快。
Retry-After: 1 告诉客户端至少等待一秒再试,而不是立刻在失败循环里重放请求。客户端如果忽略这个提示并立即重发,只会继续消耗自己的额度,还可能把入口推向更高负载,因此调用端也需要配合退避。
按 IP 限流很简单,但公司网络出口后的许多用户可能共享一个 IP。
按用户限流更公平,却要求认证先完成。
按租户、API 密钥和资源分别限流,可以保护昂贵接口,但规则也会更复杂。
还要防止客户端直接伪造 X-Client-Id。
教学项目允许这个头,是为了稳定复现实验;生产系统应从可信令牌、网关签发信息或受保护连接中获得限流身份。
固定窗口在窗口交界处可能允许短时间突发。 令牌桶能允许受控突发,并限制长期平均速率。 滑动窗口更平滑,但状态维护成本更高。 选择算法前应先定义要保护的资源和允许的突发量,再决定状态精度是否值得额外成本;只比较算法名称得不出可执行配置。
当前 Map 只属于一个网关进程。
如果部署三个网关实例,同一客户端轮流命中三个实例,就可能得到三份独立额度。
需要全局配额时,可以使用共享状态或由专门限流服务协调。
如果目标只是保护每个实例自身,局部限流也有价值。
两者保护的对象不同,不能只看“有没有限流”四个字。
现在回到最初的问题:库存服务挂了,订单入口该怎么办? 第一步不是重试,而是给等待设上限。 没有超时的请求会一直占着连接和内存。 当故障请求越来越多,入口最终会被拖成新的故障点,原本局部的库存故障也会扩散到所有经过网关的请求。
教学项目给网关到上游的调用设置了 240ms 超时。这个数字是为了让课程故障迅速出现,不是可复制到任意生产系统的固定答案;真正的时限应从端到端响应预算倒推:
response = await requestJson(
`${target.baseUrl}${internalPath}${url.search}`,
{
method: req.method,
timeoutMs: 240,
headers,
body,
}
);上游来不及回应时,网关返回 504 UPSTREAM_TIMEOUT。
如果根本无法建立上游连接,则返回 502 UPSTREAM_UNAVAILABLE。
如果服务当前被熔断保护,入口返回 503 CIRCUIT_OPEN。
这些错误都表示请求没有正常完成,但含义不同,客户端的重试条件与值班人员的排查方向也应随之不同。
504 不等于上游没有执行。
请求可能还在网络上,也可能已经完成,只是响应丢失或超过网关等待时间。
对查询来说,再查一次通常不会改变状态。
对创建订单、扣减库存和扣款来说,盲目重试可能把同一个意图执行两次。
所以超时后的真正状态应该被标记为“未知”,而不是武断地标记为“失败”。
这也是为什么项目在订单服务调用支付时带上幂等键。调用方用稳定的订单标识表达“仍然是同一次支付意图”,支付服务再用这个键查找并重放第一次结果;即使首次响应丢失,也不会创建第二笔扣款:
headers: {
'x-trace-id': traceId,
'idempotency-key': `payment:${order.id}`,
}同一个订单的重试会映射到同一笔支付。 幂等由真正掌握支付状态的服务兑现,不是网关加一个头就自动成立。只有下游保存了幂等键与原结果的对应关系,重复请求才不会产生第二笔业务事实;网关只能完整转发这个意图,不能代替支付服务记账。

第一,失败是否可能是短暂的? 参数错误、权限不足和明确的库存不足不会因为立刻再发一次就变好。 连接重置、个别实例退出或短暂过载才可能通过有限重试恢复。 第二,操作是否幂等?这项判断必须来自接口契约,不能只根据 HTTP 方法名称想当然。
GET 通常可以安全重试;创建、扣款等有副作用的请求必须先有可靠幂等协议。
第三,总时限是否还有余量?
每次尝试都有独立超时还不够,所有尝试必须受一个端到端截止时间约束。
如果用户最多愿意等待 500ms,就不能先等 400ms,再完整重试 400ms;剩余预算不足时应直接结束,而不是制造一个注定超时的重试。
第四,重试流量是否受预算限制? 故障时每层都重试三次,网关、订单服务和客户端叠加后,会把一笔请求放大成很多次下游调用。 重试本来想提高成功率,最后却可能成为压垮故障服务的额外流量。 因此重试要有次数、退避、随机抖动和总量预算,还应监控重试请求占整体流量的比例。
演示网关没有自动重试。 这是一个有意保持清晰的边界:支付超时的重试放在订单服务里,因为订单服务知道支付幂等键和业务结果。由最懂业务结果的一层决定是否重试,比让所有中间层看见超时就重复转发更安全,也更容易统一约束总尝试次数。
熔断器记录一段时间内的失败。 当失败达到阈值,它会打开,后续请求不再访问上游,而是快速返回可识别的降级结果。这并不会修复故障,却能阻止新请求不断排队,让上游获得恢复所需的时间和资源,并避免连接池被等待请求耗尽。

完整的熔断器通常有三个状态,状态转换同时约束正常流量、快速失败和恢复探测。少了半开阶段,冷却结束后的全部请求可能会同时涌回刚恢复的服务,使它在尚未稳定时再次过载:
项目用两次连续失败和 800ms 冷却窗口演示这条链路。计数只针对订单上游,成功会清零连续失败;达到阈值时,则记录一个未来的重新尝试时间,在窗口内阻止继续转发:
const breaker = { failures: 0, openUntil: 0 };
function breakerAfterCall(target, failed) {
if (target.name !== 'order') return;
if (!failed) {
breaker.failures = 0;
return;
}
breaker.failures += 1;
if (breaker.failures >= 2
连续两次让订单服务延迟 350ms,都会超过网关的 240ms 等待上限,因此入口先得到两次 504。第二次响应仍表示“这次没等到上游答案”,还不是熔断器主动拒绝,响应体如下:
{
"title": "上游服务超时",
"status": 504,
"detail": "网关等待 order 服务超时。",
"instance": "/api/orders",
"code": "UPSTREAM_TIMEOUT",
"traceId": "trace-breaker-2"
}连续失败已经达到阈值后,第三次请求不再等待上游,立即得到熔断响应。此时 Retry-After 给出退避提示,问题详情则让客户端能稳定识别 CIRCUIT_OPEN,并与普通上游超时区分开来:
HTTP/1.1 503 Service Unavailable
content-type: application/problem+json; charset=utf-8
retry-after: 1
x-trace-id: trace-breaker-3{
"type": "https://demo.local/problems/circuit-open",
"title": "订单服务暂时降级",
"status": 503,
"detail": "网关检测到连续失败,暂停访问订单服务。",
"instance": "/api/orders",
"code": "CIRCUIT_OPEN",
"traceId": "trace-breaker-3"
}这次失败来得更快,却是有意为之。 入口不再让新请求堆到已经变慢的订单服务,订单服务获得恢复空间,调用方也拿到可以退避的明确信号。对用户来说,快速失败仍然是不完整能力;对整个系统来说,它比所有请求一起超时更可控,也更容易通过错误率告警发现。
这份小型代码没有单独存一个 HALF_OPEN 枚举。
冷却窗口过期后的第一个请求承担探测作用,成功就清除失败计数,失败又会重新累计。
它表达了半开的基本意图,但没有限制多个并发请求一起穿过窗口。
生产实现通常会显式维护半开态,只放少量探测,并按滚动窗口统计错误率、最小样本数和慢请求。
熔断状态也只存在当前网关进程中。 多个网关实例可能对同一上游得出略有差异的判断,这是分布式局部状态的自然结果。设计告警和恢复流程时,不能把单个入口实例看到的熔断状态误当成整个集群的唯一结论,还要汇总各入口的失败与恢复情况。
这个项目的外部订单路径先到网关,再到订单服务,最后由订单服务调用库存服务。 因此网关直接熔断的是它眼中的订单上游,而不是订单内部每一个依赖。 库存故障导致订单服务连续失败或变慢后,网关会保护整条订单入口。 更细的库存熔断与补偿仍应由订单服务负责,因为它知道这笔订单走到了哪一步。
降级不是统一返回一份默认成功,而是按业务语义保留仍然可信的能力。对于库存故障,可以缓存只读信息、收缩非核心功能,或明确拒绝写入;下面这些选择不会伪造库存已经预留:
503,提示客户端退避,而不是持续旋转等待。不应该做的事也很明确:库存无法确认时,不能伪造预留成功;支付结果未知时,不能直接再扣一次;写请求失败时,不能用旧缓存假装业务已经完成。这些做法看似让接口“可用”,实际只是把明确故障改造成更难发现的业务不一致,并误导后续补偿流程。
熔断不是“出错就返回一份成功缓存”。对库存预留、扣款和取消订单这类写操作,错误的成功响应比明确失败更危险,因为它会制造无法自动收敛的业务事实。
网关可以替客户端把多个请求合成一个。 例如订单详情页需要订单主体、实时库存和支付摘要,入口可以并行访问三个服务,再返回一份页面所需结构。 客户端请求数少了,也不需要了解三个内部接口。 但聚合不是免费的,入口自身也多了一份编排状态与失败处理责任。
先只看成功路径的时间账单:假设订单详情页并行请求三个服务,它们的处理时间如下。并行能避免三段耗时直接相加,却不能让聚合响应早于最慢的必要分支:
订单详情 45ms
库存摘要 180ms
支付摘要 70ms即使并行执行,聚合响应也要等大约 180ms,还要加上网关自身处理时间。
只要任何一个分支失败,网关还必须回答:整体失败,还是返回部分数据?
如果返回部分数据,客户端如何知道哪个字段缺失、数据是否可用?
如果整体失败,一个非核心的库存提示就可能拖垮整个订单详情。
这些是页面组合规则,往往比通用流量网关更接近 BFF 或专门的组合服务。
一开始,网关只是并行请求并拼接字段。 后来需求变成“已支付订单才显示物流”“库存少于三件要改变按钮”“企业客户还要计算专属额度”。 这时网关已经不再做协议层组合,而是在理解订单、库存和客户业务,发布节奏也会被这些领域规则重新绑在一起。
如果继续堆下去,它会变成第二个单体:所有团队都改同一个入口,所有发布都互相等待,一处规则错误影响全部服务。 判断边界可以问一句:这段逻辑只是转换传输形态,还是在决定业务结果? 前者可以放在网关,后者通常应该回到拥有业务数据的服务或独立组合层。
外部客户端可能使用 HTTP/JSON,内部服务可能使用 gRPC。 网关可以把 JSON 字段映射为内部消息,再把内部结果转换回来。 它也可以终止 TLS、处理 WebSocket 升级、压缩响应或统一内容类型。 协议适配让客户端不必跟随内部技术变化,但转换层必须保留原协议中的错误和取消语义。
可它会带来字段映射、错误语义和超时传播的问题。 内部的“截止时间已超过”应该映射成哪个 HTTP 状态? 流式响应被转成一次性 JSON 后,内存占用会怎样变化? 客户端断开时,内部调用能否一起取消? 只转换语法而不保留语义,会得到一个表面能通、故障时却说不清的接口。
单体应用里,一次请求通常落在一个进程的日志里。 拆分后,请求可能从网关进入订单服务,再经过库存和支付。 用户只说“下单卡住了”,你面对的却是四个进程和多段网络。 这时日志、指标和链路追踪不是锦上添花,而是让系统能被排障的最低条件。

入口收到请求后,先读取合法的 X-Trace-Id,没有就生成一个。
网关转发时继续携带它,订单服务调用库存和支付时也继续传播。
教学场景用 trace-demo-2026 发起一笔订单后,可以从四个进程找回同一条链:
inventory-service POST /reservations 201
payment-service POST /payments 201
order-service POST /orders 201
api-gateway POST /api/orders 201这解决了“哪些日志属于同一次业务请求”的问题。 课程项目传播的是一个便于阅读的关联 ID。 生产系统通常使用标准追踪上下文,除了整条链的 trace ID,还会为每一段工作建立 span,并记录父子关系、耗时和状态。 只生成 ID 不传播,链路会在服务边界断掉;只传播不记录,排障时又没有数据可查。
项目每次请求都会输出一条 JSON 日志,字段在四个服务中保持一致。与自由文本相比,固定字段让采集系统可以可靠过滤同一追踪标识,并按状态与耗时聚合;字段名一致也减少了跨服务查询时的临时转换:
{
"time": "2026-08-18T03:00:00.000Z",
"level": "info",
"service": "inventory-service",
"traceId": "trace-demo-2026",
"method": "POST",
"path": "/reservations",
"status": 201,
"durationMs": 2
}字段固定后,日志系统可以按 traceId、服务、状态或路径筛选,而不是依赖脆弱的文本搜索。
日志里不应记录 Bearer 令牌、密码、完整银行卡信息或未经处理的个人数据。
可观测性不能以泄露凭据为代价,脱敏规则也应当和日志字段一起接受测试。
单条日志能说明一个请求失败,却不告诉你这是偶发还是普遍现象。网关因此同时记录请求总数、上游失败数、限流次数和熔断状态;这些累计量虽然简化,却足以展示从单次事件转向整体趋势的观察方式,也能验证保护机制是否真的触发:
{
"requests": 18,
"upstreamFailures": 2,
"rateLimited": 1,
"circuit": {
"state": "OPEN",
"consecutiveFailures": 2
}
}生产监控还应按路由和上游观察请求率、错误率与延迟分布。 平均延迟可能看起来正常,而最慢的一小部分请求已经严重超时,所以需要关注高分位延迟。 指标标签也不能无限细。 把订单 ID、用户 ID 直接当标签,会制造海量时间序列。 这些高基数信息更适合放进日志或追踪。
先从指标看到订单入口的 504 比例上升,再沿一条失败 trace 找到耗时最长的库存调用,最后用同一个追踪标识查看库存服务的结构化日志。
这是一条常见排障路径。
指标负责发现范围,追踪负责定位路径,日志负责解释具体事件。
只有访问日志而没有追踪,跨服务调用很难拼接;只有追踪而没有指标,又很难判断影响面。
网关正处在外部请求入口,适合建立这些信号的第一段,但每个下游仍要继续传播和记录。
网关集中处理横切能力很方便,也因此特别容易不断长大。 今天加认证,明天加订单字段计算,后天再把库存补偿搬进来。 最后所有业务都经过一个巨大代码库,任何团队的小改动都要发布同一个入口。 原本拆服务是为了隔离变化,结果变化又在网关重新汇合。
这条边界并非永远不变。 但每次往网关加逻辑,都应问清所有权、发布节奏和故障半径。 如果逻辑需要读取某个服务的数据库才能做决定,它大概率已经越过通用网关的职责;继续放进入口会让部署边界与数据所有权相互冲突。
只有一个网关进程时,它会成为新的单点。 生产部署通常需要多个入口实例、上层负载均衡、无状态配置或可控的共享状态,以及独立的容量与故障演练。 配置发布也要能回滚。 一条错误的全局路由或认证规则,影响面可能比一个业务服务故障更大。 网关健康不应只看进程是否在运行,还要观察配置是否加载成功、上游端点是否可用、事件循环或线程池是否饱和。
一个健康的网关应该让内部服务更容易独立变化,同时让故障更早、更清楚地暴露。如果每个业务需求都必须修改网关,它提供的就不再是边界,而是新的耦合中心。
架构图上写“支持熔断”和“支持限流”很容易。 真正有用的是把触发条件、响应语义和恢复方式变成自动测试。 这套教学项目用四个独立服务进程完成 12 项集成检查,其中本节直接涉及:
✓ 聚合健康检查会确认三个下游服务
✓ 未携带令牌时返回统一 401 问题详情
✓ 两次超时后网关打开熔断器
✓ 限流超额时返回 429 和 Retry-After
✓ 同一 trace ID 会穿过网关、订单、库存和支付服务
12/12 项测试通过。这些断言不是写在文档里的承诺,你可以进入项目目录重复执行。测试脚本会自行选择独立端口并管理服务进程,不需要先手工启动网关,也不会依赖开发者当前打开的服务:
cd restful-api-microservices-demo
npm test测试会使用独立端口启动四个服务,并在结束时关闭全部子进程。 它不会写数据库,每次运行都从同一份内存数据开始,因此重复验证不会依赖上一次测试遗留的订单或熔断状态。
401、403、429、502、503、504 的语义是否能被客户端区分?本节得到的结论可以压缩成一句话:库存服务挂了,入口不该假装一切正常,也不该让每个客户端各自猜测。 它要快速识别、限制流量、控制等待、传播上下文,并把业务无法确认的事实诚实返回。 下一节会把这些行为继续变成测试与安全边界:不只证明接口“能跑”,还要证明故障时不会悄悄走向另一个结果。