Micrometer 系列【67】统一观测:基于 Spring Boot 的生产级演示案例 | 跨线程场景

文章目录

  • [1. 前言](#1. 前言)
    • [1.1 单线程回顾](#1.1 单线程回顾)
    • [1.2 多线程的崩溃](#1.2 多线程的崩溃)
    • [1.3 问题演示](#1.3 问题演示)
  • [2. 解决方案](#2. 解决方案)
    • [2.1 场景一:手动创建线程](#2.1 场景一:手动创建线程)
    • [2.2 场景二:自定义线程池](#2.2 场景二:自定义线程池)
      • [2.2.1 方式 1 :手动恢复](#2.2.1 方式 1 :手动恢复)
      • [2.2.2 方式 2 :自定义 TaskDecorator](#2.2.2 方式 2 :自定义 TaskDecorator)
    • [2.3 场景三:@Async 注解](#2.3 场景三:@Async 注解)
    • [2.4 场景四:JDK 共享线程池](#2.4 场景四:JDK 共享线程池)
    • [2.5 场景五:JDK 原生普通线程池](#2.5 场景五:JDK 原生普通线程池)
  • [3. 方案升级:集成 context-propagation 传播库](#3. 方案升级:集成 context-propagation 传播库)
    • [3.1 基础介绍](#3.1 基础介绍)
    • [3.2 场景一/二升级:captureAll → wrap(手动逻辑的标准化版)](#3.2 场景一/二升级:captureAll → wrap(手动逻辑的标准化版))
    • [3.3 场景五升级:JDK 原生线程池 ------ ContextExecutorService.wrap(一行)](#3.3 场景五升级:JDK 原生线程池 —— ContextExecutorService.wrap(一行))
    • [3.4 场景二升级:TaskDecorator 不用自己写了](#3.4 场景二升级:TaskDecorator 不用自己写了)
    • [3.5 场景三解密:@Async 的 propagate-context 底层就是传播库](#3.5 场景三解密:@Async 的 propagate-context 底层就是传播库)
    • [3.6 手动 vs 库:什么时候选谁](#3.6 手动 vs 库:什么时候选谁)

1. 前言

1.1 单线程回顾

前面所有案例都是单线程内嵌套调用,底层依靠 OTelThreadLocal<Context> 自然继承父上下文,实现链路父子关系、串联整个执行链路。

埋点示例:

java 复制代码
        Observation observation = Observation.start("style2.officialManual", registry)
                .lowCardinalityKeyValue("style", "2");
        try (Observation.Scope scope = observation.openScope()) {
            // 执行业务逻辑
        } catch (Exception e) {
            observation.error(e);
            throw e;
        } finally {
            observation.stop();
        }

ThreadLocal 是同线程内的上下文容器,嵌套调用天然继承。

1.2 多线程的崩溃

一旦换线程,ThreadLocal 失效:

java 复制代码
ExecutorService executor = Executors.newFixedThreadPool(4);
Observation observation = Observation.start("style2.officialManual", registry)
        .lowCardinalityKeyValue("style", "2");
try (Observation.Scope scope = observation.openScope()) {
    // 主线程打开Scope,提交异步任务
    executor.submit(() -> {
        // 【问题】子线程无有效Trace上下文,拿不到traceId
        // 链路上下文丢失!
        businessLogic();
    });
} catch (Exception e) {
    observation.error(e);
    throw e;
} finally {
    observation.stop();
}

工作线程的 ThreadLocal 是一张白纸,根本就没有父 Span,建出来的是根 SpantraceId 都和主线程不一样。链路在这里彻底断裂。

1.3 问题演示

改造之前的 OrderService 在下单方法内部调用支付服务:

java 复制代码
	public OrderResult createOrder(String orderNo, Long userId, String orderType) {
		OrderObservationContext context = OrderObservationContext.builder()
			.operationMetadata(BusinessOperationMetadata.builder()
				.operationType(BusinessOperationType.ORDER.value())
				.provider("order-service")
				.build())
			.orderNo(orderNo)
			.userId(userId)
			.orderType(orderType)
			.build();

		return OrderObservationDocumentation.ORDER_CREATE
			.observation(this.observationConvention.getIfAvailable(), new DefaultOrderObservationConvention(),
					() -> context, this.observationRegistry)
			.observe(() -> {
				// ------ 业务逻辑 ------
				// 下单
				String orderId = "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
				BigDecimal amount = new BigDecimal("99.90");
				context.setOrderId(orderId);
				context.setAmount(amount);
				context.setStatus("CREATED");
				// 支付
				this.paymentService.pay(orderId, "WECHAT");
				return new OrderResult(orderId, orderNo, amount, "CREATED");
			});
	}

链路层级:

原始链路层级排版:

java 复制代码
spring-micrometer-service-aa http get /api/order/create   66.9ms   ← 父Span(Root Span)
└── spring-micrometer-service-aa order NORMAL             16.9ms   ← 子Span
    └── spring-micrometer-service-aa payment WECHAT                ← 孙Span

将支付使用异步执行任务:

java 复制代码
				// 支付
				new Thread(() -> {
					this.paymentService.pay(orderId, "WECHAT");
				}).start();

新线程不会继承父线程 ThreadLocalOTel 链路上下文会丢失,导致异步任务中的支付 Span 变成了根 Span

2. 解决方案

2.1 场景一:手动创建线程

上面的【问题演示】可以这么改,异步线程外获取当前 Scope 中的观测对象,再手动将其设置到异步现场的 Scope 中:

java 复制代码
// 捕获当前观测,跨线程恢复 scope(只依赖 micrometer-observation 核心)
Observation orderObservation = observationRegistry.getCurrentObservation();
new Thread(() -> {
    assert orderObservation != null;
    try (Observation.Scope scope = orderObservation.openScope()) {
		this.paymentService.pay(orderId, "WECHAT");
	}

}).start();

基本原理:

  • observe(...) 回调执行时,order.createscope 已压在当前线程栈上,所以 getCurrentObservation 能拿到它。
  • new ThreadorderObservation.openScope() 把该观测压到工作线程的 scope 栈 → paymentService.pay() observe(...) 读到的父级就是它 → 子 span 挂对。

2.2 场景二:自定义线程池

定义一个支付线程池:

java 复制代码
	private ThreadPoolTaskExecutor buildPaymentExecutor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setCorePoolSize(4);
		executor.setMaxPoolSize(8);
		executor.setQueueCapacity(100);
		executor.setThreadNamePrefix("order-pay-");
		executor.setWaitForTasksToCompleteOnShutdown(true);
		executor.setAwaitTerminationSeconds(30);
		executor.initialize();
		return executor;
	}

2.2.1 方式 1 :手动恢复

new Thread 一样 openScope() 在目标线程恢复:

java 复制代码
				// 支付:线程池异步执行,跨线程恢复观测 scope 以保持链路
				Observation orderObservation = observationRegistry.getCurrentObservation();
				paymentExecutor.execute(() -> {
					assert orderObservation != null;
					try (Observation.Scope scope = orderObservation.openScope()) {
						this.paymentService.pay(orderId, "WECHAT");
					}
				});

2.2.2 方式 2 :自定义 TaskDecorator

TaskDecoratorSpring 提供的任务边界装饰器接口,用于在任务提交到线程池时做横切处理。

java 复制代码
  @FunctionalInterface
  public interface TaskDecorator {                                                                                                                                                                                                 
      Runnable decorate(Runnable runnable);
  }

decorate() 在提交线程捕获当前观测,包一层 Runnable 在工作线程 observation.openScope() 里执行任务:

java 复制代码
/**
 * 提交任务时捕获当前观测,工作线程执行时恢复其 scope,
 * 使异步任务正确挂到父观测(链路)下面。只依赖 micrometer-observation 核心。
 */
public class ObservationTaskDecorator implements TaskDecorator {

	private final ObservationRegistry observationRegistry;

	public ObservationTaskDecorator(ObservationRegistry observationRegistry) {
		this.observationRegistry = observationRegistry;
	}

	@Override
	public Runnable decorate(Runnable task) {
		Observation.Scope currentScope = this.observationRegistry.getCurrentObservationScope();
		Observation observation = (currentScope != null) ? currentScope.getCurrentObservation() : null;
		if (observation == null) {
			return task;
		}
		return () -> {
			try (Observation.Scope scope = observation.openScope()) {
				task.run();
			}
		};
	}
}

ThreadPoolTaskExecutor 配置 ObservationTaskDecorator

java 复制代码
	private ThreadPoolTaskExecutor buildPaymentExecutor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setCorePoolSize(4);
		executor.setMaxPoolSize(8);
		executor.setQueueCapacity(100);
		executor.setThreadNamePrefix("order-pay-");
		executor.setWaitForTasksToCompleteOnShutdown(true);
		executor.setAwaitTerminationSeconds(30);
		executor.setTaskDecorator(new ObservationTaskDecorator(this.observationRegistry));
		executor.initialize();
		return executor;
	}

调用线程池执行:

java 复制代码
// 支付:线程池异步执行,TaskDecorator 负责跨线程恢复观测 scope 以保持链路
paymentExecutor.execute(() -> this.paymentService.pay(orderId, "WECHAT"));

2.3 场景三:@Async 注解

如果你使用 @Async 方法且依赖自动配置的 AsyncTaskExecutor,需要通过配置 spring.task.execution.propagate-context=true 主动开启上下文传播。

配置示例:

yml 复制代码
spring:
  task:
    execution:
      # 开启上下文传播(适配 @Async 线程池)
      propagate-context: true

2.4 场景四:JDK 共享线程池

ForkJoinPool.commonPool()JVM 进程内唯一公共池,不用自己创建,全局所有代码共享。

不指定线程池调用以下方法时触发:

java 复制代码
// 使用 ForkJoinPool.commonPool()
CompletableFuture.runAsync(() -> {
    System.out.println(Thread.currentThread().getName());
});

CompletableFuture.supplyAsync(() -> {
    return "test";
});

方式 1 ,手动传播:

java 复制代码
				// ------ 提交线程抓当前观测(此刻父 scope 仍打开),工作线程 openScope() 恢复,
				// 不依赖 TaskDecorator,对 commonPool 同样生效,但每处异步都要自己写一遍
				Observation orderObservation = observationRegistry.getCurrentObservation();
				CompletableFuture.runAsync(() -> {
					if (orderObservation == null) {
						this.paymentService.pay(orderId, "WECHAT"); // 无观测时直接执行,不传播
						return;
					}
					try (Observation.Scope scope = orderObservation.openScope()) {
						this.paymentService.pay(orderId, "WECHAT");
					}
				});

方式 2 ,显式传带 TaskDecorator 的线程池:

java 复制代码
// 显式传带 TaskDecorator 的线程池 ------ scope 被恢复,Trace 保持父子关系
CompletableFuture.runAsync(() -> this.paymentService.pay(orderId, "WECHAT"), this.paymentExecutor);

2.5 场景五:JDK 原生普通线程池

常见创建方式:

java 复制代码
Executors.newFixedThreadPool()
Executors.newCachedThreadPool()
Executors.newSingleThreadExecutor()

JDK 原生的 ThreadPoolExecutor/ExecutorService 没有 setTaskDecorator,那是 Spring ThreadPoolTaskExecutor 独有的能力。只能靠提交时手动 getCurrentObservation() + 工作线程 openScope() 方式:

java 复制代码
				// 三种常见创建方式(JDK 原生 ThreadPoolExecutor,均无 setTaskDecorator)
				ExecutorService fixed = Executors.newFixedThreadPool(2);      // 固定 2 线程,无界队列
				ExecutorService cached = Executors.newCachedThreadPool();     // 按需创建,空闲 60s 回收
				ExecutorService single = Executors.newSingleThreadExecutor(); // 单线程串行

				// 原生池不传播观测 scope,只能手动恢复(同方式 C)
				Observation obs = observationRegistry.getCurrentObservation();
				Runnable payTask = () -> {
					if (obs == null) {
						this.paymentService.pay(orderId, "WECHAT");
						return;
					}
					try (Observation.Scope scope = obs.openScope()) {
						this.paymentService.pay(orderId, "WECHAT");
					}
				};

				CompletableFuture.runAsync(payTask, fixed);
				CompletableFuture.runAsync(payTask, cached);
				CompletableFuture.runAsync(payTask, single);

				// 提交完立即 orderly shutdown:已提交任务仍会执行,池资源被回收(避免演示端点反复调用泄漏线程)
				fixed.shutdown();
				cached.shutdown();
				single.shutdown();

3. 方案升级:集成 context-propagation 传播库

3.1 基础介绍

上下文搬运是一个横切关注点 。手写是"点对点"方案,需要一种"面"上的统一机制,这就是 Micrometer 生态的 context-propagation 库(SpringMicrometerReactor 三团队共同设计,Micrometer 1.10 / Reactor 3.5 起被框架内置支持)。

核心概念:

概念 作用 类比手写方案
ContextRegistry 注册中心(单例),登记"有哪些上下文" ------
ThreadLocalAccessor 每种上下文的适配器(读写 ThreadLocal 的契约) ------
ContextSnapshotFactory.captureAll() 拍快照:一次带走全部已注册上下文 getCurrentObservation()
ContextSnapshot.wrap(runnable) 包装任务:执行前还原、执行后还原现场 openScope() 的标准化版

Observation 的适配器不用自己写,micrometer-observation 内置了 ObservationThreadLocalAccessor(key = "micrometer.observation"),并经 SPIjarMETA-INF/services/io.micrometer.context.ThreadLocalAccessor)自动注册进全局 ContextRegistry。同理 Slf4jThreadLocalAccessor 负责 MDC

就是说:前文手写版的"捕获"逻辑,库用 captureAll() 一行替代,而且捕获的是全部已注册上下文,不只是 Observation 一个。

3.2 场景一/二升级:captureAll → wrap(手动逻辑的标准化版)

java 复制代码
// 快照工厂:每个工程一个即可
ContextSnapshotFactory factory = ContextSnapshotFactory.builder().build();

// ① 捕获:提交前调用(此时父 scope 仍打开)------等价于手写版的 getCurrentObservation()
ContextSnapshot snapshot = factory.captureAll();

// ② 传播:等价于手写版的 openScope(),但判空、还原全部内置
new Thread(snapshot.wrap(() -> this.paymentService.pay(orderId, "WECHAT"))).start();
paymentExecutor.execute(snapshot.wrap(() -> this.paymentService.pay(orderId, "WECHAT")));

2.1 / 2.2.1 手写 8 行对比,差异在两点:

  • 判空被消化掉了 :手写版 getCurrentObservation() 可能为 null 要自己分支;captureAll() 永远返回快照------线程上没有上下文时就是空快照,wrap 后原样执行,无副作用;
  • 还原是库保证的wrap 内部是"执行前把快照值 setThreadLocaltry-with-resources → 执行后还原工作线程原状态",不会出现忘了 finallyScope 的残留。

3.3 场景五升级:JDK 原生线程池 ------ ContextExecutorService.wrap(一行)

2.5 的结论是"JDK 原生池没有 setTaskDecorator,只能手动恢复"------有了库,这条结论可以升级

java 复制代码
// 直接把 JDK 原生池包一层:提交时自动捕获、执行时自动还原
ExecutorService wrapped = ContextExecutorService.wrap(Executors.newFixedThreadPool(2), factory);
wrapped.execute(() -> this.paymentService.pay(orderId, "WECHAT"));

newFixedThreadPool / newCachedThreadPool / newSingleThreadExecutor 全部适用,业务代码零侵入------和 TaskDecorator 是同一个思路,区别是它是 ExecutorService 包装器、不依赖 Spring ,纯 JDK 工程也能用。

场景四的 ForkJoinPool.commonPool() 不是 ExecutorService,用 wrap 包任务即可:

java 复制代码
CompletableFuture.runAsync(snapshot.wrap(() -> this.paymentService.pay(orderId, "WECHAT")));

3.4 场景二升级:TaskDecorator 不用自己写了

2.2.2 手写的 ObservationTaskDecorator 只传 Observation 一种上下文。Spring 6.1 内置了 ContextPropagatingTaskDecorator ,它的核心逻辑就是一行------factory.captureAll().wrap(task)Observation + MDC + 其他已注册上下文一次全带走

java 复制代码
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 手写的 ObservationTaskDecorator 类直接删掉,换 Spring 内置的
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
2.2.2 手写 decorator Spring 内置 decorator
传播的上下文 只有 Observation 全部已注册(Observation + MDC + 其他)
判空 / 还原 自己写 captureAll().wrap() 内置
代码量 ~20 行 1 行配置

3.5 场景三解密:@Async 的 propagate-context 底层就是传播库

2.3 spring.task.execution.propagate-context: true 是"配置一行就全自动"------它的底层正是 3.5ContextPropagatingTaskDecoratorBoot 自动为自动配置的 AsyncTaskExecutor 挂上这个 decorator,本质就是 captureAll().wrap()

也就是说:2.3 已经在用传播库了,只是 Boot 替你接线。两个版本注脚:

  • 该配置由 Spring Boot 3.1 引入,早期默认 false,需要显式开启(2.3 的写法正确);
  • Spring Boot 3.2 起默认值改为 true ,新工程即使什么都不配,@Async 的上下文传播也是开着的。

3.6 手动 vs 库:什么时候选谁

前文手写方案不是白写的------它是理解库的基础,也是某些场景的合理选择

  • 选手写:上下文只有一两种、异步调用点就三五个、或想避免多一个依赖------手写透明直白,反而好维护;
  • 选库 :上下文多种并存(Observation + MDC + 安全上下文 + 租户)、异步边界多、或将来要碰 Reactor/WebFlux------库的快照"一次全量带走",新加上下文只改注册表,已有代码零改动。

两者不是对立的:库的 wrap() 内部执行的就是手写那套流程,只是把最容易写错的"判空、开闭、还原"标准化了。判断规则一句话:上下文种类 × 调用点规模,乘积越大,库的收益越明显

相关推荐
IT机器猫1 小时前
RabbitMQ基础二
java·spring boot·分布式·spring cloud·log4j·rabbitmq·springamqp
lang201509282 小时前
从 J2EE 到云原生:Spring Framework 的设计哲学与演进之路
spring·云原生·java-ee
Flynt3 小时前
我试了Spring Boot 4的原生镜像,聊聊那些数字和坑
java·spring boot
凤山老林3 小时前
复杂检索引擎落地:Spring Boot + Elasticsearch 数据同步与高阶查询实战
spring boot·后端·elasticsearch
Henry-SAP10 小时前
SAP引领ERP智能化转型
人工智能·云原生·sap·erp
砍材农夫13 小时前
spring-ai|Spring‑AI 2.0.0 新特性 + 入门教程
spring boot·spring·spring cloud
流烟默13 小时前
Kubernetes 调度器完全指南:从原理到生产实战
云原生·容器·kubernetes
styshoo13 小时前
[NVSentinel] gpu-health-monitor模块调研
人工智能·云原生·nvsentinel
小强库计算机毕业设计15 小时前
SpringBoot+Vue3 学生宿舍管理系统实战
java·spring boot·后端·vue·学生宿舍管理系统