贯通案例:观测、部署与演进
U79 让一笔订单正确穿过配置、身份与事务;U80 让"订单已发生"这件事可靠到达下游。但一个能跑的链路还不是一个能被运营的系统:出问题时你从哪里看见、发布时流量怎么走、停机时在途工作落向何处、数据库长大之后怎么迁移、备份能不能恢复出同一组不变量。本文是同一合成下单案例的最后一篇(PRD §8.2 后两个阶段),把 U79/U80 的同一笔订单接到指标、追踪、有限负载、代理、迁移与恢复上。
机制权威不在这里:遥测接入(Micrometer/OTel/Collector/PromQL)见 遥测接入(U64),部署与反代/停机见 部署与安全(U65),迁移/备份/回滚门禁见 交付门禁(U63),业务机制见 U79/U80 两篇。本文只回答组合问题:三个阶段拼在同一笔订单上,哪些结论成立、失败在哪里、恢复后拿什么验收。
入口不变:python3 labs/order-flow/run.py,28 个测试(U79 19 + U80 5 + U81 4)、36 条观察行、exit 0;本票新增证据在 labs/order-flow/evidence/u81-2026-10-31.json,U79/U80 原证据保留不动。版本沿用已验基线:JDK 21(Liberica 21.0.11+11)、Boot 4.0.9 / Framework 7.0.10 / Security 7.0.8 / MyBatis 3.5.19 / spring-kafka 4.0.8(Kafka 4.1.2 客户端)、MySQL 8.4.7;观测侧新增 Micrometer 1.16.8、micrometer-registry-prometheus、OTel SDK 1.55.0,以及 otelcol 0.164.0 与 Prometheus 3.15.1 官方发行版二进制(本机直接运行,不用 Docker;二进制复用 U64 缓存)。
一、第三阶段缺什么:从"正确"到"可运营"
U79/U80 的 24 个测试证明的是业务不变量:不超卖、不重复、意图与订单同事务、投递后效果一次。它们不回答四个运营问题:
- 看得见吗:一笔订单被受理/拒绝/投递,指标里有没有?一笔慢请求的 trace 在哪一跳慢?
- 扛得住形状吗:并发不是均匀到达的,连接池与线程池在 16 路并发下表现如何(这是负载形状,不是容量压测)?
- 发布与停机安全吗:库里有真实订单数据时能不能 expand 迁移?回滚二进制之后旧代码还能不能用?SIGTERM 时 relay 手里的活会不会丢?
- 恢复拿什么验收:备份恢复之后,凭什么说"和原来一样"?
第三阶段的做法不是加新业务,而是给同一业务加可观测性与运维面:pom 增加 Micrometer/OTel 依赖,OrderService 增加两个业务计数器,安全链显式放行 /actuator/prometheus,入口拉起真实的 Collector 与 Prometheus,新增一个测试类覆盖上述四个问题。
二、指标看见业务:计数器接在哪个分支上
OrderService 增加两个低基数计数器(lab_orders_accepted / lab_orders_rejected,标签只有 tenant),并接在业务裁决的分支上:
| 分支 | 计数器 | 语义 |
|---|---|---|
| 新建订单成功 | accepted +1 | 一次真实受理 |
| 幂等重放(duplicated) | accepted +1 | 重放也是成功受理(U79 答案 12 的语义) |
| 业务规则拒绝(422 quantity_above_limit) | rejected +1 | 服务层拒绝 |
| 条件扣减失败(409 insufficient_stock) | rejected +1 | 数据裁决拒绝 |
| 并发唯一冲突(409 duplicate_request) | rejected +1 | 唯一约束裁决 |
| Bean Validation 失败(400) | 不计数 | 这不是业务拒绝,是输入错误 |
最后一行是本票第一个实测教训:开发期把"买 999 件"当作拒绝样本,结果请求被 @Max(10) 挡在校验层(400),lab_orders_rejected 纹丝不动——校验拒绝与业务拒绝是两种事件,指标口径必须写清楚哪种进分母。改成"买 7 件(过校验、超过 maxItemsPerOrder=5)→ 422"后计数才成立。真实数据经 Collector 与 Prometheus 查询:
U81 businessMetrics=PASS accepted=4.0 rejected=1.0 httpP95=0.021711 via=collector三个数各自回答一个问题:accepted 是业务量(rate 后可算吞吐)、rejected 的比值是业务拒绝率(与 5xx 不同:4xx 业务码也可能代表用户行为,告警口径归 T26 的告警页)、httpP95 是用户感知的尾延迟(histogram_quantile 对聚合桶计算,不是客户端分位)。标签里没有 orderId——基数纪律(U64)在业务指标上同样成立。
三、追踪串起链路:traceId 从 HTTP 到安全链
应用接 spring-boot-starter-opentelemetry 后,OTLP HTTP exporter 把 span 推到 Collector 的 otlp receiver,再由 file exporter 落盘。真实导出内容(21 个 span、3 个请求):
U81 traceExport=PASS spans=21 serverSpans=3span 构成不是只有一条 http post /api/orders:每个请求还带着 authorize request、authenticate bearertoken、security filterchain before/after、authorize method 等 Security 观测 span。这是组合案例里容易忽略的一层:一个"慢请求"可能慢在安全链(JWT 解码、授权规则),只有指标(http_server_requests)时你只知道它慢,有 trace 才知道慢在过滤器还是控制器还是 SQL。
日志关联:排障的最后一步
追踪与日志的关联键是 traceId:Logback pattern 注入后,每条 order processed / order rejected 日志都带同一 traceId,指标异常 → 找到 trace → 落到具体日志行的定位顺序与 U64 的排障链一致,只是这次对象是真实订单而不是合成端点。
四、有限负载:负载形状不是容量压测
16 个线程同时下单同一 SKU(库存 16,各买 1 件,每个请求带独立 Idempotency-Key):
U81 limitedLoad=PASS threads=16 ok=16 conflict=0 p95=0.029954 poolActive=0.0 poolIdle=4.0四个观察各自的意义:
- 全部 200:16 件库存、16 个不同键,条件扣减应当全部成功——若出现 409 说明锁等待或池等待被误判成库存不足;
- p95:负载下的尾延迟形状(本机是毫秒级,不能外推到生产);
- poolActive=0 / poolIdle=4:请求结束后连接全部归还(
maximum-pool-size=4,在界内)——池泄漏会在这里暴露:如果有连接未归还,active 会停在非零; - 不变量:
available + orders = 16、orders = ok、conflict = 0。
开发期这里踩了一个真实陷阱:第一版用 IntStream.parallel() 做 16 路并发,公共 ForkJoinPool 的并行度等于 CPU 核数(本机 10),占满 pool 的任务在 latch.await() 等剩余 6 个任务——死锁,测试挂 900 秒超时。改成固定 16 线程池后通过。这个教训本身值得写进正文:并发测试的执行器就是被测量系统的一部分,用错了测的不是服务而是调度器。
另一处:第一版 placeOrder 没发 Idempotency-Key,服务端按 tenant+sku+quantity 生成自动键——16 个相同请求全部撞同一个键,只有 1 个真实下单、15 个幂等重放(U79 复核已提示"自动键只适合演示")。实测结果 available=15 orders=0,把断言写成了 16 才暴露。测试的请求身份必须显式,否则并发测试测的是幂等重放。
五、带数据的迁移:expand 之后旧代码还能跑
迁移场景不再是空库:SKU-MIG 下单 2 件、relay 派发完成后,对有真实订单的库执行 V3 expand 迁移(orders 增加可空列 note),登记迁移历史,再走备份与恢复:
U81 migrationBackupRestore=PASS v3Expand=true restored=true oldCodeStillWorks=true dumpBytes=15489四段验证各自回答一个问题:
- expand 不破坏旧代码:新增可空列,旧 SQL(不引用
note)正常执行——迁移后再次下单 200; - 备份可恢复:
mysqldump --single-transaction全库导出 →DROP TABLE orders/outbox/notification模拟数据丢失 →mysql < dump恢复; - 恢复后不变量:订单行存在、
outbox中该订单为 SENT、迁移列值migrated保留、outbox → orders的 anti-join 为 0(没有悬空意图); - 回滚二进制不回滚数据:恢复后运行的仍是"不认识 note 列"的旧代码路径(本测试的 SQL 未引用该列),下单成功。
这与 U63 的空库迁移门禁互补:U63 证明 Flyway 历史表/校验和/失败修复机制,这里证明在真实数据上行 expand 的边界——真正危险的是 contract 阶段(删旧列),它要求所有旧消费者退出后才能做,本实验未执行 contract,如实列为未运行。
六、停机与在途工作:relay 手里的活有归宿
U65 的优雅停机验证的是 HTTP 在途请求;这里验证的是后台 relay 的在途工作。受控窗口模拟"relay 领取后进程死亡":claimOne() 写入租约后不发送、不标记,模拟停机瞬间的状态:
U81 shutdownRelayTakeover=PASS claimedKept=true takeoverVersion=1 deliveries=1 effectsGuard=inbox三个观察:
- claimedKept:停机后该行保持
CLAIMED(不是丢失,也不是假 SENT); - takeoverVersion=1:租约到期后新 relay 以新令牌接管(版本递增),旧令牌的标记语句 CAS 不上——U80 已验证的令牌语义在停机窗口下同样成立;
- deliveries=1:最终 Kafka 里该订单只有一条事件;消费者侧 Inbox 唯一键仍是效果一次的兜底。
顺带的实测发现:测试里"租约未到期时 claimOne() 返回 null"的断言,在第一版因为前序用例遗留 PENDING 行而失败——"新 relay 不抢未到期租约"的断言必须先排空无关行。这提醒生产排障:relay 空闲时 claim 返回 null 不能证明没有积压,要查 outbox 的 PENDING/CLAIMED 计数和租约时间。
七、恢复验收:用不变量而不是行数
第三阶段的"验收对象"延续 U80 的思想但扩大范围:恢复后要同时满足业务不变量与遥测一致性:
| 对象 | 验收方式 | 本实验 |
|---|---|---|
| 订单/库存 | available + orders = 初始库存 | 16/16 并发用例断言 |
| Outbox ↔ 订单 | anti-join 为 0(无悬空意图) | 恢复后断言 |
| 投递 ↔ 效果 | SENT 均有对应 notification、效果均对应 Inbox | U80 五条配对检查保留 |
| 指标 ↔ 业务 | accepted 计数 = 成功受理数;rejected = 业务拒绝数 | 4/1 与请求脚本一致 |
| 追踪 | 每个请求有 server span,且安全链 span 在场 | 21 spans / 3 请求 |
单看"恢复后能查询"不够:悬空意图、重复效果、指标与实际业务量不符,都是"能跑但已经错了"的状态。不变量核验用 SQL anti-join 与 PromQL 各自回答,不互相替代——指标断流时以数据库对账为准(T26 的语义契约)。
八、证明边界:模型、受控窗口与真实组件的分层
| 类别 | 本票有什么 | 不能推出什么 |
|---|---|---|
| 真实组件 | 真实 Servlet/Security/MyBatis/MySQL 8.4.7、真实单节点 KRaft Kafka、真实 otelcol 0.164.0、真实 Prometheus 3.15.1、真实 OTLP 导出与 PromQL 查询 | 生产监控 HA、长期存储、多 broker 副本 |
| 受控窗口 | 停机=不发送不标记+SQL 改租约时间(非进程强杀);"进程死亡"是模拟 | OS 崩溃/断电、kill -9 下的连接状态 |
| 合成部分 | HS256 token、SKU/金额、负载的 16 线程规模 | 生产流量形状与容量 |
| 未运行 | Docker 镜像与容器化部署(用户未授权 daemon)、Nginx 代理链路(U65 已验,本票未重复)、contract 迁移阶段、Prometheus 告警路由、Tempo/Loki、容量压测 | 不能标为已通过 |
一个需要明确声明的组合事实:本票把"观测栈"作为测试进程外的真实二进制拉起(run.py 启动 otelcol 与 Prometheus,测试结束后 TERM 回收),应用以固定端口监听以便 Collector 抓取——这是教学为了可确定性做的取舍,生产由编排器与 Service 发现解决。开发期还踩到一个安全链交互:/actuator/prometheus 默认被本应用的 denyAll() 挡住,Collector 收到 401;显式放行该端点后指标才通——观测端点的授权面也是部署配置的一部分。
九、架构师变化条件
把单实例换成多实例:指标标签需要加 instance/zone 维度并用 sum by 聚合,trace 的 service.name 与采样策略要按副本统一;负载从 16 线程换成真实流量后,p95 与池 active 会给出容量信号,但容量结论必须用压测而不是本票的形状测试。把 expand 走到 contract(删 note 列):必须先确认所有旧二进制与外部消费者退出,回滚策略从"切回旧 jar"变成"前向修复"(U63 迁移页的纪律)。把停机从受控窗口换成真实 SIGTERM:relay 的 shutdown hook 需要在途 claim 完成后才退出,宽限期与租约时长的关系(宽限期 ≥ 令租约过期的检查间隔)是新的对齐点。把 Collector 从 file exporter 换成 Tempo 后端:排障第三步从"读 JSON"变成"查 UI",保留期与采样策略都要重验。每个变化都重跑本页的五个对照(指标口径、trace 完整性、并发不变量、停机接管、恢复 anti-join),不能沿用旧结论。
闭卷验收
本页 14 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。