qinghuan
← Spring Cloud / 全部笔记

单元 113 / Spring Cloud

P1 · 重点提升L3 · 深入

可观测性与一致性:一次跨服务请求如何留下证据

把指标、日志和追踪对应到一次跨服务请求,再用持久业务记录确认订单是否完成,避免只看某段调用成功。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能关联一次请求在各服务中的记录,并为提交后发布失败设计可查询、可重试的事件记录。

学习理由、准备要求与知识点

为什么学它

追踪帮助定位故障,可靠事件发布帮助恢复状态;两者需要结合但不能互相替代。

先读这些内容

知识点清单

  • trace 与 span
  • 上下文传播
  • 采样
  • 指标与日志
  • 本地事务
  • 事件登记
  • Outbox
  • 补偿与对账

运行前先确认Spring Boot 3.5、Micrometer Context Propagation 1.1.3 与 OpenTelemetry 通用传播模型。Java 17 示例分析同一时钟域的嵌套时间区间,不等价于生产 Trace 的完整关键路径分析。

01先弄懂它是什么

一次订单请求可能经过网关、库存、支付和消息消费者,仅看某个进程的异常栈不够。指标帮助看失败了多少、耗时怎样;日志留下具体事件;追踪把一次跨服务操作的各段连起来。所谓可观测性,就是让这些信号帮助解释系统发生了什么。三者相互补充,单纯多打日志不一定更容易排错。

Trace 表示一组通过上下文关联的操作,Span 是其中有开始结束时间的工作片段。Trace ID 串起过程,Span ID 区分片段;普通调用可用父子关系,队列、批处理和重放有时更适合链接关系。订单号则长期代表业务对象,一个订单可能经历多次请求和多条 Trace,不能只靠某次追踪记录判断订单的最终状态。

数据一致性由事务边界、唯一约束、幂等协议和恢复流程保证,可观测性负责揭示这些机制是否按预期工作。看到支付 Span 成功不代表订单数据库已提交;没有查到某个 Span,也可能只是采样或导出失败。应把业务事实放在持久记录中,把追踪当成诊断证据,并明确每一种证据能够证明哪一步。

文中这几个词,先认一认

Span
一次有开始、结束时间和关联信息的工作片段,例如调用库存服务。
上下文传播
把请求关联信息按规则传到下一个调用、线程或消息处理中,使分散记录能够连起来。
基数 Cardinality
某个指标标签可能出现多少个不同值;把订单号等高数量值做标签会增加存储和查询成本。
业务一致性
相关数据变化始终遵守业务规则,并且失败后有明确办法恢复到有效状态。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

入口从受控传播头提取上下文或创建新的 Trace,下游调用前注入传播头,接收方提取后建立关联 Span。进程内跨线程也需要传递上下文,线程池复用意味着一个线程会先后处理很多用户。捕获时机、安装范围和退出恢复必须成对;否则既可能断链,也可能把上一个请求的关联信息附到下一个请求。

同步调用形成的父子时间范围可以帮助找出等待,但并行子调用会发生重叠。父耗时减去所有子耗时之和可能得到负数,因为同一段墙钟时间被重复计算。即使正确合并重叠时间,剩余部分也只表示没有被这些子区间覆盖的墙钟时间,不能直接叫CPU执行时间,还可能包含锁等待、调度或未埋点的IO。

订单与消息的一致性可采用本地事务同时保存订单和待发布事件,再由投递器发送,消费方以事件ID幂等处理。追踪关联帮助还原链路,而订单状态、Outbox重试记录与消费唯一约束才提供恢复依据。监控应包含待发布最老年龄、重试次数、消费积压和业务对账差异,不能只看HTTP响应码。

正在加载流程图…

追踪关联与业务事件身份并存;诊断系统不能承担事务提交证明。
查看流程图源码(Mermaid)
sequenceDiagram
 participant U as 用户请求
 participant O as 订单服务
 participant D as 本地数据库
 participant P as 事件投递器
 participant C as 消费服务
 U->>O: 请求及传播上下文
 O->>D: 同一事务写订单与Outbox
 D-->>O: 提交结果
 O-->>U: 真实业务状态
 P->>D: 读取待发送事件
 P->>C: 带事件ID及追踪关联投递
 C->>C: 持久去重与业务提交
 C-->>P: 处理或投递确认
 Note over O,C: Trace解释执行过程,持久状态支持一致性与恢复

按顺序看,每一步做了什么

  1. 为入口、远程客户端与消息消费建立一致的传播契约。
  2. 跨执行器捕获上下文,在任务范围安装并恢复原上下文。
  3. 记录低基数指标、结构化日志与必要Span属性。
  4. 从持久业务状态确认提交与重放结果,再用Trace解释耗时。
  5. 通过故障注入验证采样、断链、导出失败和补偿流程。

03对照实现代码

ContextSnapshot 如何安装并恢复线程上下文 (新标签页)↗

Micrometer Context Propagation 1.1.3 · DefaultContextSnapshot#setThreadLocals / DefaultScope#close

原创单字段Java教学模型。真实实现通过注册的ThreadLocalAccessor处理多个上下文字段,并支持缺失值清理与逆序恢复。

核心机制代码 · java
final class TraceScopeModel implements AutoCloseable {
    static final ThreadLocal<String> TRACE = new ThreadLocal<>();
    private final String previous;
    TraceScopeModel(String captured) {
        previous = TRACE.get(); // 保存任务线程原有上下文,支持嵌套范围。
        if (captured == null) TRACE.remove();
        else TRACE.set(captured); // 安装提交任务时捕获的关联值。
    }
    @Override public void close() {
        if (previous == null) TRACE.remove(); // 原本缺失时清除,避免线程复用串值。
        else TRACE.set(previous); // 恢复外层范围,而非无条件删除。
    }
}

真实源码遍历已注册的访问器,对匹配字段保存previousValues,再设置快照值。它没有通过反射复制线程里的所有ThreadLocal,只有注册并受传播策略管理的数据会被处理。这有助于控制范围,也提醒我们自定义字段需要明确接入,而不是假设线程切换自然保留。

clearMissing决定快照没有某字段时是否清理任务线程上已有的值。缺失、空字符串和已有旧值是不同状态;若任务没有用户上下文却沿用线程上的旧用户,就可能错误关联日志甚至误用身份。传播组件只能处理已定义的字段语义,业务授权仍需来自可信认证上下文。

DefaultScope.close 会按相反顺序恢复进入 Scope 前各字段的值,这样内层任务结束后,外层任务还能继续使用自己的上下文。可以用 try-with-resources 确保抛异常时也执行 close。只在正常返回时清理,会留下旧数据;每次一律 remove 也不对,因为它可能删掉外层任务还需要恢复的值。

Boot的自动配置依赖受支持的客户端构建器和相应追踪组件。绕过这些构建器自行new客户端、手工创建线程或脱离框架订阅链,可能没有自动传播。排错时应先验证入站提取、出站注入和异步安装三个边界,再检查展示系统,不要看到Trace断裂就先修改业务事务代码。

代码先调用谁,再调用谁

  1. 提交任务前捕获已注册上下文字段
  2. 任务线程安装快照并保存先前值
  3. 业务代码与客户端从当前上下文取得关联信息
  4. 任务正常或异常结束时关闭Scope
  5. 逆序恢复原值或清理原本缺失的字段

04动手写一个例子

合并重叠子Span,计算未覆盖的父时间

给定父区间[start,end)和若干同一时钟域的子区间,先裁剪到父范围,再合并重叠区间,计算父区间未被覆盖的时长。时间非负且end不小于start;空区间合法,负数或反向区间拒绝。

输入 1
父[0,10),子[1,5)、[3,8)
输出 1
子覆盖7,未覆盖3
输入 2
父[10,20),子[0,15)、[18,30)
输出 2
裁剪后覆盖7,未覆盖3

每个子区间先与父区间求交集,避免越界数据污染结果。将有效区间按起点、终点排序,再维护当前已合并段。下一个起点小于等于当前终点时,两个区间可以合并;否则当前段已经完整,可以把长度加入总覆盖并开始新段。

排序后的扫描保持一个已结算的互不重叠前缀与一个尚未结算的当前段。后来的起点不会更小,因此遇到间隙后,之前的段再也不会与未来区间相交。每个区间只会处理一次,重叠部分不会被重复累计。这也是会议室占用统计、时间线合并和文件范围处理中的通用结构。

采用非负long时间戳和半开区间,端点相接不贡献额外时长。所有合并段都包含在父范围内,所以总长度不超过父长度;实现仍使用精确加法检查异常。跨机器时钟偏差、缺失Span和异步非嵌套关系不满足问题前提,应先标记数据质量,不能把算法输出当成服务的精确CPU耗时。

为什么这样写是对的已结算区间两两不重叠且不会再与未来区间相交,当前区间覆盖尚未结算的重叠连通部分。

时间 O(n log n)额外空间 O(n)

复制和裁剪n个区间O(n),排序O(n log n),单次扫描O(n);使用新列表避免修改输入,存储最多n个有效区间。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 46 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    record Range(long start, long end) {
        Range {
            if (start < 0 || end < start) throw new IllegalArgumentException("range");
        }
    }
    static long exclusive(Range parent, List<Range> children) {
        Objects.requireNonNull(parent); Objects.requireNonNull(children);
        List<Range> ranges = new ArrayList<>();
        for (Range child : children) {
            Objects.requireNonNull(child);
            long left = Math.max(parent.start(), child.start());
            long right = Math.min(parent.end(), child.end());
            if (left < right) ranges.add(new Range(left, right)); // 裁剪并忽略空交集。
        }
        ranges.sort(Comparator.comparingLong(Range::start).thenComparingLong(Range::end));
        long covered = 0, left = 0, right = 0;
        boolean active = false;
        for (Range r : ranges) {
            if (!active) { left = r.start(); right = r.end(); active = true; }
            else if (r.start() <= right) right = Math.max(right, r.end()); // 合并重叠或相接。
            else {
                covered = Math.addExact(covered, right - left); // 只结算已经不会再相交的段。
                left = r.start(); right = r.end();
            }
        }
        if (active) covered = Math.addExact(covered, right - left);
        return parent.end() - parent.start() - covered;
    }
    static void check(long actual, long expected) {
        if (actual != expected) throw new AssertionError(actual + " != " + expected);
    }
    public static void main(String[] args) {
        check(exclusive(new Range(0,10), List.of(new Range(1,5),new Range(3,8))),3);
        check(exclusive(new Range(10,20), List.of(new Range(0,15),new Range(18,30))),3);
        check(exclusive(new Range(0,10), List.of()),10);
        check(exclusive(new Range(3,3), List.of(new Range(0,9))),0);
        check(exclusive(new Range(0,10), List.of(new Range(0,5),new Range(5,10))),0);
        check(exclusive(new Range(0,Long.MAX_VALUE), List.of(new Range(0,Long.MAX_VALUE))),0);
        try { new Range(4,3); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 无子区间时全部父时间未覆盖。
  • 子区间重叠、嵌套或重复只计一次。
  • 越界子区间裁剪,空交集忽略。
  • 零长父区间结果为0,反向区间拒绝。
进阶选读 · 分布式追踪关键路径与缺失数据

场景一次请求并行调用三个服务,其中一个服务又发送异步事件,简单相加所有Span耗时远大于用户等待时间。

做法先建立经过验证的依赖DAG与开始完成约束,再按拓扑顺序计算关键路径;区分同步等待和异步脱离请求的工作,对时钟偏差、丢失节点和采样给出不确定性标记。

复杂度已构建DAG的最长路径动态规划O(V+E);从原始追踪恢复正确依赖还需要额外校验,不能仅凭父子ID套公式。

好处与代价精细关键路径能解释瓶颈,但更依赖埋点语义与数据质量;简单区间合并适合明确嵌套的局部分析。

05项目里怎么用

指标标签应选择种类有限的值,例如路由模板、状态类别和服务名。若把每个订单号、用户 ID 或完整 URL 都当标签,监控系统就要分别保存大量时间序列,这就是高基数问题。具体 ID 更适合按权限写进日志或 Span 属性,供定位某次请求使用。密码、令牌、完整支付数据和未经审查的请求体,也不应因为方便排错就进入监控系统。

采样是只保留部分请求的详细链路记录。保留比例越高,通常越容易找到排错线索,导出和存储的开销也越大。“尾部采样”会先收集一条链路的全部或大部分记录,再按耗时、错误等条件决定是否保留,因此需要暂存数据和明确的决定规则。没有被保留记录的请求,也可能成功或失败,仍然影响真实业务。关键成功率应另外用覆盖相应业务范围的指标和业务账本核对,不能直接拿筛选后的链路当作全部请求。

对一致性建立可执行检查:同一事件重复投递后业务只产生一次效果,投递失败后能从Outbox继续,消费者提交后确认丢失仍能去重。把这些状态与关联ID记录下来,监控最老未完成任务而不只看队列总量,才能区分短暂波峰与长期卡住的个别事件。

容易踩的坑

  • 把Trace ID当作订单唯一业务键。
  • 对子Span时长直接求和,重复计算并行重叠。
  • Scope只在正常返回时关闭,异常后线程复用串上下文。
  • 用用户ID作指标标签导致基数失控。
  • 把未找到Span解释成请求从未发生。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
低基数指标适合汇总与告警无法保留每个请求细节服务SLO与容量观察
结构化日志保留具体事件证据需要脱敏、索引和保留策略业务状态变化与排错
分布式追踪连接跨服务执行片段受采样、传播和导出质量影响分析调用路径与耗时
持久业务账本支撑恢复、去重和对账需要事务与存储设计确认关键业务事实

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01Trace完整是否证明分布式事务成功?

展开参考答案与推导

不证明。Span状态取决于埋点与观测,事务事实应由持久业务状态和一致性协议确认。

02为什么父耗时不能减去全部子耗时之和?

展开参考答案与推导

并行子调用的时间范围可能重叠,相加会重复计数,应先按明确时钟域合并区间。

03为什么退出上下文范围应恢复而不总是删除?

展开参考答案与推导

嵌套调用外层可能已有合法上下文,直接删除会破坏外层;原本缺失时才应清理。

换个情况,还会处理吗

01日志出现另一个用户的Trace ID,如何排查?

展开参考答案与推导

检查线程池任务上下文捕获时机、异常路径清理和Scope关闭,复现连续两个任务及嵌套范围;同时核对身份数据是否也被错误传播。

02订单成功率正常但Outbox最老年龄持续上升,意味着什么?

展开参考答案与推导

入口成功不代表事件已完成传播,应检查投递失败、毒消息和重试状态,按最老记录恢复并核对下游业务差异。

03Trace断在手写HTTP客户端,优先检查哪里?

展开参考答案与推导

检查是否使用受自动配置管理的构建器、传播拦截器是否安装以及出站头是否正确注入,再检查接收端提取。

写代码试一试

01实现重叠时间区间覆盖计算。

输入 / 条件
父[0,10),子[1,5)、[3,8)
输出 / 预期
未覆盖3

提示 排序后维护一个当前合并段。

展开参考答案与推导

裁剪、排序、扫描合并,O(n log n)。

02为上下文Scope写异常与嵌套测试。

输入 / 条件
外层A,内层B抛异常
输出 / 预期
退出内层恢复A,退出外层恢复原值

提示 try-with-resources保证异常关闭。

展开参考答案与推导

分别测试原值缺失、原值存在、内层异常和线程复用,检查每个退出点。

03实现按事件ID统计未完成任务最老年龄。

输入 / 条件
当前100,未完成创建时刻20、80,完成10
输出 / 预期
80

提示 先排除已完成项,取最小创建时刻。

展开参考答案与推导

扫描O(n),空集合返回无待处理;未来时间戳应标记时钟异常而非输出负年龄。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能关联一次请求在各服务中的记录,并为提交后发布失败设计可查询、可重试的事件记录。

本篇重点加练

上下文传播帮助关联证据,Outbox 帮助保存事实,两者不能互相替代。

  • 上下文传播
  • Outbox

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    一次下单包含两个并行查询和一次异步写日志。自行绘制 span 起止时间,并标出跨线程、跨 HTTP 的上下文交接点。

    提交内容
    时序图与上下文字段表
    怎样算完成
    • 区分父子关系和实际重叠
    • 包含缺失上下文时的记录

    练到的知识点trace 与 span · 上下文传播

  2. 作业 02原理问答

    系统只保存十分之一的 trace,但所有请求均计入成功率。为一次五分钟异常选择所需指标、日志与 trace,说明各自遗漏风险。

    提交内容
    证据来源表
    怎样算完成
    • 注明采样规则
    • 不由样本缺失直接推断没有失败

    练到的知识点采样 · 指标与日志

  3. 作业 03代码推演

    用户请求结束后 Outbox 发送器次日才处理事件。评审沿用原 trace 上下文、建立新 trace 或使用事件关联信息时各自表达的因果关系。

    提交内容
    关联语义与时间图
    怎样算完成
    • 区分父子持续时间和因果链接
    • 保留稳定业务事件身份

    练到的知识点上下文传播

综合应用

  1. 作业 04设计评审

    订单数据库事务中登记一条事件,发送工作者可能早于提交开始查询。定义事件可发送状态和业务事务的可见性要求。

    提交内容
    事务及事件状态图
    怎样算完成
    • 覆盖事务回滚
    • 事件内容能定位到原始业务事实

    练到的知识点本地事务 · 事件登记

  2. 作业 05面试场景

    Outbox 积压后恢复发送,库存消费和通知消费进度不同。设计对账口径,并判断哪些失败可以自动补偿、哪些需要人工确认。

    提交内容
    消费者进度表与处理规则
    怎样算完成
    • 按事件和处理器核对
    • 明确补偿适用的业务前提

    练到的知识点Outbox · 补偿与对账

  3. 作业 06编程实践

    用本地存储实现订单与 Outbox 演示,增加发送中断和消费异常开关;每个事件串起日志、业务行、登记行和处理结果。

    提交内容
    代码与关联证据包
    怎样算完成
    • 同一业务操作的关联信息完整
    • 注明持久存储及故障模拟边界

    练到的知识点trace 与 span · 上下文传播 · 指标与日志 · 本地事务 · 事件登记 · Outbox · 补偿与对账

  4. 作业 07设计评审

    Outbox 清理任务只保留三天,而最慢消费者可能停机五天。界定“已发送”“已接收”“业务已完成”三个证据的责任方。

    提交内容
    保留与确认契约
    怎样算完成
    • 每种状态有可信证据来源
    • 包含清理与重放并发

    练到的知识点Outbox

  5. 作业 08编程实践

    制作本地离线证据关联器,将请求日志、Outbox 行和消费记录连成事件图,故意删去部分 trace 字段以检验仍可建立哪些关联。

    提交内容
    源码、脱敏输入及关系图
    怎样算完成
    • 标明确定与推测的关联
    • 不把相同时间当作唯一关联依据

    练到的知识点上下文传播 · Outbox

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    团队发现 trace 都成功而客户订单仍未到账。提出可证伪的调查假设,并设计同时覆盖请求成功率与异步收敛时间的验收标准。

    提交内容
    假设树与观测方案
    怎样算完成
    • 每项假设对应独立证据
    • 规定未知和未完成状态的处理

    练到的知识点采样 · 指标与日志 · Outbox · 补偿与对账

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 开放探究

    一次交易涉及多个合法独立事件,要求三个月后解释其中一个用户投诉。探究保留怎样的最小关联信息才足以审计,同时减少个人信息存储。

    提交内容
    研究问题与保留方案比较
    怎样算完成
    • 兼顾可追溯性与字段最小化
    • 说明无法恢复的信息

    练到的知识点上下文传播 · Outbox

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗