用户找到你时,通常不会说:“支付服务的 P99 延迟升高了。”他只会说一句:“订单一直转圈。”这句话几乎没有技术细节,却已经足够让一支微服务团队忙起来。前端看到的是一个按钮,网关看到的是一条 HTTP 请求,订单服务看到的是一次编排,库存服务看到的是一笔预留,支付服务看到的是一笔扣款;任何一段变慢,用户看到的都只是同一个转圈。
如果这是单体应用,你还能先翻一份日志,顺着一个进程里的调用栈往下找。服务拆开后,日志也跟着散开了:网关说自己等了 22 毫秒,订单服务说自己处理了 19 毫秒,库存和支付各自说已经成功。没有一个共同标识时,这四句话甚至不能证明它们属于同一次请求。可观测性因此不是上线之后再补的“运维功能”,而是服务拆分时必须一起补上的基础能力,否则我们只是把一段能看懂的本地调用换成了四个进程各说各话。
本章继续沿用订单、库存、支付这条链路。前面我们已经处理了超时、重试、幂等与补偿,知道“调用超时”不等于“业务失败”。现在问题又向前走了一步:当请求穿过四个服务,我们怎样知道它停在哪里;当 API 被多个客户端长期依赖,我们又怎样让它变化而不把旧客户端突然推倒。

排查“转圈”最缺的不是更多日志,而是把同一次请求产生的记录找回来。一个实用起点是:请求第一次进入系统时生成 trace ID,后续每次跨服务调用都把它传下去。它像贴在行李上的号码,虽然行李会经过不同传送带,每个环节只处理自己眼前的一段,最后仍能凭同一个号码还原整条路线。
教学项目把入口规则放在公共 HTTP 处理层:先检查 x-trace-id,调用方提供的值合法就继续使用,没有提供就生成新的 UUID。这样每条请求在进入业务路由前就已经有了稳定身份,后面的服务无需重复判断:
function traceIdFrom(req) {
const supplied = req.headers['x-trace-id'];
return typeof supplied === 'string' && /^[a-zA-Z0-9._-]{1,80}$/.test(supplied)
? supplied
: randomUUID();
}入口生成标识还不够,跨进程时必须继续传递。网关转发请求时,会把同一个值放进发往下游的请求头,让订单服务接着使用入口创建的身份:
const headers = { 'x-trace-id': traceId };
const response = await requestJson(
`${target.baseUrl}${internalPath}${url.search}`,
{
method: req.method,
timeoutMs: 240,
headers,
body,
},
);订单服务调用库存和支付时也执行同样的传播,标识便不会停在网关,而会跟着请求跨过进程边界。这里首先要守住“入口生成、沿途传播”的规则,不能让每个服务收到请求后各自生成新号码,否则四个号码仍然拼不成一条链。响应也应返回追踪标识;用户报障时如果能提供 x-trace-id,排查就能从一个精确号码开始,而不是从“下午三点左右好像很慢”开始。
追踪标识只用来关联,不能承载秘密。用户手机号、访问令牌或订单详情都不该塞进追踪头,因为它会经过代理、网关、服务与日志系统,传播范围比一段业务代码大得多。教学项目使用自定义 x-trace-id 以便看清传播过程,真实系统通常使用标准化的追踪上下文,其中还会携带当前 span 标识和采样信息,让下游知道“我属于哪条链,也是谁的子步骤”。
只有 trace ID,不等于已经拥有完整的分布式追踪。trace ID 能把记录归成一组;要画出可靠的父子关系、开始时间、结束时间和并行关系,还需要为每段操作创建 span,并正确传播上下文。
有了共同号码,下一步才是决定每个服务究竟记录什么。我们先看最简单的对比:下面两条日志都在描述失败,但它们能回答的问题完全不同。
订单失败了{
"time": "2026-08-18T04:02:40.566Z",
"level": "error",
"service": "api-gateway",
"traceId": "trace-demo-2026",
"method": "POST",
"path": "/api/orders",
"status": 504,
"durationMs": 242
}第一条对写日志的人很省事,对排障的人几乎没用:你不知道是谁失败、哪条请求失败、失败用了多久,也很难稳定统计“过去五分钟有多少个 504”。第二条把信息拆成字段,日志平台可以按 service 分组、按 status 过滤、按 durationMs 排序,再用 traceId 找出同一次调用留下的其他记录。人仍然能看懂这些内容,机器也终于不必靠猜句子含义来分析。

教学项目在每个请求结束时统一输出这些字段,因而四个服务能使用相同的检索方式。公共处理层还会根据响应状态设置日志级别,避免每个路由各写一套格式:
const entry = {
time: new Date().toISOString(),
level: status >= 500 ? 'error' : 'info',
service,
traceId,
method: req.method,
path: new URL(req.url, 'http://localhost').pathname,
status,
durationMs: Date.now() - started,
};
process.stdout.write(`${JSON.这是一组够用的起点,生产环境还可以按问题增加部署版本、运行环境、实例标识、规范化路由、错误码、上游服务名与重试次数。增加字段时要先问它是否能帮助筛选、聚合或定位,不能因为采集方便就无限扩张。字段定义最好由公共库和团队约定维护,否则同一个“耗时”在三个服务里可能出现三种名字。
高基数字段尤其要谨慎。例如,route=/orders/{orderId} 只有有限取值,适合放进指标标签;真实 orderId 可能产生数百万条时间序列,会迅速推高存储和查询成本。订单号更适合留在受控日志或 span 属性里按需查询,不应直接成为所有指标的标签。
日志还要主动删掉不该出现的内容。访问令牌、密码、银行卡信息、完整请求体不应因为“以后可能有用”就原样记录,可观测数据本身也是需要权限、保留周期和脱敏规则的数据系统。否则原本用于排障的平台,会变成新的敏感信息泄露入口。
结构化日志的目标不是把整个业务对象复制一遍。保留定位问题所需的最小上下文,并让字段名在所有服务中保持一致,通常比记录一大段无法稳定查询的对象更有用。
现在把前面的规则放进一次真实调用。我们给创建订单的请求明确指定 trace-demo-2026,这样响应与四个进程留下的记录都能按同一号码核对:
curl -i http://127.0.0.1:43100/api/orders \
-H 'Authorization: Bearer demo-admin-token' \
-H 'Idempotency-Key: trace-demo-order-2026' \
-H 'X-Trace-Id: trace-demo-2026' \
-H 'Content-Type: application/json' \
-d '{"productId":"keyboard-001","quantity":1,"amount":199}'网关返回 201 Created,同时把追踪标识、资源位置和当前 ETag 交给客户端。响应体中的订单、库存预留与支付标识则说明这次编排已经走完:
HTTP/1.1 201 Created
x-trace-id: trace-demo-2026
etag: "v1"
location: /api/orders/ord-0001
{
"id": "ord-0001",
"status": "COMPLETED",
"reservationId": "res-0001",
"paymentId": "pay-0001"
}接着按 trace-demo-2026 筛选四个进程的日志,就能把原本散落的记录收回一处。为便于比较,下面只保留服务、方法、路径、状态和耗时字段:
inventory-service POST /reservations 201 durationMs=0
payment-service POST /payments 201 durationMs=1
order-service POST /orders 201 durationMs=19
api-gateway POST /api/orders 201 durationMs=22这四行已经回答了不少问题:库存预留和支付都完成,订单服务最终返回 201,网关向客户端返回的也是 201。四条记录拥有同一个 trace ID,因此我们知道它们属于同一笔调用,而不是四个时间恰好接近的请求。完成顺序也符合编排关系,库存和支付先结束,订单服务拿到结果后结束,网关最后把响应交还给客户端。
不过,这组日志仍然不能严谨地画出每一段父子关系。durationMs=19 表示订单服务从收到请求到发完响应的总时间,其中既包括下游调用,也包括自身处理;把 0、1、19、22 直接相加会重复计算嵌套时间。我们已经找回了同一次请求的记录,下一步还要用 span 还原它们之间的因果和时间关系。
一个 span 表示一段有明确开始和结束的工作,并且知道自己属于哪条 trace、由哪一个父步骤触发。把订单链路展开后,可以得到下面这棵操作树:
POST /api/orders 网关服务端 span
└─ POST /orders 网关调用订单的客户端 span
└─ create-order 订单业务 span
├─ POST /reservations 调用库存的客户端 span
└─ POST /payments 调用支付的客户端 span每个 span 有自己的 span ID,同时与整条链共享同一个 trace ID;子 span 再记录父 span ID,后台就能还原树状关系。span 本身至少包含操作名、开始时间、结束时间、状态和少量稳定属性。HTTP 调用通常还会记录方法、规范化路由、响应状态码、服务名和对端信息,异常则可以记成 span 事件并把状态标为错误。
落地时可以使用 OpenTelemetry 统一采集 trace、指标和日志。它解决的是埋点接口、上下文传播、数据处理与导出,不等于某一款固定的可视化产品。应用可以把数据交给 Collector,再由 Collector 进行批处理、采样、属性过滤和后端路由。
自动埋点通常能先覆盖 HTTP 客户端、服务端和数据库调用,让团队快速看到跨进程骨架。业务内部仍要补手工 span,例如“创建库存预留”“确认支付结果”“执行补偿”。否则调用链只能告诉你订单服务内部用了 1.8 秒,却无法说明时间花在业务的哪一步。
上下文传播应放在统一客户端和服务端中间件里。服务端收到请求时提取上游上下文,创建服务端 span;发起下游请求前创建客户端 span,再把当前上下文注入请求头。把这件事散落在每个业务函数里,很容易有一条分支忘记传递,导致 trace 在中途断开。
日志也应带上当前 trace ID 和 span ID。这样你可以从错误日志跳到对应 span,也可以从慢 span 回看当时的业务错误码。只靠时间戳和服务名猜关联,在并发高时很容易把两位用户的请求混在一起。

瀑布图的阅读顺序很直观:最上方根 span 覆盖用户等待的总时长,向下每一行是更具体的一段工作。横向起点表示何时开始,条带长度表示花了多久;两个条带重叠,说明它们可能并行,一个条带完全包住另一个,则说明调用方在等待子步骤。这样看图时,我们关注的是时间归属与因果关系,不是机械相加每一行的数字。
假设用户等待 2.4 秒,其中支付 span 占了 2.1 秒,库存只用了 30 毫秒,此时继续优化库存查询没有意义,应该先查支付服务。如果支付 span 本身只有 50 毫秒,但订单服务在支付结束后还停了 2 秒,就要检查订单服务自己的锁、事件循环、数据库提交或重试退避。调用链的价值不只是告诉你“慢了”,更是把等待时间归到具体边界。
异步流程要多想一步。消息生产者和消费者不一定有同步嵌套关系,消费者可能在几分钟后才处理消息。此时可以用上下文传播与 span link 表达因果关系,不能为了让图好看就伪造同步父子关系。
采样也不能只理解成“随机丢掉一些链路”。全量保留每条 trace 成本可能很高,但单纯固定比例采样又可能错过稀有错误。常见做法是保留所有错误与高延迟链路,再对大量正常请求抽样,同时保证指标不因 trace 抽样而失真。
日志告诉你某个服务说了什么,指标告诉你问题有多大,trace 告诉你一次请求经过了哪里。三者通过 trace ID、服务信息和时间关联起来,才是一套能从告警走到根因的可观测系统。
可观测数据很多,面板不能把所有数字一股脑堆上去。排查用户请求时,可以先用 RED 找受影响的服务,把视线固定在请求、错误和耗时三个用户可感知的方向:
请求速率(Rate):每秒有多少请求,按路由、方法和结果分类后是否突然变化。
错误(Errors):失败请求占比是多少,既包括 5xx,也包括业务定义的失败与超过目标时限的请求。
耗时(Duration):请求延迟如何分布,尤其是 P50、P95、P99 与超时比例。
RED 是从服务使用者的视角问问题。订单服务 CPU 不高,不代表用户体验正常;如果 30% 的请求都在超时,这就是明确的服务故障。当 RED 告诉你“支付服务延迟上升”,下一步再用 USE 检查承载它的资源:
使用率(Utilization):CPU 忙碌比例、内存占用、连接池已用比例等。
饱和度(Saturation):队列长度、等待线程、磁盘排队、连接池等待数等。
错误(Errors):磁盘错误、网络丢包、连接获取失败、容器重启等资源级故障。

这两个方法不是互相竞争的清单,而是一条有先后顺序的排障路径。先看 RED,确认用户影响出现在哪个服务、哪条路由、哪类结果上;再看 USE,判断资源是否已经忙满或开始排队。如果一上来就翻几十张 CPU 图,很容易在正常波动里迷路,也无法确认某次资源抖动是否真的伤到了用户。
教学项目的网关已经维护了三个简单计数,它们提供了最小的请求与故障概览。我们先看代码,再判断这组数据为什么还不足以支持多实例聚合和延迟分析:
const metrics = {
requests: 0,
upstreamFailures: 0,
rateLimited: 0,
};它可以回答请求总数、上游失败数与限流次数,却还不能计算每秒速率、错误比例和延迟分布。一个进程内的累计数字不是完整的时间序列,多实例时也不能直接代表整体。实际实现会把计数器和直方图暴露给指标系统,请求数按规范化路由、方法和状态类别聚合,持续时长进入直方图,资源侧再补 CPU、内存、连接池和队列指标。
标签要克制。status_class="5xx"、route="/orders/{orderId}" 很适合聚合;真实 URL、trace ID、订单 ID 不适合成为指标标签。需要跳到单次请求时,可以通过直方图样本关联到 trace,而不是让每条请求都变成一条独立时间序列。
假设 100 次请求中,99 次用了 100 毫秒,1 次用了 10 秒,平均值大约是 199 毫秒,看起来似乎还能接受。但那位等了 10 秒的用户不会因为“平均值不错”就觉得系统很快。延迟通常也不是对称分布:缓存命中请求很快,少量未命中请求要查数据库;普通订单很快,少量订单却要等待慢支付渠道。把这些体验揉成一个平均值,最难受的尾部用户就被藏起来了。
分位数把请求按快慢排列,更接近不同位置上的用户感受。实际面板通常至少放出下面三个观察点,分别对应典型请求、较慢请求和尾部请求:
P50 表示一半请求不超过这个时长,可看作典型体验。
P95 表示 95% 请求不超过这个时长,剩余 5% 更慢。
P99 表示 99% 请求不超过这个时长,用来观察最慢的那一小段尾部。

分位数也不能孤立阅读。P99 从 800 毫秒升到 1.2 秒,究竟影响多少请求,还要结合总请求量;错误响应可能返回得非常快,所以应把成功请求和失败请求的延迟分开看。直方图适合记录这类分布,它把观测值放进一组桶中,查询端可以跨实例聚合后再计算分位数。预先在每个实例计算好的分位数通常不能直接取平均,否则会得到没有统计意义的结果。
设计桶边界时,要围绕用户目标。假设订单接口目标是 95% 在 300 毫秒内完成,那么 250、300、400 毫秒附近需要足够分辨率。桶全挤在几十毫秒或几秒之外,即使数据很多,也回答不了是否达标。
延迟目标最终要落到用户可感知的条件,而不是只写“接口要快”。例如,我们可以同时约束正确响应比例和两档尾部延迟,让发布与告警都能判断是否真正达标:
在滚动 28 天窗口内:
99.9% 的有效创建订单请求得到正确响应;
其中 95% 在 300 毫秒内完成,99% 在 1 秒内完成。“有效请求”要有清楚定义。客户端 JSON 写错导致的 400 通常不算服务不可用;服务内部异常导致的 500 应计入错误;支付明确拒绝产生的 422 是业务结果还是可用性错误,要由业务语义决定,不能只看状态码首位。
服务目标不能只写一句“尽量稳定”,它必须让团队判断现在还能不能继续承担发布风险。若可用性目标是 99.9%,允许未满足目标的比例就是 0.1%,这部分余量便是错误预算。它不是鼓励制造故障,也不是说预算没花完就可以随便上线,而是给产品变化和系统稳定提供一把共同的尺子。
预算消耗平稳时,团队可以正常发布功能、调整缓存或升级依赖。预算在短时间内快速燃烧时,应暂停高风险变更,先处理正在影响目标的故障。预算已经耗尽时,除紧急修复外继续堆新变化,等于拿用户体验做没有上限的实验。
错误预算要基于用户侧 SLI,而不是内部进程存活率。四个服务都显示“进程存活”,但网关持续返回超时,用户仍然不可用。反过来,一台实例重启但流量被其他实例接住,用户请求没有失败,就不该把整段时间都算作不可用。
预算还可以表达延迟。若目标规定 99% 请求小于 1 秒,那么超过 1 秒的有效请求也在消耗预算,即使它最后返回 200,从而防止“全部成功但每次都等十秒”被报表包装成健康。一个实用面板会同时显示剩余预算和燃烧速度:前者说明这个周期还有多少余量,后者说明按当前趋势多久会花光。
短窗口高速燃烧适合紧急告警,因为它通常表示突发严重故障。长窗口缓慢超标适合创建工单,因为它说明问题持续存在,却未必需要半夜叫醒人。
一套监控刚搭起来时,团队很容易为每个数字都设阈值:CPU 超过 70% 告警、内存超过 80% 告警、每出现一个 500 告警。结果通常是值班手机不停响,真正严重的通知反而被淹没。
一个需要立即呼叫人的告警,至少应满足下面三个条件。缺少其中任何一项,都应重新考虑它究竟适合呼叫、进入工单,还是只留在日志中:
用户正在受到明显影响,或按当前趋势马上会受到影响。
接到通知的人现在能做出具体动作。
等到工作时间再处理会造成更大损失。
不满足这些条件的问题,可以进入工单或留作分析,日志并不需要逐条触发通知,面板也不需要有人一直盯着。告警应尽量围绕服务目标和预算燃烧,而不是某台机器的一次瞬时波动。CPU 95% 但请求延迟和错误率都正常,可能只是资源利用得充分;CPU 40% 但连接池队列暴涨、P99 已越过超时线,则更值得处理。
为了减少噪声,需要同时调整触发窗口、聚合维度和通知内容。下面这些办法可以组合使用,各自处理毛刺、重复通知或上下文缺失,而不是只靠放宽某一个阈值:
使用持续窗口,过滤只出现几十秒的毛刺。
同时看快速和慢速燃烧,兼顾突发故障与长期退化。
按用户影响聚合重复症状,避免同一事故从二十个实例发二十条通知。
在告警中直接附上服务、路由、时间窗口、当前值、目标值和排查入口。
每次值班后删除无动作告警,或者把它降级为工单。
告警说明不能只写“错误率过高”。更有用的内容是:“创建订单接口过去 10 分钟错误预算以 14 倍速度燃烧,主要状态为 504,慢 trace 集中在订单到支付调用。”接手的人不必重新猜测告警含义,可以直接验证支付依赖与超时情况。
如果一条告警长期无人处理,它不是在保护系统,只是在训练团队忽略通知。告警数量不是覆盖率,能否驱动及时、明确的动作才是判断标准。
可观测性解决“现在发生了什么”,API 演进解决“改完以后谁会坏”,两者其实紧密相关。微服务把发布节奏分散给不同团队,订单服务升级时,网关、移动端、后台管理系统和数据任务可能仍在使用旧契约。服务端单元测试全部通过,只能说明订单服务自己的代码满足预期,不能证明这些消费者还能正常工作。
最常见的兼容性误判是:“我只是加了一个字段,所以肯定兼容。”添加响应字段通常对能忽略未知字段的客户端安全,但有些客户端使用严格反序列化,遇到未知字段就会失败。给请求体添加“可选”字段也不一定安全,如果服务端在字段缺失时改变旧行为,旧客户端仍会受到影响。因此,判断变更时要站在消费者完成原任务的角度,而不是只看服务端改了几行代码。
先看风险通常较低的变化,它们不会改变旧请求的语义,也不会要求旧客户端理解新内容。只要默认行为保持稳定并经过旧客户端验证,常见情形包括:
新增独立端点,且不改变既有端点行为。
响应增加真正可忽略的字段。
请求增加有稳定旧默认行为的可选字段。
枚举约定本就允许未知值,客户端也实现了兜底。
相反,有些变化会直接打破旧客户端的解析、校验或控制流,即使服务端只改了很少代码,影响也可能立刻出现。需要重点拦截的情形包括:
删除或重命名字段。
改变字段类型、单位或格式。
把可选字段改为必填。
收紧校验范围,使旧请求被拒绝。
改变状态码、错误码或默认排序的语义。
给封闭枚举增加值,而旧客户端没有未知分支。
这份清单的目的不是让人背规则,而是建立一条判断原则。所谓兼容,就是旧消费者在没有同步升级的情况下,仍能完成原来的任务,并得到与过去一致的业务语义。
为了兼容,团队常采用只增不删:保留旧字段,再增加新字段;保留旧端点,再增加新端点。这是很实用的演进手段,但它有边界。如果永远不清理,API 会同时保留三种用户状态、两种金额单位和四套分页格式,每个消费者都要猜哪套是当前推荐,服务端也要维护越来越多分支。到那时,原本用于争取迁移时间的兼容层反而会拖慢每一次变化。
更稳妥的做法是给弃用建立完整窗口,并让每个阶段都有可观察的完成条件。一次从替代方案发布到旧能力移除的过程可以这样展开,任何一步未完成都不应直接跳到删除:
发布替代方案
→ 标记旧能力将被弃用
→ 统计仍在使用的消费者
→ 给出迁移说明和截止时间
→ 持续提醒并协助迁移
→ 确认流量归零或获得明确豁免
→ 到达停止服务日期后移除弃用和停止服务不是同一天。弃用表示“不建议新增依赖,请开始迁移”,在窗口期内旧资源仍按原语义工作;停止服务才表示它将不再响应。为了让客户端与网关自动识别进度,响应中可以发送机器可读的弃用时间、停止服务时间,以及指向迁移说明的链接。
下面的响应头同时给出弃用日期、停止服务日期和迁移说明。三者分开表达,客户端才不会把“开始迁移”误解成“现在立刻不可用”:
Deprecation: @1798675200
Sunset: Wed, 30 Jun 2027 23:59:59 GMT
Link: </docs/migrations/orders-v2>; rel="deprecation"窗口长度没有适用于所有 API 的固定答案。内部服务有清晰所有者,可能几周就能迁完;公开 API、离线设备或合作方系统的发布周期更长,可能需要数月。决定窗口前,先用网关指标和访问日志找出真实消费者,而不是发一封邮件后假设大家都看见了。
版本号也不是兼容性的自动保险。把路径从 /v1/orders 换成 /v2/orders 只是把两个契约分开,仍然要维护迁移窗口、监控使用量和明确语义差异。版本越多,测试矩阵、文档和运行成本越高。
教学项目的 openapi.yaml 描述了订单创建、读取和修改,包含幂等键、ETag、请求体 Schema、响应 Schema 与问题详情。这份文件不该只是给人阅读的展示文档,还应成为变更检查、测试生成与发布决策的共同输入。

一个实用流程会把规范变化挡在合并与发布之前,而不是等旧客户端报错后再补救。可以把流水线组织成下面几个连续关卡,让结构检查、消费者验证与灰度观察逐层收紧风险:
修改 API 实现与 OpenAPI
↓
校验规范本身是否有效
↓
与已发布版本执行 OpenAPI diff
↓
识别删除字段、类型变化、必填变化等风险
↓
运行提供方验证与消费者契约测试
↓
在灰度环境观察错误率、延迟与旧版本流量
↓
允许发布,或要求版本化与弃用计划OpenAPI diff 擅长找结构变化。例如把 quantity 从整数改成字符串、删除 Problem.traceId、把 currency 从可选改成必填,都可以在代码合并前暴露。这类检查速度快、结果明确,很适合成为每次合并都必须通过的基础护栏。
但 diff 不能理解所有业务语义。金额单位从“元”改成“分”,Schema 可能仍然都是 number;默认排序从创建时间倒序改成金额升序,路径和字段也没有变化。即使工具看到 422 被改成 200 加错误字段,它也不能替团队判断消费者的重试与分支行为会怎样变化。
所以,机器差异检查是护栏,不是架构裁判。对于工具无法理解的变化,需要评审问题清单:旧消费者会怎样解释这个值?重试逻辑会不会改变?缓存键是否仍然成立?监控按状态码统计时会不会失真?
规范与实现必须一起验证。只检查 OpenAPI 文件,不能证明线上代码真的按它响应;只测实现,又容易漏掉消费者依赖的契约。两者缺一边,都会留下“文档正确但服务不对”或“服务能跑但消费者不知道怎么变”的空档。
集成测试会把多个服务一起启动,验证完整流程能否工作。契约测试关注的范围更窄,它记录消费者会发出什么请求,以及最低限度依赖提供方返回什么。这个范围差异很重要,因为契约测试保护的是跨团队承诺,不是重新验证提供方内部的全部业务逻辑。
例如,网关或前端创建订单时真正依赖的是一组具体交互,而不是订单服务的全部实现。把请求和最低响应写出来后,契约边界就会变得清楚:
{
"request": {
"method": "POST",
"path": "/orders",
"headers": {
"Idempotency-Key": "非空字符串"
},
"body": {
"productId": "keyboard-001",
"quantity": 1,
"amount": 199
}
},
"minimumResponse": {
"status": 201,
消费者侧先验证自己的客户端确实发出了这类请求,也能处理约定的响应。生成的交互契约再交给订单服务验证,确认当前提供方仍然满足消费者所需的最小部分。
这里故意写“最小部分”。如果契约把时间戳、字段顺序、每个非关键文案都固定死,测试会变得很脆。提供方只是增加一个无关字段,消费者并不受影响,流水线却到处失败,团队很快就会失去对契约测试的信任。
契约测试也不替代功能测试。它能证明订单服务会返回消费者需要的 id 和 status,不能证明扣款金额一定正确,也不能证明支付拒绝后库存已经恢复。后者仍需要服务自身测试和跨服务场景测试。
教学项目的 12 项测试同时覆盖了资源行为与服务协作,其中有几项直接描述跨边界事实。把它们单独列出来,就能看清它们与单函数测试的差别:
✓ 资源型 URL 可创建订单并返回 Location、ETag 和链接
✓ 重复的幂等键返回同一个订单
✓ 支付被拒后会释放已预留库存
✓ 同一 trace ID 会穿过网关、订单、库存和支付服务契约检查、提供方功能测试和端到端测试覆盖的是不同风险。把它们分层,才能既早发现接口破坏,又不过度依赖一套又慢又难定位的全链路测试。
当 API 变慢,第一反应常常是“加缓存”。缓存确实能减少重复计算和下游压力,但它也会引入新的状态,缓存里的表述可能和源数据不一致。订单读取接口演示了一种更克制的条件请求:客户端第一次读取时,同时拿到订单表述与对应的 ETag。
HTTP/1.1 200 OK
ETag: "v1"客户端再次读取同一资源时,不必假设本地副本仍然最新。它把刚才保存的版本标识放进条件请求头,请服务端只在资源变化时发送新表述:
If-None-Match: "v1"服务会把条件请求中的版本与当前资源版本比较。若资源没有变化,就可以只返回状态而不重复发送正文,客户端则继续使用本地已有表述:
HTTP/1.1 304 Not Modified304 不传完整响应体,节省带宽,也避免客户端重复解析。但服务端仍然收到了请求,仍需判断当前版本,所以它主要减少传输,不一定消除源站计算。
共享缓存要继续考虑 Cache-Control、认证信息、查询参数和 Vary。不同用户看到不同订单时,绝不能因为缓存键少了身份维度,就把甲的订单响应交给乙。带敏感信息的响应默认应采取更保守策略。
缓存命中率也不能单独成为成功指标。命中率从 80% 升到 95%,如果剩余 5% 的未命中请求全部把数据库打满,用户的 P99 仍然会恶化。更完整的观察包括命中与未命中的延迟分布、回源速率、失效风暴、缓存容量和过期策略。
性能回答“单个请求多快”,容量回答“当前资源能稳定承载多少请求”,两者共同决定系统在流量上升时何时开始排队和失败。这些边界不能只靠线上出事后猜,而要在发布前建立基线,再用线上趋势持续校正。

容量规划要先把“请求有多重”和“哪个资源先满”问清楚,不能只盯着一个吞吐数字。下面这组问题可以帮助团队把压测条件与线上限制对齐:
当前峰值请求速率是多少,增长趋势如何?
创建订单和读取订单的资源成本是否相同?
当支付延迟上升时,订单服务会积累多少等待中的请求?
连接池、线程池、事件循环、队列或文件描述符,哪个先到上限?
单个下游超时会占住上游资源多久?重试会把流量放大几倍?
吞吐量接近极限时,系统往往先出现排队,随后延迟尾部陡升,最后才是明确错误。只看 CPU 平均使用率,可能错过连接池已经用完、队列正在增长的事实。
容量基线需要用与真实流量相近的请求组合来测。全部使用缓存命中的 GET,无法代表包含库存预留和支付调用的 POST;只压单个服务,也看不到重试与超时如何把压力传给上游。
压测结果不应只写“每秒支持 5000 请求”。还要写清响应目标、错误率、数据规模、实例数量、缓存状态和下游条件。缺少这些条件,数字换个环境就失去意义。
扩容也不是万能答案。如果每个订单请求都会在数据库上争同一把锁,多加订单服务实例可能制造更多竞争;如果重试没有退避与上限,多加调用方只会更快压垮支付服务。
真正有用的容量告警关注距离边界还有多少余量。例如连接池使用率与等待队列一起上升,磁盘按当前增长速度四小时后会写满,或 P99 在流量增加前就开始抬头。这类信号比“资源已经 100%”更早给团队行动时间。
一次故障结束,不代表工作结束。恢复服务只处理了眼前影响,复盘还要回答为什么系统允许故障发生、为什么发现得晚、为什么恢复花了这么久。它关注的是系统条件和防线如何失效,不是找一个人承担所有解释。
复盘先按时间线固定事实,避免大家用事后记忆补出不同版本的故事。一次围绕“订单一直转圈”的记录可以这样写,每个时间点都对应可查证的指标、告警或处置动作:
14:02 支付服务 P99 开始上升
14:05 订单服务等待请求增加
14:07 网关出现 504,错误预算快速燃烧
14:09 值班人员收到告警
14:12 通过慢 trace 定位支付调用
14:18 降低非必要流量并停止发布
14:26 支付依赖恢复,订单成功率回升
14:40 错误预算燃烧恢复正常时间线把事实和推测分开。接下来再问:为什么 14:02 没有发现?是没有支付服务的延迟分位数,还是阈值等待太久?为什么订单服务积累了请求?是超时上限过长,还是并发隔离缺失?为什么用户重试造成更多压力?是前端没有退避,还是接口幂等策略没有覆盖这条路径?
分析只有落到行动项才会改变下一次结果,而且每个行动项都要能验收。针对这条故障链,可以形成下面这些具体改进,并为每项指定负责人、截止时间与验证方式:
为订单到支付的客户端 span 增加超时、重试次数和结果属性。
增加支付调用 P95/P99 与超时比例面板。
用多窗口错误预算燃烧替换单次 500 告警。
增加支付变慢时的容量测试,并验证并发隔离。
在 OpenAPI 和消费者契约中固定超时错误的状态码与问题详情结构。
为弃用端点增加消费者使用量面板。
“加强监控”“提高稳定性”“开发注意一点”都无法验收。没有负责人、截止时间和完成标准的行动项,通常会在下一次故障前被遗忘。
复盘还应检查哪些机制有效。trace ID 让定位节省了多少时间?熔断有没有限制故障扩散?告警是否指向了用户影响?保留有效防线,才能避免团队只围着失败部分打转。
现在回看“订单一直转圈”,我们已经不需要从几十张图里碰运气,而是有了一条可重复的处理路径。它从用户可见结果出发,逐步缩小到服务、调用和资源边界:
先从用户响应、客服记录或前端日志拿到 trace ID,确认发生时间、路由与用户可见结果。
查看订单接口的 RED 指标与错误预算,判断是单次异常、局部消费者问题,还是正在扩大的服务故障。
打开慢 trace,沿网关、订单、库存和支付的 span 找到错误或等待集中在哪个边界。
下钻对应服务的 USE 指标,确认是否有连接池排队、CPU 忙满、内存压力或资源错误。
这条排障路径也会暴露架构问题,因为慢与错经常集中在某些固定边界。如果几乎每一条订单 trace 都要同步穿过库存与支付,我们就该追问:订单服务承担的是清晰编排职责,还是把本应共同变化的模块勉强拆开了?这个问题不能凭一次故障下结论,却值得用长期数据继续验证。
共同发布是另一条证据。如果订单和支付每次都必须同步修改契约、同步灰度、同步回滚,它们可能没有获得真正的独立演进能力;如果支付服务为了回答订单页面查询,被迫暴露大量订单语义,当前边界也许只是按数据表切开,并没有按业务责任切开。调用依赖与变更依赖同时很强时,团队需要重新比较拆分收益和网络协作成本。
可观测性不会自动给出“正确边界”,却会把跨边界调用的频率、延迟、失败与共同变化暴露出来。这些证据比“微服务应该拆得更小”之类的口号更有用。
同样,API 可演进性也不是靠永远添加版本号实现。OpenAPI diff 告诉你结构变了,契约测试告诉你消费者依赖什么,弃用指标告诉你谁还没迁移,错误预算告诉你系统现在有没有继续变化的余量。它们共同把架构决定从一次性的会议意见,变成可以持续验证的假设。
拆分服务减少了进程内耦合,却增加了网络、状态和协作成本。可观测性让这些成本可见,兼容性与契约让变化受到约束。下一步要讨论的,不是怎样继续机械地拆,而是订单、库存与支付的边界为什么这样划,以及什么时候应该合、什么时候值得分。
可观测性最直接的用途是排障,但它留下的数据还有另一层价值:验证服务边界是否合理。trace 会告诉我们一次订单请求究竟跨过多少边界、在哪些边界反复等待;指标会告诉我们这些调用的频率、失败率和尾部延迟;发布记录与契约变更则会显示两个服务是否总要一起修改、一起上线。
如果订单与支付拥有不同的业务规则、扩容节奏和安全要求,拆开后可以各自演进,那么网络调用、契约维护和故障隔离的成本可能值得承担。反过来,如果它们几乎每次需求都同步变化,每条订单链路都必须实时等待支付,而且任何一方发布都要拉着另一方联调,当前拆分也许没有换来真正的独立性。
这类问题没有一条只看代码目录就能得到的标准答案。我们需要把调用链、错误预算、共同变更频率、数据所有权和团队职责放在一起判断。边界过粗会把不同责任重新揉成一个大单体,边界过细又会让本地函数调用变成大量不可靠的网络协作。
下一章就从这组证据出发,重新审视订单、库存与支付的职责:订单和支付到底该不该拆成两个服务,哪些数据应由谁拥有,又该怎样记录一项没有唯一答案的架构决定。
恢复服务后完成复盘,把结论变成可验收的指标、告警、契约、容量或代码行动项。