Micrometer 系列【67】统一观测:基于 Spring Boot 的生产级演示案例 | 跨线程场景
文章目录1. 前言1.1 单线程回顾1.2 多线程的崩溃1.3 问题演示2. 解决方案2.1 场景一手动创建线程2.2 场景二自定义线程池2.2.1 方式 1 手动恢复2.2.2 方式 2 自定义 TaskDecorator2.3 场景三Async 注解2.4 场景四JDK 共享线程池2.5 场景五JDK 原生普通线程池3. 方案升级集成 context-propagation 传播库3.1 基础介绍3.2 场景一/二升级captureAll → wrap手动逻辑的标准化版3.3 场景五升级JDK 原生线程池 —— ContextExecutorService.wrap一行3.4 场景二升级TaskDecorator 不用自己写了3.5 场景三解密Async 的 propagate-context 底层就是传播库3.6 手动 vs 库什么时候选谁1. 前言1.1 单线程回顾前面所有案例都是单线程内嵌套调用底层依靠OTel的ThreadLocalContext自然继承父上下文实现链路父子关系、串联整个执行链路。埋点示例ObservationobservationObservation.start(style2.officialManual,registry).lowCardinalityKeyValue(style,2);try(Observation.Scopescopeobservation.openScope()){// 执行业务逻辑}catch(Exceptione){observation.error(e);throwe;}finally{observation.stop();}ThreadLocal是同线程内的上下文容器嵌套调用天然继承。1.2 多线程的崩溃一旦换线程ThreadLocal失效ExecutorServiceexecutorExecutors.newFixedThreadPool(4);ObservationobservationObservation.start(style2.officialManual,registry).lowCardinalityKeyValue(style,2);try(Observation.Scopescopeobservation.openScope()){// 主线程打开Scope提交异步任务executor.submit(()-{// 【问题】子线程无有效Trace上下文拿不到traceId// 链路上下文丢失businessLogic();});}catch(Exceptione){observation.error(e);throwe;}finally{observation.stop();}工作线程的ThreadLocal是一张白纸根本就没有父Span建出来的是根 SpantraceId都和主线程不一样。链路在这里彻底断裂。1.3 问题演示改造之前的OrderService在下单方法内部调用支付服务publicOrderResultcreateOrder(StringorderNo,LonguserId,StringorderType){OrderObservationContextcontextOrderObservationContext.builder().operationMetadata(BusinessOperationMetadata.builder().operationType(BusinessOperationType.ORDER.value()).provider(order-service).build()).orderNo(orderNo).userId(userId).orderType(orderType).build();returnOrderObservationDocumentation.ORDER_CREATE.observation(this.observationConvention.getIfAvailable(),newDefaultOrderObservationConvention(),()-context,this.observationRegistry).observe(()-{// —— 业务逻辑 ——// 下单StringorderIdORD-UUID.randomUUID().toString().substring(0,8).toUpperCase();BigDecimalamountnewBigDecimal(99.90);context.setOrderId(orderId);context.setAmount(amount);context.setStatus(CREATED);// 支付this.paymentService.pay(orderId,WECHAT);returnnewOrderResult(orderId,orderNo,amount,CREATED);});}链路层级原始链路层级排版spring-micrometer-service-aa http get/api/order/create66.9ms ← 父SpanRootSpan └── spring-micrometer-service-aa orderNORMAL16.9ms ← 子Span└── spring-micrometer-service-aa paymentWECHAT← 孙Span将支付使用异步执行任务// 支付newThread(()-{this.paymentService.pay(orderId,WECHAT);}).start();新线程不会继承父线程ThreadLocalOTel链路上下文会丢失导致异步任务中的支付Span变成了根Span2. 解决方案2.1 场景一手动创建线程上面的【问题演示】可以这么改异步线程外获取当前Scope中的观测对象再手动将其设置到异步现场的Scope中// 捕获当前观测跨线程恢复 scope只依赖 micrometer-observation 核心ObservationorderObservationobservationRegistry.getCurrentObservation();newThread(()-{assertorderObservation!null;try(Observation.ScopescopeorderObservation.openScope()){this.paymentService.pay(orderId,WECHAT);}}).start();基本原理observe(...)回调执行时order.create的scope已压在当前线程栈上所以getCurrentObservation能拿到它。new Thread里orderObservation.openScope()把该观测压到工作线程的scope栈 →paymentService.pay()里observe(...)读到的父级就是它 → 子span挂对。2.2 场景二自定义线程池定义一个支付线程池privateThreadPoolTaskExecutorbuildPaymentExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(4);executor.setMaxPoolSize(8);executor.setQueueCapacity(100);executor.setThreadNamePrefix(order-pay-);executor.setWaitForTasksToCompleteOnShutdown(true);executor.setAwaitTerminationSeconds(30);executor.initialize();returnexecutor;}2.2.1 方式 1 手动恢复和new Thread一样openScope()在目标线程恢复// 支付线程池异步执行跨线程恢复观测 scope 以保持链路ObservationorderObservationobservationRegistry.getCurrentObservation();paymentExecutor.execute(()-{assertorderObservation!null;try(Observation.ScopescopeorderObservation.openScope()){this.paymentService.pay(orderId,WECHAT);}});2.2.2 方式 2 自定义 TaskDecoratorTaskDecorator是Spring提供的任务边界装饰器接口用于在任务提交到线程池时做横切处理。FunctionalInterfacepublicinterfaceTaskDecorator{Runnabledecorate(Runnablerunnable);}decorate()在提交线程捕获当前观测包一层Runnable在工作线程observation.openScope()里执行任务/** * 提交任务时捕获当前观测工作线程执行时恢复其 scope * 使异步任务正确挂到父观测链路下面。只依赖 micrometer-observation 核心。 */publicclassObservationTaskDecoratorimplementsTaskDecorator{privatefinalObservationRegistryobservationRegistry;publicObservationTaskDecorator(ObservationRegistryobservationRegistry){this.observationRegistryobservationRegistry;}OverridepublicRunnabledecorate(Runnabletask){Observation.ScopecurrentScopethis.observationRegistry.getCurrentObservationScope();Observationobservation(currentScope!null)?currentScope.getCurrentObservation():null;if(observationnull){returntask;}return()-{try(Observation.Scopescopeobservation.openScope()){task.run();}};}}ThreadPoolTaskExecutor配置ObservationTaskDecoratorprivateThreadPoolTaskExecutorbuildPaymentExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(4);executor.setMaxPoolSize(8);executor.setQueueCapacity(100);executor.setThreadNamePrefix(order-pay-);executor.setWaitForTasksToCompleteOnShutdown(true);executor.setAwaitTerminationSeconds(30);executor.setTaskDecorator(newObservationTaskDecorator(this.observationRegistry));executor.initialize();returnexecutor;}调用线程池执行// 支付线程池异步执行TaskDecorator 负责跨线程恢复观测 scope 以保持链路paymentExecutor.execute(()-this.paymentService.pay(orderId,WECHAT));2.3 场景三Async 注解如果你使用Async方法且依赖自动配置的AsyncTaskExecutor需要通过配置spring.task.execution.propagate-contexttrue主动开启上下文传播。配置示例spring:task:execution:# 开启上下文传播适配 Async 线程池propagate-context:true2.4 场景四JDK 共享线程池ForkJoinPool.commonPool()JVM进程内唯一公共池不用自己创建全局所有代码共享。不指定线程池调用以下方法时触发// 使用 ForkJoinPool.commonPool()CompletableFuture.runAsync(()-{System.out.println(Thread.currentThread().getName());});CompletableFuture.supplyAsync(()-{returntest;});方式1手动传播// —— 提交线程抓当前观测此刻父 scope 仍打开工作线程 openScope() 恢复// 不依赖 TaskDecorator对 commonPool 同样生效但每处异步都要自己写一遍ObservationorderObservationobservationRegistry.getCurrentObservation();CompletableFuture.runAsync(()-{if(orderObservationnull){this.paymentService.pay(orderId,WECHAT);// 无观测时直接执行不传播return;}try(Observation.ScopescopeorderObservation.openScope()){this.paymentService.pay(orderId,WECHAT);}});方式2显式传带TaskDecorator的线程池// 显式传带 TaskDecorator 的线程池 —— scope 被恢复Trace 保持父子关系CompletableFuture.runAsync(()-this.paymentService.pay(orderId,WECHAT),this.paymentExecutor);2.5 场景五JDK 原生普通线程池常见创建方式Executors.newFixedThreadPool()Executors.newCachedThreadPool()Executors.newSingleThreadExecutor()JDK原生的ThreadPoolExecutor/ExecutorService没有setTaskDecorator那是Spring ThreadPoolTaskExecutor独有的能力。只能靠提交时手动getCurrentObservation() 工作线程openScope()方式// 三种常见创建方式JDK 原生 ThreadPoolExecutor均无 setTaskDecoratorExecutorServicefixedExecutors.newFixedThreadPool(2);// 固定 2 线程无界队列ExecutorServicecachedExecutors.newCachedThreadPool();// 按需创建空闲 60s 回收ExecutorServicesingleExecutors.newSingleThreadExecutor();// 单线程串行// 原生池不传播观测 scope只能手动恢复同方式 CObservationobsobservationRegistry.getCurrentObservation();RunnablepayTask()-{if(obsnull){this.paymentService.pay(orderId,WECHAT);return;}try(Observation.Scopescopeobs.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库Spring、Micrometer、Reactor三团队共同设计Micrometer 1.10/Reactor 3.5起被框架内置支持。核心概念概念作用类比手写方案ContextRegistry注册中心单例登记有哪些上下文——ThreadLocalAccessor每种上下文的适配器读写 ThreadLocal 的契约——ContextSnapshotFactory.captureAll()拍快照一次带走全部已注册上下文≈getCurrentObservation()ContextSnapshot.wrap(runnable)包装任务执行前还原、执行后还原现场≈openScope()的标准化版Observation的适配器不用自己写micrometer-observation内置了ObservationThreadLocalAccessorkey micrometer.observation并经SPIjar内META-INF/services/io.micrometer.context.ThreadLocalAccessor自动注册进全局ContextRegistry。同理Slf4jThreadLocalAccessor负责MDC。就是说前文手写版的捕获逻辑库用captureAll()一行替代而且捕获的是全部已注册上下文不只是Observation一个。3.2 场景一/二升级captureAll → wrap手动逻辑的标准化版// 快照工厂每个工程一个即可ContextSnapshotFactoryfactoryContextSnapshotFactory.builder().build();// ① 捕获提交前调用此时父 scope 仍打开——等价于手写版的 getCurrentObservation()ContextSnapshotsnapshotfactory.captureAll();// ② 传播等价于手写版的 openScope()但判空、还原全部内置newThread(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内部是执行前把快照值set进ThreadLocal→try-with-resources→ 执行后还原工作线程原状态不会出现忘了finally关Scope的残留。3.3 场景五升级JDK 原生线程池 —— ContextExecutorService.wrap一行2.5的结论是JDK原生池没有setTaskDecorator只能手动恢复——有了库这条结论可以升级// 直接把 JDK 原生池包一层提交时自动捕获、执行时自动还原ExecutorServicewrappedContextExecutorService.wrap(Executors.newFixedThreadPool(2),factory);wrapped.execute(()-this.paymentService.pay(orderId,WECHAT));newFixedThreadPool/newCachedThreadPool/newSingleThreadExecutor全部适用业务代码零侵入——和TaskDecorator是同一个思路区别是它是ExecutorService包装器、不依赖 Spring纯JDK工程也能用。场景四的ForkJoinPool.commonPool()不是ExecutorService用wrap包任务即可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)ObservationMDC 其他已注册上下文一次全带走ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();// 手写的 ObservationTaskDecorator 类直接删掉换 Spring 内置的executor.setTaskDecorator(newContextPropagatingTaskDecorator());2.2.2 手写 decoratorSpring 内置 decorator传播的上下文只有 Observation全部已注册Observation MDC 其他判空 / 还原自己写captureAll().wrap()内置代码量~20 行1 行配置3.5 场景三解密Async 的 propagate-context 底层就是传播库2.3的spring.task.execution.propagate-context: true是配置一行就全自动——它的底层正是3.5的ContextPropagatingTaskDecoratorBoot自动为自动配置的AsyncTaskExecutor挂上这个decorator本质就是captureAll().wrap()。也就是说2.3 已经在用传播库了只是 Boot 替你接线。两个版本注脚该配置由 Spring Boot 3.1引入早期默认false需要显式开启2.3的写法正确Spring Boot 3.2 起默认值改为true新工程即使什么都不配Async的上下文传播也是开着的。3.6 手动 vs 库什么时候选谁前文手写方案不是白写的——它是理解库的基础也是某些场景的合理选择选手写上下文只有一两种、异步调用点就三五个、或想避免多一个依赖——手写透明直白反而好维护选库上下文多种并存ObservationMDC 安全上下文 租户、异步边界多、或将来要碰Reactor/WebFlux——库的快照一次全量带走新加上下文只改注册表已有代码零改动。两者不是对立的库的wrap()内部执行的就是手写那套流程只是把最容易写错的判空、开闭、还原标准化了。判断规则一句话上下文种类 × 调用点规模乘积越大库的收益越明显。

相关新闻

AMD 显卡 AI 绘图不再吃灰:用 ComfyUI-Zluda 把闲置 A 卡变成生产力

AMD 显卡 AI 绘图不再吃灰:用 ComfyUI-Zluda 把闲置 A 卡变成生产力

AMD 显卡 AI 绘图不再吃灰:用 ComfyUI-Zluda 把闲置 A 卡变成生产力 【免费下载链接】ComfyUI-Zluda The most powerful and modular stable diffusion GUI, api and backend with a graph/nodes interface. Now ZLUDA enhanced for better AMD GPU performance. …

2026/10/18 12:58:48 阅读更多 →
游戏模组框架 BepInEx 稳定性进阶:插件在 IL2CPP 游戏里反复崩溃,问题到底出在哪

游戏模组框架 BepInEx 稳定性进阶:插件在 IL2CPP 游戏里反复崩溃,问题到底出在哪

游戏模组框架 BepInEx 稳定性进阶:插件在 IL2CPP 游戏里反复崩溃,问题到底出在哪 【免费下载链接】BepInEx Unity / XNA game patcher and plugin framework 项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx 很多模组开发者在把插件装…

2026/10/20 14:44:03 阅读更多 →
MZmine打不开Thermo RAW文件?4步分层排查法帮你快速搞定质谱数据导入

MZmine打不开Thermo RAW文件?4步分层排查法帮你快速搞定质谱数据导入

MZmine打不开Thermo RAW文件?4步分层排查法帮你快速搞定质谱数据导入 【免费下载链接】mzmine3 mzmine source code repository 项目地址: https://gitcode.com/gh_mirrors/mz/mzmine3 深夜十一点,仪器管理员发来一份Thermo RAW格式的质谱数据。你…

2026/10/20 7:22:09 阅读更多 →

最新新闻

WebRTC拥塞控制核心:AimdRateControl原理与实战调优

WebRTC拥塞控制核心:AimdRateControl原理与实战调优

1. 项目概述:为什么一个“码率控制器”值得你花20分钟认真读完在WebRTC的实际工程落地中,90%的卡顿、花屏、延迟抖动问题,最终都指向同一个被低估的模块——AimdRateControl。它不是WebRTC协议栈里最炫酷的部分,没有SDP协商那么直…

2026/10/20 22:20:53 阅读更多 →
从零搭建群聊自动化Bot:环境配置、创建与部署实战

从零搭建群聊自动化Bot:环境配置、创建与部署实战

1. 从零搭建一个能干活儿的 Bot:整体思路与方案选型1.1 这个 Bot 到底能干什么,适合谁来折腾先把话说在前头:这篇东西讲的不是那种"跑个 demo 就完事"的玩具,而是一套能真正拉进群里、替你分担重复劳动的自动化助手。核…

2026/10/20 22:20:53 阅读更多 →
Claude Code 安装排坑指南:从环境检查到命令行实战

Claude Code 安装排坑指南:从环境检查到命令行实战

不知道你有没有见过这种场面:一个开发者看了一篇安装教程,前十分钟每一步都照做了,结果在最后启动命令时直接卡住,界面白屏、报错刷屏,只能把窗口关掉重新再来。身边最近讨论新一代命令行 AI 编程助手的声音挺大&#…

2026/10/20 22:20:53 阅读更多 →
Kubernetes入门:Ubuntu环境下k8s集群搭建实操与避坑指南

Kubernetes入门:Ubuntu环境下k8s集群搭建实操与避坑指南

有朋友让我聊聊 k8s 到底是个啥,顺便给一套能在 Ubuntu 上把环境跑起来的实操流程。这话题我前前后后折腾了不少次,从最早只会跑 docker 容器,到后来维护几个节点的集群,中间踩的坑不算少。这篇就结合我自己的实操经验&#xff0c…

2026/10/20 22:20:53 阅读更多 →
OpenClaw消息总线架构拆解:从SQLite队列到Agent运行时设计

OpenClaw消息总线架构拆解:从SQLite队列到Agent运行时设计

1. 项目概述与核心命题拆解1.1 这个项目到底在做什么OpenClaw 是一个开源的个人 AI 助理框架,核心定位是让用户通过日常聊天工具(如即时通讯软件)直接操控一台远程计算机,完成文件管理、代码执行、网页浏览、定时任务等操作。它不…

2026/10/20 22:20:53 阅读更多 →
Obsidian标签体系完整拆解:四种方案对比与落地模板

Obsidian标签体系完整拆解:四种方案对比与落地模板

开头笔记软件换了又换,最后能让我一直留下来的,反倒是 Obsidian 里那套看起来不起眼的标签系统。我见过不少人把 Obsidian 当成文件夹管理器,一个笔记塞进一个目录,回头找资料就翻了天;也见过更夸张的,半年…

2026/10/20 22:19:42 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →