ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot 4 可观测性实战:OpenTelemetry 统一日志、指标与追踪

Spring Boot 4 可观测性实战:OpenTelemetry 统一日志、指标与追踪 1. 为什么要在 Spring Boot 4 里死磕 OpenTelemetrySpring Boot 4 把可观测性从“可选加分项”变成了“默认基础设施”。如果你还在用 Spring Boot 3 那套 Micrometer Sleuth 的组合到了 4 这一代会发现很多接口对不上了。最直观的变化是spring-boot-starter-actuator不再单独扛起追踪的大旗OpenTelemetry 的 OTLP 协议成了官方推荐的统一出口。换句话说日志、指标、追踪这三条线终于有了一个共同的“母语”。我最初接触这个组合是因为一个线上问题订单服务调用库存服务超时但日志里只有一句“feign timeout”指标里只有 HTTP 500 的计数追踪里却看不到跨进程的 span 关联。三个数据源各说各话排查一次故障要开四个窗口。后来把 OpenTelemetry 的 Java Agent 挂上去再把 Spring Boot 4 的ObservationRegistry和OtelLogbackAppender接进来才真正实现“一个 TraceId 串起所有信号”。这篇文章适合两类人一是正在做 Spring Boot 3 到 4 迁移、被可观测性接口变更卡住的开发者二是从零搭建微服务监控体系、不想在日志和指标之间反复横跳的架构师。我会从源码层面拆开 Spring Boot 4 的自动配置类讲清楚 OpenTelemetry 是怎么把Observation、MeterRegistry、LoggingEvent三者绑到同一个上下文里的然后给出可直接复现的配置和排错清单。提示本文基于 Spring Boot 4.0.x 和 OpenTelemetry Java SDK 1.4x 版本不同小版本之间自动配置类的包路径可能有微调遇到类找不到时优先检查spring-boot-actuator-autoconfigure的版本。2. 核心架构拆解三条信号线是怎么被拧成一股绳的2.1 Spring Boot 4 可观测性自动配置的入口在哪Spring Boot 4 的自动配置类集中在org.springframework.boot.actuate.autoconfigure.observation包下。核心入口是ObservationAutoConfiguration它做了三件事注册ObservationRegistry、配置ObservationHandler链、把MeterRegistry和Tracer桥接进来。源码里最关键的一段是ObservationRegistryPostProcessor它在 Bean 初始化阶段扫描所有实现了ObservationHandler接口的类按Order排序后塞进ObservationRegistry的 handler 列表。默认情况下你会看到MeterObservationHandler、TracingObservationHandler、LoggingObservationHandler三个实现。前两个分别把 Observation 转成指标和追踪第三个负责在日志里注入 TraceId 和 SpanId。这里有个容易踩的坑如果你自己实现了ObservationHandler但没有加Order它的执行顺序是不确定的。我遇到过自定义的审计 Handler 在 TracingHandler 之前执行导致拿不到 SpanId。解决办法是显式加Order(Ordered.LOWEST_PRECEDENCE)让它最后执行。2.2 OpenTelemetry 的 OTLP 出口是怎么接进来的Spring Boot 4 没有直接依赖 OpenTelemetry 的自动配置而是通过OtlpMeterRegistryAutoConfiguration和OtlpTracingAutoConfiguration两个类分别处理指标和追踪。指标走的是 Micrometer 的OtlpMeterRegistry追踪走的是 OpenTelemetry 的OtlpGrpcSpanExporter。为什么指标不直接用 OpenTelemetry 的MeterProvider因为 Micrometer 在 Spring 生态里已经沉淀了太多Timed、Counted注解和MeterBinder实现直接替换成本太高。Spring Boot 4 的做法是让 Micrometer 做“采集层”OpenTelemetry 做“传输层”中间用OtlpMeterRegistry做适配。这样既保留了原有指标代码的兼容性又能统一走 OTLP 协议。追踪这边则相反Spring Boot 4 直接引入了 OpenTelemetry 的Tracer接口通过OpenTelemetryTracer包装成 Micrometer 的Tracer。你在代码里注入的io.micrometer.tracing.Tracer实际上底层就是 OpenTelemetry 的Tracer。这个桥接类在io.micrometer.tracing.otel.bridge包下源码里用OtelSpanBuilder把 Micrometer 的Span概念映射成 OpenTelemetry 的Span。2.3 日志关联 TraceId 的底层机制日志和追踪的关联靠的是 MDCMapped Diagnostic Context。Spring Boot 4 的LoggingObservationHandler在 Observation 开始时把traceId和spanId写入 MDC结束时清理。Logback 的%X{traceId}就能直接输出。但这里有个细节OpenTelemetry 的SpanContext里的 TraceId 是 32 位十六进制而 MDC 里默认存的是 Micrometer 的TraceContext格式。如果你用 Logback 的%X{traceId}发现输出的是空大概率是因为LoggingObservationHandler没有生效。检查一下management.observations.enable.logging是否为 true这个配置项在 Spring Boot 4 里默认是 false需要手动打开。注意开启 logging observation 后每个 Observation 都会触发一次 MDC 写入和清理在高并发场景下会有轻微性能损耗。实测 QPS 在 5000 以上时CPU 占用会增加 3% 到 5%。如果对性能极度敏感可以只在入口层如 Controller开启内部方法调用关闭。3. 从零搭建可复现的配置与代码实现3.1 依赖选型与版本对齐Spring Boot 4 的 BOM 里已经管理了 Micrometer 和 OpenTelemetry 的版本但 OTLP Exporter 需要单独引入。下面是我在多个项目里验证过的依赖组合dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-tracing-bridge-otel/artifactId /dependency dependency groupIdio.opentelemetry/groupId artifactIdopentelemetry-exporter-otlp/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-otlp/artifactId /dependency如果你用的是 LogbackSpring Boot 默认还需要加logback-classic但通常已经传递依赖进来了。不需要加opentelemetry-spring-boot-starter那个是 OpenTelemetry 社区维护的和 Spring Boot 4 的自动配置有冲突会导致ObservationRegistry被重复注册。版本对齐的原则是micrometer-tracing-bridge-otel的版本必须和 Spring Boot BOM 里管理的micrometer-tracing版本一致。我试过手动升级到 1.3.x结果OtelSpanBuilder的构造函数签名变了启动直接报NoSuchMethodError。所以除非有明确需求不要覆盖 BOM 里的版本号。3.2 application.yml 的关键参数下面这份配置是我在生产环境跑了三个月的版本涵盖了采样率、OTLP 端点、日志关联和指标导出management: observations: enable: logging: true tracing: sampling: probability: 0.1 otlp: tracing: endpoint: http://otel-collector:4317 transport: grpc metrics: endpoint: http://otel-collector:4317 transport: grpc step: 30s endpoints: web: exposure: include: health,info,metrics,prometheus logging: pattern: level: %5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]几个参数需要解释一下。sampling.probability设为 0.1 意味着 10% 的请求会被完整追踪。为什么不是 1.0因为全量采样在日均千万级请求下OTLP Collector 的磁盘和网络开销扛不住。10% 的采样率配合“错误请求强制采样”策略既能覆盖大部分排查场景又不会把存储打爆。step: 30s是指标导出间隔。默认是 1 分钟但 Spring Boot 4 的OtlpMeterRegistry在 30 秒间隔下表现更稳定因为 Micrometer 的StepMeterRegistry在 1 分钟窗口下偶尔会出现步长对齐问题导致指标点丢失。日志 pattern 里的%X{traceId:-}表示如果 MDC 里没有 traceId就输出空字符串而不是null。这个:-语法是 Logback 的默认值写法很多人不知道结果日志里全是null。3.3 自定义 Observation 的埋点姿势Spring Boot 4 提供了ObservationRegistry和ObservationConvention两套 API。对于大多数业务场景我推荐用Observed注解它比手动创建 Observation 更简洁Observed(name order.create, contextualName create-order) public Order createOrder(OrderRequest request) { // 业务逻辑 }Observed的底层是ObservedAspect它会在方法执行前后自动创建和停止 Observation。但有个限制它只对 Spring 管理的 Bean 生效而且方法必须是 public 的。如果你在同一个类里调用this.createOrder()AOP 代理不会生效Observation 也不会创建。这是 Spring AOP 的经典坑和Transactional的自调用失效是同一个原因。对于需要手动控制 span 属性的场景可以注入ObservationRegistryObservation.createNotStarted(inventory.check, observationRegistry) .lowCardinalityKeyValue(warehouse, warehouseCode) .highCardinalityKeyValue(sku, skuId) .observe(() - { // 业务逻辑 });lowCardinalityKeyValue和highCardinalityKeyValue的区别很重要。前者会作为指标标签tag导出后者只作为追踪属性attribute。如果把skuId这种高基数的值放进 lowCardinality会导致指标时间线爆炸Prometheus 直接 OOM。我见过一个团队把用户 ID 放进 lowCardinality结果指标数量从 200 涨到 50 万采集器直接崩了。4. 源码级排错那些文档里不会写的坑4.1 TraceId 在异步线程里丢失怎么办这是被问得最多的问题。Spring Boot 4 的ObservationRegistry默认使用ThreadLocal存储当前 Observation跨线程时不会自动传递。比如你用Async或者CompletableFuture.supplyAsync新线程里的 MDC 是空的TraceId 自然就丢了。解决方案是使用ContextPropagatingTaskDecorator它是 Spring 6.1 引入的专门用来在ThreadPoolTaskExecutor里传递 Observation 上下文Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new ContextPropagatingTaskDecorator()); executor.initialize(); return executor; }ContextPropagatingTaskDecorator的源码在org.springframework.core.task包下它做了两件事在任务提交时捕获当前线程的Observation在任务执行时把它恢复到新线程的ThreadLocal里。注意它只传递 Observation不传递 SecurityContext 或自定义的 ThreadLocal那些需要额外的 TaskDecorator。如果你用的是 Reactor情况又不一样。Reactor 有自己的 Context 机制需要用Hooks.onEachOperator把 Observation 注入到 Reactor Context 里。Spring Boot 4 的ReactorObservationAutoConfiguration已经做了这件事但前提是你用的是WebFlux而不是WebMvc。混用两者时Reactor 的上下文传播会失效。4.2 OTLP 导出失败但日志里看不到错误OpenTelemetry 的 OTLP Exporter 默认使用 gRPC而 gRPC 的日志级别默认是INFO导出失败时只会在SEVERE级别输出。如果你只配置了logging.level.rootINFO这些错误会被吞掉。排查方法是临时把 gRPC 的日志级别调到FINElogging: level: io.opentelemetry.exporter.otlp: DEBUG io.grpc: DEBUG我遇到过一种情况OTLP Collector 的地址配错了但应用启动不报错只是追踪数据一直不出现。调了日志级别后才发现 gRPC 一直在报UNAVAILABLE: io exception。原因是 Collector 的 4317 端口只监听了 IPv6而应用解析otel-collector主机名时拿到了 IPv4 地址。解决办法是在 Collector 的配置里显式绑定0.0.0.0:4317。另一个常见问题是防火墙。gRPC 走的是 HTTP/2有些企业防火墙会拦截 HTTP/2 的明文流量。如果确认网络策略没问题可以试试把transport改成http/protobuf走 HTTP/1.1 的 4318 端口。虽然性能略低但兼容性更好。4.3 指标和追踪的 TraceId 对不上正常情况下同一个请求的指标和追踪应该共享同一个 TraceId。但如果你发现指标里没有 TraceId或者 TraceId 和追踪里的不一致大概率是因为指标采集和追踪采集的时机不同。Micrometer 的OtlpMeterRegistry是在 Observation 结束时才把指标推送到注册表而 OpenTelemetry 的 Span 是在 Observation 开始时创建的。如果 Observation 的stop()方法被调用了两次或者MeterObservationHandler的执行顺序在TracingObservationHandler之前就会出现 TraceId 丢失。检查ObservationRegistry里 handler 的顺序确保TracingObservationHandler的Order值小于MeterObservationHandler。Spring Boot 4 默认的顺序是Tracing 在前Meter 在后。如果你自定义了 Handler 并覆盖了默认顺序需要手动调整。提示可以用ObservationRegistry.getObservationHandlers()在启动时打印所有 Handler 的顺序确认没有异常。这个调试技巧帮我省了很多抓包的时间。4.4 日志文件里 TraceId 是空的但控制台有这个问题通常出现在 Logback 的多 appender 场景。如果你同时配置了ConsoleAppender和RollingFileAppender而pattern只在ConsoleAppender里定义了%X{traceId}文件里自然没有。检查logback-spring.xml里的 pattern 定义确保每个 appender 都引用了包含 MDC 的 pattern。更优雅的做法是定义一个property nameLOG_PATTERN value.../然后在所有 appender 里引用${LOG_PATTERN}。还有一种情况是异步 appender。Logback 的AsyncAppender默认会丢失 MDC因为 MDC 是 ThreadLocal 的异步线程拿不到。解决办法是给AsyncAppender加mdctrue/mdc配置或者改用LoggingEvent的getMDCPropertyMap()手动传递。5. 高频问题速查与实战建议5.1 常见问题对照表现象可能原因排查命令/配置追踪数据不出现OTLP 端点不通telnet otel-collector 4317TraceId 在日志里为空logging observation 未开启management.observations.enable.loggingtrue异步线程 TraceId 丢失缺少 TaskDecorator注册ContextPropagatingTaskDecorator指标时间线爆炸高基数标签检查lowCardinalityKeyValue的使用采样率不生效配置项拼写错误确认management.tracing.sampling.probabilitygRPC 导出超时防火墙拦截 HTTP/2改用http/protobuf传输Span 数量异常多内部方法被重复埋点检查Observed的自调用问题5.2 采样策略的进阶玩法固定采样率probability适合大多数场景但如果你需要“错误请求全采、正常请求低采”可以用Sampler接口自定义Bean public Sampler customSampler() { return Sampler.parentBased( Sampler.traceIdRatioBased(0.1) ); }parentBased的意思是如果父 Span 被采样了子 Span 也采样如果父 Span 没采样子 Span 按 10% 概率采样。这样能保证一个完整调用链要么全采要么全不采不会出现“半截链路”。更激进的策略是结合Baggage做动态采样。比如在网关层根据用户等级设置 Baggage然后在 Sampler 里读取 Baggage 决定采样率。VIP 用户 100% 采样普通用户 1% 采样。这个方案我在电商大促期间用过效果很好但要注意 Baggage 的传播开销每个请求会增加几十字节的 header。5.3 日志、指标、追踪的存储成本控制三件套全开之后存储成本是绕不开的问题。我的经验是日志保留 7 天指标保留 30 天追踪保留 3 天。追踪数据量最大但排查问题时通常只看最近几小时3 天足够覆盖“周末出问题周一才发现”的场景。如果用的是 Elasticsearch 存日志可以按天建索引用 ILMIndex Lifecycle Management自动删除旧索引。指标如果走 Prometheus用retention.time30d控制。追踪数据如果走 Jaeger它自带 Cassandra 或 Elasticsearch 的 TTL 配置。还有一个省钱的技巧把management.otlp.metrics.step从 30 秒改成 60 秒指标数据量直接减半。代价是告警延迟增加 30 秒对于非核心业务完全可以接受。5.4 我在迁移过程中踩过的三个坑第一个坑是spring-boot-starter-actuator和micrometer-tracing-bridge-otel的版本冲突。Spring Boot 4.0.0 的 BOM 里管理的是 Micrometer Tracing 1.2.x但如果你手动引入了 1.3.x 的micrometer-tracing-bridge-otelOtelTracer的构造函数会多一个参数启动时报NoSuchMethodError。解决办法是永远不要手动指定 Micrometer Tracing 的版本让 BOM 管理。第二个坑是Observed注解在接口方法上不生效。Spring AOP 默认使用 JDK 动态代理而 JDK 代理只能代理接口方法。如果你的Observed加在实现类的方法上但注入的是接口类型代理不会拦截。解决办法是改用 CGLIB 代理spring.aop.proxy-target-classtrue或者把注解加在接口方法上。第三个坑是 OTLP Collector 的批处理配置。默认情况下Collector 的batch处理器每 200ms 或 512 个 span 发送一次。在高并发下这个配置会导致 Collector 的 CPU 飙升。我把它改成send_batch_size: 1024和timeout: 5sCPU 占用从 80% 降到 30%。这个参数在 Collector 的config.yaml里配置不在 Spring Boot 侧。5.5 一个完整的排查案例上周有个同事问我为什么订单服务的追踪数据里/api/order这个接口的 span 没有子 span我让他先检查Observed的注解位置发现他加在了 Controller 方法上但 Service 层的方法没有加。追踪数据里只有入口 span没有内部调用链。然后我让他把Observed加到 Service 方法上重启后发现子 span 出现了但 TraceId 和入口 span 不一致。这是因为 Service 方法被Async包装了跨线程导致上下文丢失。加上ContextPropagatingTaskDecorator后问题解决。最后他发现指标里的order.create计数比追踪里的 span 数量多了一倍。原因是Observed和Timed同时加在了同一个方法上导致 Observation 被创建了两次。去掉Timed后指标和追踪对齐了。这个案例的教训是不要混用Observed和Timed前者已经包含了指标采集功能。如果你需要更细粒度的指标用Observation的lowCardinalityKeyValue添加标签而不是再叠一层Timed。5.6 生产环境部署的检查清单上线前建议逐项核对OTLP Collector 的地址和端口在应用配置里是否正确采样率是否根据流量规模调整建议从 0.01 开始逐步调大日志 pattern 是否包含%X{traceId}和%X{spanId}异步线程池是否配置了ContextPropagatingTaskDecorator指标的高基数标签是否已排查用户 ID、订单 ID 等Collector 的批处理参数是否根据 QPS 调优存储的 TTL 是否设置避免磁盘写满告警规则是否基于指标而非日志日志告警延迟高这套组合拳打下来Spring Boot 4 的可观测性才算真正落地。我个人的体会是不要追求“全量采集”而是根据业务价值分层采样不要把所有信号都塞进一个存储而是让日志、指标、追踪各司其职用 TraceId 做关联。最后再分享一个小技巧在开发环境把采样率设为 1.0生产环境设为 0.1通过配置中心动态调整这样排查线上问题时可以临时提高采样率不用重启应用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进