Boot4挂起函数丢traceId怎么修

Spring Boot 4:Kotlin 挂起函数丢了 traceId 怎么办

Spring Boot 4 / Framework 7 把 Kotlin 基线抬到了 2.2 ,协程与 Web 栈贴得更紧。可观测这条线上,有个很容易踩的坑:过滤器、拦截器里明明有 observation,同步代码日志也能打出 traceId,一进 挂起函数 ,同一请求的业务日志却空了。Micrometer 没坏,毛病出在 上下文没跟着协程走。下面按官方博客与 Kotlin Coroutines 文档,把原因、开关和显式桥接说清楚,并补一个官方博客没提的前提:Spring MVC 里只配 YAML 不一定生效,不展开 GlobalScope。

一、为什么挂起函数会丢 observation

Spring Framework 的 Kotlin Coroutines 文档把路径分得很清楚:

  1. 阻塞路径 :observation 放在 ThreadLocal 里;
  2. 响应式路径:observation 放在 Reactor Context 里。

挂起函数是第三条路。协程可能在挂起点换线程、换调度器,ThreadLocal 不会自动搬家;若不把 Micrometer Context Propagation 接到协程上下文,当前 observation 进不了挂起体 ,MDC / 日志桥再怎么配,也拼不出 traceId。你在网关或 Servlet 过滤器里看到完整链路,到 suspend 服务层却断档。现场往往是「过滤器有、挂起无」,并非「整条链路都没开 tracing」。

Sébastien Deleuze 在 Next-level Kotlin support in Spring Boot 4 里把「Automatic context propagation with Coroutines」写成 Boot 4 的明确能力:挂起函数里的可观测与 tracing 可以 工作。前提是你打开了自动传播,并且 classpath 上有对应库。这句话值得对照源码和配置各看一眼:能力在框架侧已经准备好了,缺的常常是一行配置,外加 classpath 上的几样东西:context-propagation、tracing 实现,以及在 Spring MVC 里还要有 spring-boot-reactor 模块(第二节细说)。

典型症状可以对照这张表:

调用形态 上下文载体 未配置传播时
同步 Controller / Filter ThreadLocal 日志通常有 traceId
WebFlux Mono/Flux Reactor Context Context 里有;普通日志(MDC)默认不一定拿得到,实测默认的 limited 下无 traceId,要开 auto
suspend 业务函数 需显式/自动桥接 容易丢 traceId

排查时先别急着换日志框架或改 pattern:先确认「丢」是不是只发生在挂起路径。若同步过滤器有、suspend 服务没有,十有八九就是传播没开。也可以临时在挂起函数里第一次真正挂起(比如 delay 或 withContext)之后打一条「裸」业务日志、在过滤器里再打一条(挂起之前的代码还跑在请求线程上,实测 MVC 下那一段日志往往还带着 traceId,把日志放在入口会看不出问题)。对比同一请求号两侧是否都带 traceId,比先怀疑采样率更省时间。

另一个容易混淆的点:响应式链路「看起来」也异步,但 Reactor Context 是官方已经接好的载体;挂起函数若没有把 Micrometer 的传播接到 CoroutineContext,既不等于 ThreadLocal,也不自动等于 Reactor Context。文档把 PropagationContextElement 和自动 Hooks 写出来,正是为了补上这一环。

二、最小修复:配置 + 依赖,以及 MVC 里为什么只配 YAML 不一定生效

官方博客与 Boot Actuator Observability 的说法一致:把

yaml 复制代码
spring:
  reactor:
    context-propagation: auto

设为 auto ,启用 Reactor 侧的自动上下文传播。默认值是 limited,只对 tap、handle 这类算子传播上下文;这个属性 Boot 3.3 起就有,Boot 4 / Framework 7 新增的是协程侧的支持。同时确保依赖里有:

xml 复制代码
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>context-propagation</artifactId>
</dependency>

(Gradle 同理:implementation("io.micrometer:context-propagation")。版本交给 Boot BOM 管理即可;装了 tracing 起步依赖时它通常已被传递带入。)

Spring MVC 项目要多看一步。 Boot 4 把 Reactor 的自动配置拆进了独立的 spring-boot-reactor 模块,spring-boot-starter-webmvc 和 actuator 都不依赖它,只有 spring-boot-starter-webflux 之类才会带上。实测(Boot 4.0.8、Framework 7.0.9、JDK 21,MVC + 挂起 Controller):只配 spring.reactor.context-propagation=auto,Hooks.isAutomaticContextPropagationEnabled() 仍是 false,withContext 之后的日志没有 traceId。三种做法任选其一:

  1. 加上 org.springframework.boot:spring-boot-reactor(或者项目本来就用 WebFlux 起步依赖),属性才会生效;
  2. 不依赖属性,在启动入口显式调用 Hooks.enableAutomaticContextPropagation();
  3. 在自己写的协程入口加 PropagationContextElement()(见第三节)。

官方博客和文档没有提到第 1 种需要这个模块,这是从 POM、源码和实测得出的结论,上线前请在你的工程里用 Hooks.isAutomaticContextPropagationEnabled() 自检。

Boot 4 的 Kotlin 特性页还要求至少 Kotlin 2.2.x ,且 classpath 上必须有 kotlin-stdlib / kotlin-reflect,版本由 Boot 导入的 Kotlin BOM、Coroutines BOM 管理。在 start.spring.io 生成 Kotlin 项目并勾选 reactive 依赖时才会默认带上 kotlinx-coroutines-reactor,Spring MVC 项目要自己加这个依赖。文档 Context Propagation 一节也把它列为可选协作依赖。注解默认目标有新规则时,文档建议 -Xannotation-default-target=param-property;这只是次要提示,主线仍是传播开关。

kotlin 复制代码
// build.gradle.kts 示意(Spring MVC + 挂起 Controller):版本以 Boot 4.0.8 / Kotlin 2.2.21 为例,其余依赖由 Boot BOM 管理
plugins {
    kotlin("jvm") version "2.2.21"
    kotlin("plugin.spring") version "2.2.21"
    id("org.springframework.boot") version "4.0.8"
    id("io.spring.dependency-management") version "1.1.7"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    // tracing 实现:没有它就不会有 traceId(也可换成 zipkin 起步依赖)
    implementation("org.springframework.boot:spring-boot-starter-opentelemetry")
    // MVC 项目里让 spring.reactor.context-propagation=auto 生效;用 WebFlux 起步依赖时不用单独加
    implementation("org.springframework.boot:spring-boot-reactor")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("io.micrometer:context-propagation")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}

开关确认打开后,再走一遍「同步有、挂起无」的对比请求。验证时把 management.tracing.sampling.probability 设成 1.0,默认只采样 10%,容易把「没采样」当成「traceId 丢了」。实测在 MVC + 挂起 Controller 上,打开 Hook 之后,withContext(Dispatchers.Default) 之后的日志也带着同一个 traceId。若仍没有,再往下看第三节的显式 PropagationContextElement。这种情况往往是你自己写了 runBlocking 或自定义协程入口,自动路径没覆盖到。

把开关放进配置中心时,建议和应用环境一起灰度:先在预发对「挂起密集」的接口做日志抽样,确认 MDC 键名与现有 Trace 后端一致,再全量。开关本身很轻,但日志管道若还在用旧的 MDC 键映射,会出现「框架有 observation、落盘仍看不到你熟悉的字段」。那是映射问题,并非传播没开。

三、PropagationContextElement 与自动 Hooks

文档 Context Propagation 节给出两类用法,适合不同接入点。

1. 显式:PropagationContextElement

类名在 org.springframework.core.PropagationContextElement(Framework 7.0 起提供)。它让 Micrometer Context Propagation 与 Kotlin Coroutines 协作。需要自己起阻塞桥时,可以写成:

kotlin 复制代码
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.springframework.core.PropagationContextElement

fun bridgeFromBlocking() {
    runBlocking(Dispatchers.IO + PropagationContextElement()) {
        // 此处挂起业务可读到当前 observation
        // businessSuspend()
    }
}

这段可单独编译(在已引入 Spring Framework 与 coroutines 的模块里)。要点是:把 PropagationContextElement 加进 CoroutineContext ,别只换 Dispatchers。只写 Dispatchers.IO 解决的是线程池选择,解决不了 observation 搬家。

2. 自动:Hooks.enableAutomaticContextPropagation()

当 Spring 把挂起函数适配成 Flux/Mono(文档提到 CoroutinesUtils#invokeSuspendingFunction)时,可调用 Reactor 的

kotlin 复制代码
import reactor.core.publisher.Hooks

fun enablePropagationEarly() {
    Hooks.enableAutomaticContextPropagation()
}

让适配路径上的上下文自动传播。Boot 里 spring.reactor.context-propagation=auto 生效时,ReactorAutoConfiguration 做的就是这一次调用(Boot 4.0.8 源码)。但这个自动配置在 spring-boot-reactor 模块里,纯 Spring MVC 项目默认没有它,这时在启动入口显式调用 Hooks 就是最直接的办法。这个方法需要 Reactor 3.5.3 及以上(Boot 4 管理的是 3.8.x),classpath 上没有 context-propagation 时调用不会有任何效果。两种开法选一个即可,别在多处手工开关互相踩脚。

一张极简「挂起服务 + 日志」骨架(演示结构;observation API 以你项目已接入的 Micrometer/Observation 为准):

kotlin 复制代码
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class OrderQueryService {
    private val log = LoggerFactory.getLogger(javaClass)

    suspend fun findStatus(orderId: String): String {
        // 传播开启后,MDC/桥接应能带上 traceId;此处只打业务字段
        log.info("query order status, orderId={}", orderId)
        return "OK"
    }
}

若传播未开,上面这行 info 往往就是「有业务字段、没有 traceId」的现场。修好后,同一 orderId 应能在追踪系统里和入口 span 对上号。

Controllers 若直接声明 suspend fun,Spring MVC 和 WebFlux 都会通过 CoroutinesUtils#invokeSuspendingFunction 把它适配成 Mono/Flux;这个方法只有在 Hooks.isAutomaticContextPropagationEnabled() 为真时,才会给协程加上 PropagationContextElement(Framework 7.0.9 源码)。所以这里完全取决于自动传播有没有真正开启。自己在服务层用 coroutineScope / async 拆并发时,子协程默认继承父 CoroutineContext。父上下文里已经有了 PropagationContextElement(开启自动传播后,框架给 Controller 协程加的就是这个元素),子任务一般也能带上;若子任务跑在新建的 CoroutineScope 或 GlobalScope 里,不再继承父上下文,传播元素就没了,问题会再次出现(实测 launch(Dispatchers.IO) 这种只换调度器的写法仍然继承;CoroutineScope(Dispatchers.Default).launch 和 GlobalScope.launch 则丢失)。审查「自定义 CoroutineContext」的 diff 时,把传播元素是否还在,当作和 Dispatcher 同级的检查项。

四、和 @Async、自配线程池的边界

Boot Observability 文档还提到:@Async 和自配线程池要另外处理:Boot 4.1 起可以用 spring.task.execution.propagate-context 给自动配置的执行器开启传播,4.0 里没有这个属性,需要自己注册 ContextPropagatingTaskDecorator Bean。那是 任务执行器 上的 ThreadLocal 传播问题,和这里讲的 协程挂起路径 不是同一开关。

落地时记住分层:

  • 挂起函数 / Reactor ↔ 协程:spring.reactor.context-propagation=auto + io.micrometer:context-propagation(+ 必要时 PropagationContextElement / Hooks);
  • @Async、自建线程池:另看 task execution 的 propagate 配置与装饰器,这里不展开,只提醒别混改。

评审里看到「只在异步线程池上贴了装饰器、协程侧仍空 traceId」,就是这两层没对齐。反过来,协程侧已经修好、却有人把所有问题都怪到 spring.reactor.context-propagation 上,也会漏掉真正跑在 @Async 里的那段日志。分层命名、分层验证,比「一个开关治百病」可靠。

WebMVC + 挂起、WebFlux + 挂起,在 Boot 4 / Framework 7 里都会碰到同一类「载体不同」的问题:MVC 的 observation 在 ThreadLocal 里,WebFlux 的在 Reactor Context 里。实测两条栈在默认的 limited 模式下,挂起之后的日志都没有 traceId;MVC 还要满足上面说的 spring-boot-reactor 或显式 Hook。无论哪条栈,先确认 observation 进没进挂起上下文,再去看导出到追踪后端的细节。

五、落地检查清单

按顺序勾,比反复换日志 pattern 省事:

  1. 基线:Spring Boot 4 / Framework 7,Kotlin ≥ 2.2.x;

  2. 依赖 :运行时 classpath 上要有 io.micrometer:context-propagation(装了 tracing 起步依赖时通常已传递带入)、一个 tracing 实现(如 spring-boot-starter-opentelemetry)和 kotlinx-coroutines-reactor(MVC 项目要自己加);

  3. 开关 :spring.reactor.context-propagation=auto 且 classpath 上有 spring-boot-reactor(WebFlux 起步依赖自带,MVC 要自己加),或者在启动入口显式调用 Hooks.enableAutomaticContextPropagation();用 Hooks.isAutomaticContextPropagationEnabled() 自检;

  4. 对照实验 :同一请求 ID,同步路径与 suspend 路径各打一条日志,看 MDC/traceId 是否同时出现;

  5. 显式桥 :自写 runBlocking / 自定义协程入口时,加上 PropagationContextElement();

  6. 别误伤 :别把 GlobalScope、结构化并发问题跟「丢 traceId」绑成同一个 PR,前者是生命周期,后者是上下文载体(GlobalScope 本身的问题见《Kotlin后端别再用GlobalScope》);

  7. 任务执行器 :若还有 @Async 丢字段,单独查 task execution 的 propagate,别拿它和 reactor 开关互相覆盖结论。

  8. 变更可回滚:传播开关与依赖升级分开提交更好复盘;出问题能立刻区分是「YAML」还是「classpath」。

  9. 文档锚点:把 Framework Coroutines 页的 Context Propagation 节、Boot Observability 的同名节、以及 Boot 4 Kotlin 博文链到团队 wiki,避免口口相传成「好像要装某个私有 starter」。

六、小结

挂起函数丢 traceId,根因通常是 observation 还停在 ThreadLocal 或未接入的 Reactor Context,协程上下文里没有副本 。Boot 4 / Framework 7 提供了协程侧的自动传播,但要真正开启:WebFlux 项目配 spring.reactor.context-propagation=auto 并带上 context-propagation 依赖即可;Spring MVC 项目只配 YAML 不一定够,还要加 spring-boot-reactor 模块,或者在启动入口显式调用 Hooks.enableAutomaticContextPropagation(),或者在自写协程入口加 PropagationContextElement()。先让挂起路径「看得到」当前 observation,再谈 span 命名与采样策略;顺序反了,再漂亮的仪表盘也对不上同一条请求链。

Kotlin 2.2 基线、Coroutines BOM、以及注解默认目标的编译参数,是 Boot 4 Kotlin 支持的配套项;它们服务于语言与框架对齐,不能替代传播配置。把「能编译挂起 Controller」和「挂起日志带 traceId」当成两个验收项,上线检查会踏实很多。

封面建议:白底示意图,左侧红色一栏标出挂起函数里的日志没有 traceId,中间是一个灰色箭头,右侧绿色一栏标出开启自动传播或加上 PropagationContextElement 之后日志带上了 traceId,画面一角有「Boot 4 · Kotlin 2.2」的小字。

相关推荐
code_slave(码畜)1 小时前
微服务架构落地:基础服务 —— 报表服务(AI 集成篇:AI 增强报表能力)
人工智能·spring boot·spring cloud·微服务·架构
code_slave(码畜)3 小时前
微服务架构落地:公共中间件层总览——不承载业务,只承载稳定性
spring boot·spring cloud·微服务·中间件·架构
谢亮_vipxieliang4 小时前
Spring Boot 自动配置原理:从 @SpringBootApplication 到自定义 Starter
java·spring boot·后端
维克兜率天6 小时前
【维克】配对交易的季节性:哪些品种适合长拿?
android·开发语言·笔记·python·算法·kotlin·量化
墨天梦6 小时前
B06_XML控件布局与ViewBinding
android·kotlin
paopaokaka_luck7 小时前
非遗文物数字化小程序(AI非遗问答、ONNX图像识别、协同过滤推荐、ECharts数据分析、非遗知识浏览与互动、文创商城订单闭环、文化活动报名签到、社区交流)
javascript·spring boot·mysql·数据分析·echarts·mybatis
EatFan9 小时前
Spring Boot 4 迁移避坑清单:Jackson 3、starter 拆分与最低 JDK 口径核对(含若依/芋道/CRMEB 升级对照)
java·数据库·spring boot·spring boot 4·java 21·jakarta ee 11·jackson 3
释厄6239 小时前
BSD 简单真理循环论——简单真理 × 复杂循环=大一统
android·开发语言·kotlin
ly768910 小时前
Spring Boot 集成 Redis 企业级实践:连接池、序列化与缓存穿透雪崩的工程化防御
spring boot·redis·缓存·缓存穿透·布隆过滤器·lettuce 连接池