微服务链路追踪与灰度发布:上下文传播、版本选择和回退
HTTP 请求中的 traceparent 按下面的结构携带追踪上下文:
traceparent: 00-<trace-id>-<parent-id>-<trace-flags>
│ │ │ └─ 采样等标志
│ │ └─ 发送方当前 Span 的 ID,16 位十六进制
│ └─ 整条 Trace 的 ID,32 位十六进制
└─ 格式版本;00 使用上述固定字段结构下游创建自己的 Span,同时保留 Trace ID,把传来的 parent-id 作为父 Span ID。这样可以把不同进程中的操作关联起来。至于这次请求应交给稳定版还是灰度版,需要另一个明确的路由决定;Trace ID 不包含部署版本选择规则。
一次调用怎样形成可关联的 Span
Trace、Span 与上下文对象
Trace 表示关联起来的一组操作,Span 记录其中一个有开始和结束的操作。一个 HTTP 调用通常包含调用方 CLIENT Span 与接收方 SERVER Span;应用内部还可以为重要计算、排队或业务操作创建 INTERNAL Span。
| 对象 | 保存什么 | 使用位置 |
|---|---|---|
| Span | 名称、时间、属性、事件、状态和父子关系 | 当前操作的生命周期 |
| SpanContext | Trace ID、Span ID、TraceFlags、TraceState 等 | 跨进程传播所需的追踪标识 |
| Context | 当前执行流携带的数据,可包含 Span 与 Baggage | 同线程及异步任务关联 |
| Propagator | 把 Context 注入载体,或从载体提取 | HTTP 头、消息属性等 |
| Scope | 使 Context 在当前执行范围内可见 | 进入处理逻辑,退出后恢复原上下文 |
| SpanProcessor / Exporter | 接收结束的 Span 并处理、发送 | SDK 到观测后端之间 |
traceparent 的全零 Trace ID 或全零 parent-id 无效;收到无效上下文时,应按协议忽略,而不是继续传播一个固定的伪 ID。tracestate 为兼容不同追踪系统保留供应商数据,不适合装业务参数。字段语法与处理规则以 W3C Trace Context为准。
一条 Trace 中不同 Span 的时间可能重叠,不能把所有 Span 的 duration 相加当作请求耗时。并行调用应看关键路径;父 Span 中未被子 Span 覆盖的时间还可能包含排队、序列化或未埋点操作。跨主机绝对时间受时钟偏差影响,先核对单次操作持续时间与调用关系,再解释时间线。
采集路径与业务调用路径分开
业务请求继续访问下游;结束的 Span 由 SDK 交给处理器和导出器。常见生产采集路径是:
应用埋点 / Java agent
↓
OpenTelemetry SDK → 批处理、采样、导出队列
↓ OTLP
Collector → 接收 → 过滤/处理 → 导出
↓
追踪存储与查询系统Exporter 发送失败不会自动取消已经执行的业务。队列上限、导出超时、重试与丢弃数量应有独立监控,否则“查不到 Trace”可能只是遥测未送达。Collector 的接收器、处理器和导出器还必须加入 service.pipelines 才会启用,具体配置见Collector 配置说明。
Java 应用可以选择 agent、框架集成或手工 API。Agent 适合覆盖受支持的常用库,手工 Span 适合补充业务操作;同一 HTTP 调用如果同时被多套机制重复埋点,会产生重复的 CLIENT/SERVER Span。组合方式见OpenTelemetry Java 埋点体系。
Spring Boot 可通过 Micrometer Tracing 桥接追踪实现,并在框架管理的客户端构建器上应用观测配置。手工创建另一个客户端可能绕过这些配置,详见Spring Boot Tracing。后续实验显式创建 OTel SDK 与 HTTP 埋点,不同时启用 agent 或该自动集成。
上下文传播与流量选择怎样配合
HTTP 提取、线程切换和 Scope 关闭
提取远端上下文后,需要同时处理父 Span 和当前执行上下文。只调用 setParent(extracted) 可以建立追踪父子关系,却不会把提取到的所有 Baggage 自动放进当前线程。
Context extracted = telemetry.extract(request);
Span server = tracer.spanBuilder("edge.call")
.setSpanKind(SpanKind.SERVER)
.setParent(extracted)
.startSpan();
try (Scope scope = extracted.with(server).makeCurrent()) {
Callable<Result> task = () -> invokeDownstream();
Future<Result> future = executor.submit(Context.current().wrap(task));
return future.get(5, TimeUnit.SECONDS);
} finally {
server.end();
}这里的 Scope 仅控制上下文可见范围,close 不会替 Span 调用 end。线程池线程长期复用,缺少 Scope 关闭可能把上一个任务的信息带入下一个任务;在提交任务时显式包装,才能把当前 Context 带到执行线程。OTel Java API说明了这些对象的用法。
HTTP 客户端发送前还要注入 Context,下游才能提取;消息消费要从消息载体提取,而非照抄 HTTP 线程逻辑。Reactor 等异步执行模型也不能只依赖普通 ThreadLocal,需要使用相应集成和响应式 Context。传播对象与载体的关系见上下文传播。
Baggage 只传播允许携带的信息
Baggage 可以携带跨服务的键值,但它既不会自动成为 Span 属性,也没有提供业务身份可信性。入口收到 baggage: tenant=admin,不能据此获得管理员权限。它还可能随下游请求离开本系统,因此凭据、手机号、完整订单内容不应放入其中。Baggage 说明特别讨论了这一风险。
实验只保留 color,值必须是 1~16 个小写字母;其他字段被丢弃。这个字段用于观察传播,没有授权作用,也不决定路由。生产中允许的每个字段都应有用途、长度与传递范围。
灰度先确定分组,再选择实例
部署版本指实际运行的代码制品;API 版本描述接口契约;cohort 表示参与某次发布的一组流量。三者可能有关联,但通常不是一对一关系。同一个 API 版本可以同时由两个部署版本实现,而一个部署版本也可能兼容多个 API 版本。
| 选择方式 | 优点 | 必须处理的问题 |
|---|---|---|
| 按请求随机比例 | 配置简单 | 同一用户连续操作可能落到不同版本 |
| 按用户或租户稳定分桶 | 连续操作较一致,便于对照 | 大租户流量不均;标识应来自可信认证结果 |
| 指定测试名单 | 便于开始小范围验证 | 名单规模与样本代表性有限 |
| 按接口或业务功能开关 | 控制到具体功能 | 下游依赖和数据格式仍需兼容 |
客户端自带灰度头只能作为未受信任的输入。生产入口应在认证后根据策略重新计算分组,并清理外来同名头;内部服务只信任受保护的调用来源。依赖链中每一跳还要定义无灰度实例时的动作,不能只在网关染色,随后由其他服务任意回退。
Spring Cloud 5.0.3 的 HintBasedServiceInstanceListSupplier 使用默认 X-SC-LB-Hint 头匹配实例 metadata 的 hint;没有匹配时返回原候选集。这适合偏好选择,不能直接当成严格隔离。Hint Supplier 源码明确保留该回退。
BlockingApiVersionServiceInstanceListSupplier 则读取版本策略,与实例 API_VERSION 比较。默认无匹配返回空集合;开启 fallback-to-available-instances 后才回退到全部实例。它服务于 API 版本选择,不应因默认行为较严格就把所有部署灰度改名为 API 版本。机制见API Version Supplier 源码及LoadBalancer 文档。
观察真实的跨线程追踪和版本筛选
启动三个可区分的 Java 进程
下载完整实验 ZIP,解压进入 microservice-tracing-gray-lab。源码入口与运行说明见该目录的 README.md。
工程使用 Boot 4.1.1、Cloud 版本 2025.1.3(LoadBalancer 5.0.3)、OpenTelemetry Java 1.65.0与 Maven 3.9.12,源码目标为 Java 17。普通 Linux 用户需要 Docker Compose、Bash、curl 和 jq。
set -euo pipefail
test "$(id -u)" -ne 0 || exit 1
docker info >/dev/null
docker compose version
curl --version
jq --version
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify
docker compose up -d
docker compose logs --tail=30构建应有 7 个测试通过,并生成 target/tracing-app.jar。Compose 用这个 JAR 启动 edge、stable、canary 三个进程,运行身份为 10001:10001,根文件系统只读,临时目录可写。构建仍使用宿主 UID/GID 和独立 Maven 缓存。
| 进程 | 宿主回环端口 | 作用 |
|---|---|---|
| edge | 18201 | HTTP 入口、异步任务、实例选择与下游调用 |
| stable | 18202 | 稳定版响应,metadata hint=stable、API_VERSION=1 |
| canary | 18203 | 灰度版响应,metadata hint=canary、API_VERSION=2 |
候选地址在工程中显式声明,没有启动注册中心。过滤使用真实 Spring Cloud Supplier,之后由 RoundRobinLoadBalancer 选择实例并发出真实 HTTP 请求。为了比较选择行为,这个实验把两个部署版本分别映射到 API 1、2;实际发布应根据真实契约配置。
QuoteVersionConfiguration 仅导入 quote 的 LoadBalancer 子上下文,提供简单的字符串版本解析策略。它放在主应用扫描包之外,避免把只用于出站选择的版本解析器注册为 MVC 全局配置。接收请求的 MVC API 版本路由与出站 LoadBalancer 选择是两处不同配置。
三个应用日志都出现 Started 后,检查服务并清空之前的实验数据:
EDGE=http://127.0.0.1:18201
STABLE=http://127.0.0.1:18202
CANARY=http://127.0.0.1:18203
CURL=(curl -q --noproxy '*' --connect-timeout 2 --max-time 10 \
--silent --show-error --fail-with-body)
for base in "$EDGE" "$STABLE" "$CANARY"; do
"${CURL[@]}" "$base/health" | jq -e '.status == "UP"'
"${CURL[@]}" -X POST "$base/lab/reset" >/dev/null
done第一次调用:比较真实生成的 ID
"${CURL[@]}" -H 'baggage: color=blue,token=never-forward' \
"$EDGE/call" > .lab-call.json
jq '{edgeTraceId, edgeSpanId, taskTraceId, taskSpanId, clientSpanId, worker}' \
.lab-call.json
jq -e '.edgeTraceId == .taskTraceId and
.edgeTraceId == .worker.traceId and
.worker.baggage == ["color"] and .worker.color == "blue"' .lab-call.jsonID 由本次 SDK 实际生成,不应比对某个固定值。edge、异步任务、下游的 Trace ID 应相同,各自 Span ID 不同;worker 只收到 color,没有收到 token。
再读取已完成的 Span:
"${CURL[@]}" "$EDGE/lab/spans" > .lab-edge-spans.json
"${CURL[@]}" "$STABLE/lab/spans" > .lab-worker-spans.json
jq '.[] | {name, kind, traceId, spanId, parentSpanId, status}' .lab-edge-spans.json
jq -e --arg p "$(jq -r '.edgeSpanId' .lab-call.json)" \
'.[] | select(.name == "async.work") | .parentSpanId == $p' .lab-edge-spans.json
jq -e --arg p "$(jq -r '.clientSpanId' .lab-call.json)" \
'.[] | select(.name == "quote.work") | .parentSpanId == $p' .lab-worker-spans.json应得到以下关系,父子关系由 ID 相等来确认,不能只看名称和展示顺序:
edge.call SERVER
└─ async.work INTERNAL
└─ quote.request CLIENT
└─ quote.work SERVER,位于 stable 进程示例使用真实 InMemorySpanExporter 读取已结束的 Span,外层在超过 256 项时清空旧窗口以限制实验内存。它没有 OTLP、Collector 或持久化查询能力。/lab/spans 和其他管理端点只能用于隔离实验,不应原样公开部署。
这里手工创建的 SERVER Span 覆盖 Controller 处理逻辑,在返回值交给 MVC 序列化之前结束;不能拿它当作包含完整网络写出的服务端耗时。正常 Span 状态显示 UNSET 也不等于失败,只有实际标记的错误显示 ERROR。SDK 的采样、处理和导出配置见Java SDK 说明。
去掉线程包装:下游仍成功,Trace 已断开
"${CURL[@]}" "$EDGE/call?propagate=false" \
| jq -e '.edgeTraceId != .taskTraceId and .taskTraceId == .worker.traceId'
"${CURL[@]}" "$EDGE/call" \
| jq -e '.edgeTraceId == .taskTraceId and .edgeTraceId == .worker.traceId'第一条在预先创建的线程池中直接提交任务,没有 Context.wrap。异步 Span 成为新 Trace 的根,HTTP 注入随后仍然工作,所以任务与 worker 可以关联,却无法接回 edge。
第二条重新包装后恢复完整关系。工程测试还检查下一个没有外来 traceparent 的请求创建新根,避免 Scope 泄漏。HTTP 200 只反映这次业务响应,断链必须从父子 ID 判断。
不存在的 hint 会扩大候选范围
"${CURL[@]}" "$EDGE/lab/candidates?selector=hint&target=canary" \
| jq -e '. == ["canary"]'
"${CURL[@]}" "$EDGE/lab/candidates?selector=hint&target=missing" \
| jq -e '. == ["stable","canary"]'
for ((i=0;i<4;i++)); do
"${CURL[@]}" "$EDGE/call?selector=hint&target=missing" | jq -r '.selected'
done第一条只包含 canary。不存在的 hint 返回两个实例,后续轮询会访问 stable 和 canary,起始实例不固定。生产若要求某类请求绝不进入另一版本,就必须换成严格筛选逻辑,并定义空候选的响应。
收回版本候选,检查后续请求是否还访问它
API 2 正常选择 canary;随后只从 edge 的候选来源中收回 canary,服务进程本身保持运行:
"${CURL[@]}" "$EDGE/call?selector=api&target=2" \
| jq -e '.selected == "canary"'
before=$("${CURL[@]}" "$CANARY/lab/counts" | jq -r '.calls')
"${CURL[@]}" -X POST "$EDGE/lab/canary?enabled=false" >/dev/null
transport=0
status=$(curl -q --noproxy '*' --connect-timeout 2 --max-time 10 -sS \
-o .lab-denied.json -w '%{http_code}' \
"$EDGE/call?selector=api&target=2") || transport=$?
test "$transport" -eq 0 && test "$status" = 503
jq -e '.error == "NO_CANDIDATE"' .lab-denied.json
after=$("${CURL[@]}" "$CANARY/lab/counts" | jq -r '.calls')
test "$before" = "$after"API 2 没有匹配实例,应用把空候选映射为 503 NO_CANDIDATE;计数未增加,说明没有向 canary 发出这次请求。
同样状态下,hint=canary 却会退回剩下的 stable:
"${CURL[@]}" "$EDGE/call?selector=hint&target=canary" \
| jq -e '.selected == "stable"'
"${CURL[@]}" -X POST "$EDGE/lab/canary?enabled=true" >/dev/null
"${CURL[@]}" "$EDGE/call?selector=api&target=2" \
| jq -e '.selected == "canary"'恢复候选后 API 2 再次成功。该操作没有更新注册中心、取消在途请求或恢复数据库,它只改变这个调用方随后看到的静态实例集合。
从断链、误投和观测偏差恢复
先区分没传播、没采样和没导出
| 观察结果 | 检查位置 | 下一步 |
|---|---|---|
| 异步任务出现新 Trace ID | 提交任务处是否包装 Context | 修正传播,再验证下一任务没有串号 |
| 同 Trace 但 parentSpanId 错误 | CLIENT Span 何时 current、何时注入 | 在实际发送前注入当前 CLIENT 上下文 |
| 有 traceparent,没有导出 Span | 父采样标志、Sampler 与 SpanProcessor | 使用受控样本验证采样,再检查导出 |
| SDK 有结束记录,后端查不到 | 导出地址、TLS、队列、Collector pipeline | 看导出错误与丢弃量,勿反复修改业务代码 |
| 一次调用有重复 CLIENT/SERVER | agent 与框架/手工埋点组合 | 保留一套常用库埋点,补充必要业务 Span |
| 灰度请求到达稳定版 | 无匹配 fallback、头被覆盖、metadata 陈旧 | 核对实际候选与最终选中实例 |
实验使用 parentBased(alwaysOn):无父上下文的新根会采样;有效远端父标志为 00 时,Trace ID 仍可继续传播,但本次 Span 不会被记录导出。测试明确覆盖了这个分支,避免把“导出列表为空”误诊为 HTTP 头丢失。
头部采样在调用开始附近作决定,无法预知后续错误;尾部采样在收集一定上下文后决定保留,更适合按错误或耗时筛选,但需要缓存、等待与同 Trace 数据归集。上游已丢弃的数据无法由尾部采样补回。策略和权衡见OpenTelemetry Sampling。
灰度比较需要完整分母
发布判断应按 stable/canary、接口与业务结果比较请求量、错误率和延迟分位数,再观察业务指标,例如报价展示失败或订单重复。低流量版本中连续几次成功,无法说明它已经覆盖了稳定版的输入分布。
Trace 可以解释某些失败经过了哪些调用,但经过采样的 Trace 数量不应直接当作全部请求数。若尾部采样偏向保留错误,直接从 Trace 算错误率会偏高。指标维度应使用有限集合,例如部署版本、路由模板与错误类型;Trace ID、用户 ID、完整 URL 查询参数不适合作为普通指标标签。
日志关联还需要在日志记录时取得当前 Trace/Span ID,不能假设安装 SDK 就会自动替所有日志附加字段。业务审计信息应按自己的保留和权限要求保存,不能依赖可被采样、丢弃的追踪数据。
停止流量与回退数据是两项操作
逐步发布需要明确放量、暂停和退回的条件。出现跨版本不兼容、错误率显著增加或业务结果异常时,先停止扩大范围;对已确认的错误版本收回后续流量,同时保留诊断信息。执行前应确认稳定版有承载能力,而不是把过载重新转移给它。
Kubernetes Deployment 可以滚动替换 Pod,也支持回退到旧的 Pod 模板;流量分桶仍需结合 Service、入口或服务网格策略。Deployment 回退本身不会逆转数据库内容,详见Deployment 机制。
新旧版本并存时,数据库和消息格式应尽量采用分阶段兼容变更:
先扩展:新增可兼容字段或格式,旧版仍可读取
↓
部署可兼容读写的代码,完成必要的数据回填
↓
逐步切换流量,观察实际错误与业务结果
↓
确认旧版退出且回退窗口结束,再移除旧结构如果新版本已经写入旧版本无法识别的枚举、字段格式或事件,回滚镜像可能让故障扩大。数据同步、schema 变化与回退安排可参考AWS 数据与 Schema 变更实践。
还要处理已建立的长连接、正在执行的请求、延迟消息和后台任务。候选摘除只影响之后的选择;需要配合连接排空、任务取消或幂等补偿。修复完成后验证新的请求落点,再单独核对已经产生的业务状态。
实验结束后回收:
rm -f -- .lab-call.json .lab-edge-spans.json .lab-worker-spans.json .lab-denied.json
docker compose down容器和网络被删除,内存 Span 与计数消失;源码、JAR 和 Maven 缓存保留在实验目录。再次验证可重新启动,或运行 bash scripts/verify.sh 复验已讲解的父子关系、过滤、拒绝与恢复。
