SpringBoot4系列24 - 使用Actuator与OpenTelemetry可观测性
上个月支付网关出了一次故障,客服那边的投诉先进来,我这边才反应过去翻日志。日志有,但散在四个服务实例里,想回答「昨晚十点支付接口慢在哪」,我得先找到慢的判断依据,再去四个实例里捞请求日志,最后把两次跨服务调用的日志按请求对上号。这三件事分别对应指标、链路、日志,工程上叫可观测性三件套。复盘的时候我意识到,排障有个更早的阶段:问题还没发生时,系统就该把三类信号持续吐出去,指标给告警提供阈值,链路给定位提供调用路径,日志给复盘提供细节。
Spring Boot 对这件事的答案是 Actuator 暴露端点、Micrometer 统一指标门面、OpenTelemetry 负责把链路和日志信号按 OTLP 协议导出。Boot 4.1 在这层动了不少手术:模块拆分、属性前缀改名、starter 重组,我从网上找来的 3.x 配置片段有一半直接抄过来不生效。所以这篇我把三件套在 Spring Boot 4.1.1 上完整接了一遍:starter 坐标取证、端点暴露、自定义指标、OTLP 三信号导出到本地假收集器、traceId 贯穿日志,端到端验证脚本的每个数字都在正文里对得上。
端点与暴露
动手接线的第一件事,是把三个 starter 的坐标钉下来。我翻了本地 Maven 仓库的制品和 spring-boot-dependencies 4.1.1 的 POM,抄录如下:
| |
actuator 的 starter 坐标没改名。挺有意思的是 OpenTelemetry 这次有了专属 starter,Boot 3 时代要手动凑四个依赖,4.1 一个坐标全带上。版本号一个都不用写,micrometer 1.17.1、micrometer-tracing 1.7.1、opentelemetry 1.62.0 三条版本线都在 Boot BOM 里管理。
模块拆分是 Boot 4 的大动作。OpenTelemetry 相关自动配置从 spring-boot-actuator-autoconfigure 里整体搬了出去,我在 actuator-autoconfigure 的 AutoConfiguration.imports 里搜了一遍,已经找不到任何 OTel 类。新家有两处:spring-boot-opentelemetry 模块(包名 org.springframework.boot.opentelemetry.autoconfigure)管 OpenTelemetrySdk 和日志导出;spring-boot-micrometer-tracing-opentelemetry 模块(包名 org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure)管链路导出,里面有 OpenTelemetryTracingAutoConfiguration 和 OtlpTracingAutoConfiguration 两个入口类。你要是还按 3.x 的包名去翻源码,直接换这两个包名找。
工程的依赖声明只有四个:
| |
webmvc 提供宿主接口,actuator 带出端点体系,opentelemetry starter 接通链路与日志的 OTLP 导出,prometheus registry 补上 Pull 模式的刮擦端点,四个依赖正好覆盖三件套的两个出口方向:Push 走 OTLP,Pull 走 prometheus。这里我顺带提醒一句,Boot 没有 prometheus 专属 starter,最后这一行要手动加。
starter 加完还不算完,默认只暴露 health 一个端点,这个默认值来自 spring-boot-actuator-autoconfigure 的配置元数据:management.endpoints.web.exposure.include 默认值就是 [‘health’]。演示工程我打开了四个:
| |
health 的 show-details 打开后,磁盘、线程池、liveness 这些组件明细会出现在响应里。我启动应用访问 /actuator/health,返回 status UP,components 里 diskSpace 带着总容量 484949618688 字节、剩余 441346002944 字节。生产环境按需收敛,health 明细可能泄露内部结构,配合 management.endpoints.web.exposure.exclude 或独立管理端口(management.server.port)做隔离都是常规做法。
prometheus 端点是文本刮擦格式,注册它的是 spring-boot-micrometer-metrics 模块里的 PrometheusMetricsExportAutoConfiguration。我刮了一次,59 个 TYPE 行,JVM 内存、Tomcat 线程池、磁盘、执行器指标全部在列。metrics 端点则是 JSON 格式的指标目录,先访问 /actuator/metrics 列出全部指标名,再访问 /actuator/metrics/<指标名> 读具体取值,这个两级结构我后面读自定义指标时会用到。
端点先暴露再谈安全。prometheus 文本和 metrics 目录都会完整展示内部指标结构,health 明细里的路径、容量属于环境信息。这三类端点放进生产之前,至少要做一层隔离:管理流量与业务流量分开,端口的来源限制到运维网段。工程里有 Spring Security 的话,端点权限用角色收口到运维角色;容器部署时把管理端口映射到内部网络,不经过业务负载均衡。演示工程为了验证方便我全部打开了,安全边界你自己按环境收紧。基础端点就位,下一步把业务信号接进来。
自定义指标
示例业务我选了订单域,五个接口:下单、查询、处理、慢请求、故意失败。慢请求固定睡 800 毫秒,失败接口抛 IllegalStateException,这两个是专门留出来观测慢样本和错误路径的。OrderService 里我注册了两个自定义指标:
| |
指标命名用点分小写,Micrometer 会在各导出口做命名映射:prometheus 端点里点号换成下划线,Counter 自动补 _total 后缀,Timer 自动带 _seconds 单位。所以 orders.placed 在 prometheus 里叫 orders_placed_total,orders.processing 叫 orders_processing_seconds。两套名字容易搞混:在 actuator 的 metrics 端点里读数用原始名,在 prometheus 文本里 grep 用映射后的名,两套名字对应同一个指标。
Counter 在下单时 increment 一次,Timer 用 record 包住处理逻辑:
| |
计时之外,处理逻辑还套了一层 Observation,这是我有意为之的。Observation 是 Micrometer 的统一观测 API,一次调用同时产出两个东西:指标里的耗时记录,链路里的一个子 Span。这是 Micrometer Tracing 路线的核心设计:业务代码面向 Observation 编程,指标和 trace 自动分流到各自的导出器,换存储后端不用改业务代码。lowCardinalityKeyValue 只放 stage 这类低基数值,你要是把订单号放进去,标签基数会把存储撑爆,这是自定义指标最常见的事故源。
验证脚本建了两笔订单、处理了一次,然后从 actuator 读回取值:
| |
orders.placed 计数 2.0,对上两笔订单。orders.processing 记了 1 个样本,总耗时 0.3028589 秒,对上 process 接口里 300 毫秒的 sleep 加业务开销。JSON 里的 statistic 有 COUNT、TOTAL_TIME、MAX 三种,分位数要等 prometheus 端点里看。
慢接口的 800 毫秒落在框架自带的 http.server.requests 指标里。每个 HTTP 请求框架默认都记一行,method、uri、outcome、status 四个标签起步,异常类名自动进 exception 标签。我按路由模板和结果过滤后读回:
| |
慢请求 811 毫秒、失败请求 5.9 毫秒加 500 状态。失败路径不用业务代码埋点,framework 层的观测已经把错误类别记全了。指标端点的查询参数里那个 {id} 是路由模板,不是展开后的订单号,我第一次就拿着展开后的订单号去查,白忙一场,这个坑后面细说。
注入 MeterRegistry 前后还有一层全局加工的机会。management.observations.key-values.region=cn-east 这类配置给全部观测挂公共标签,机房、版本号这类每实例一个值的属性用它带出去;代码侧的等价物是 MeterRegistryCustomizer,在指标注册前统一加 common tags 或设置计量规则。公共标签和业务标签的边界要想清楚:region 放标签,订单号放 trace 属性,前者每实例一个值,后者每请求一个值,混着用会把存储和查询都拖垮。
编程接口之外还有注解路线。@Observed、@Timed、@Counted 三个注解打在方法上,配合 management.observations.annotations.enabled=true 开启注解扫描,方法耗时与调用次数自动进指标。这里藏着一个静默失效的坑:注解扫描靠 AOP 实现,spring-boot-starter-aspectj(或 aspectjweaver)得先进依赖,只开属性不加依赖时注解不报错也不生效。我的用法是注解适合快速给存量方法补观测,编程式 Observation 适合需要动态键值的场景,两种写法产出同一种指标,后端无差别对待。
OTLP 链路导出
指标、链路、日志三个信号在 Boot 4.1 里有三套属性前缀,这件事我是从三个 jar 的 spring-configuration-metadata.json 里各抄了一份才敢下结论的:
| |
链路前缀是 management.opentelemetry.tracing.export.otlp.endpoint,Boot 3 的 management.otlp.tracing.endpoint 降级成兼容别名。日志前缀 management.opentelemetry.logging.export.otlp.endpoint,旧别名同理。指标前缀没动,还是 management.otlp.metrics.export.url,因为指标导出走 Micrometer 的 OtlpMeterRegistry,和 OTel SDK 的 SdkMeterProvider 完全无关。官方文档明确说过:Boot 不创建 SdkMeterProvider bean,OTel API 产生的指标不会被导出。还有个我差点栽进去的细节:三行 endpoint 都要写到 /v1/traces、/v1/metrics、/v1/logs 完整路径,OTel 导出器对信号专属 endpoint 不自动补路径,只写 host 会一个包都发不出去。
resource-attributes 两个键值得单独说。service.name 是后端里区分服务的唯一线索,OTLP 包里没有它,所有数据都会混在一起。这个属性同时喂两个体系:OTel 的 Resource bean 和 Micrometer OtlpMeterRegistry 的 resource attributes,我启动后翻日志,确认信息直接就在里面:
| |
环境变量也能进:OTEL_SERVICE_NAME 和 OTEL_RESOURCE_ATTRIBUTES 会被 OpenTelemetryEnvironmentVariableEnvironmentPostProcessor 读入并与属性合并,配置属性优先。management.opentelemetry.enabled 设 false 相当于 OTEL_SDK_DISABLED,全部信号退回 no-op 实现,context 传播保留。采样比例 management.tracing.sampling.probability 我设了 1.0,这是演示用法,生产按 0.1 左右权衡导出量,配合 schedule-delay 控制批量节奏。

三条导出链路的组件关系如上图:业务代码只面对 MeterRegistry、ObservationRegistry、SdkLoggerProvider 三个门面,出口侧四个导出器各管一段,属性前缀的三套体系对应图中三段箭头。
导出目标不是现成的收集器,我本机没有 OpenTelemetry Collector,所以验证脚本先用 Python 起了一个假收集器,代码不到 50 行,收 POST 计数不解析 protobuf:
| |
它只做一件事:按请求路径记条数和字节数,回 200。端到端验证跑完,假收集器的接收记录是这样:
| |
三个信号都有真实流量落地。traces 3 次导出对应建单、处理、慢请求、失败这几条请求链路的批量上报,schedule-delay 压到 2 秒,每个请求的 HTTP Span 加业务 Observation 产出的 orders.process 子 Span 都在里面;metrics 3 次对应 5 秒一个导出周期;logs 2 次是两笔订单的 OTLP 日志信号。字节数都在涨,导出路径实打实通了。
以 process 接口为例看一次请求在链路里的形状。GET /api/orders/1/process 进来,框架先开一个 HTTP 服务端 Span,名字是路由模板,带 method、uri、status、outcome 属性;业务代码里的 orders.process Observation 挂在这个父 Span 下面,300 毫秒的处理时长就是它和父 Span 的时间差。收集器侧拿到的每个批次里,Span 之间靠 traceId 和 parentSpanId 两个字段构成树,接进 Jaeger 或 Grafana Tempo 这类后端,就能看到一次请求从入口到业务处理的完整轨道。慢请求在轨道上一眼可见:父 Span 拉出 811 毫秒,子 Span 缺席说明慢在框架层与控制器之间,子 Span 长说明慢在业务内部,这就是链路信号对定位的实际贡献。

把时序展开成上图:一次下单请求同步经过的每个环节都有信号产出,三路导出按各自的批量窗口异步落地,底部一行是本轮验证的真实计数。
日志关联 traceId
日志信号在 Boot 4.1 里有个官方声明的边界,我原样抄在这里:SdkLoggerProvider bean 会自动创建,OTLP 日志导出器也接好了,但应用日志不会自动桥接进去。SLF4J 的每一行日志默认不会变成 OTel LogRecord 发出去。要让 /v1/logs 有流量,两条路:引入 OTel 的 logback appender 桥接(io.opentelemetry.instrumentation 系,版本线归 instrumentation BOM 管,不在 Boot BOM 里),或者业务代码直接用 OTel Logger API 发射。演示工程我选了后者,零额外依赖:
| |
构造器用 ObjectProvider 注入 SdkLoggerProvider,测试场景关掉 OTel 自动配置时 bean 缺席,退回 no-op 实现不炸启动。emit 按批聚合,management.opentelemetry.logging.export.schedule-delay 默认 1 秒,发射后最多等 1 秒就能在收集器侧看到计数。setContext(Context.current()) 把当前链路上下文挂上去,导出的 LogRecord 自带 traceId,日志和 trace 在后端能直接对上。
控制台日志的 traceId 是另一件事,靠 Micrometer Tracing 写 MDC 完成。链路开启后,请求线程里的业务日志会自动带上 MDC 键值,logging pattern 里显式带出:
| |
验证脚本数了一遍,应用日志 19 行带 traceId,抽三条看格式:
| |
请求线程里的业务日志全部有 traceId 和 spanId,两条日志同一个 traceId 就能判定同一条请求链路,这就是跨实例对账的铁证,排障时先按 traceId 聚合再按时间排序,调用次序一目了然。最后一条 ERROR 是失败接口抛的,traceId 为空,我查了下原因:异常已经抛出请求线程,Tomcat 的错误处理线程没有链路上下文。这行 ERROR 是全脚本唯一的 ERROR,来自故意失败接口,属于验证目标本身。
控制台之外还有第三种出口:logging.structured.json 系列属性能把日志直接格式化成 JSON,traceId 作为字段输出,接入日志平台时比正则解析文本模式省事。三种出口(文本模式、结构化 JSON、OTLP 信号)共享同一个 MDC 与链路上下文,按需选用。
线程池与异步代码是上下文断裂的高发区。@Async 方法进线程池后 traceId 会丢,spring.task.execution.propagate-context=true 让 Boot 自动装配的 AsyncTaskExecutor 把上下文带过去;Reactor 管线对应 spring.reactor.context-propagation=auto。这两处不开,链路一到异步边界就断成两截,日志的 traceId 同步丢失。你要排查异步超时问题,先查这两行配置。
完整案例
整条接线的全貌先看架构图:

把上面的部件串成一次端到端验证。脚本共 11 步:起假收集器、起应用、health 检查、建两笔订单、读自定义指标、读慢请求与失败指标、等导出落地、数日志 traceId、关服务。我给脚本配了五类断言:端点可访问性、指标取值与业务动作对账、prometheus 文本与 metrics 端点同源、OTLP 三信号接收计数、日志 traceId 覆盖率。应用启动耗时 2.889 秒,全部取值来自同一次运行,数字之间互相印证,任何一处对不上都说明接线有问题。
mvn test 有 6 个端到端用例,真实启动 Tomcat 随机端口逐项验证:health 明细可读;创建两笔订单后 orders.placed 计数不低于 2;process 接口计时后 orders.processing 有样本;fail 接口返回 500 且 prometheus 文本里出现 outcome=“SERVER_ERROR” 与 uri="/api/orders/{id}/fail";prometheus 端点含 orders_placed_total 与 http_server_requests_seconds_count;不存在的订单返回 404。测试上下文里用三个属性关掉 OTLP 导出(management.opentelemetry.enabled=false 加两个导出开关),避免没有收集器时的告警噪音。
prometheus 端点共 59 个 TYPE 行,自定义指标的输出长这样:
| |
完整取值面板如下,四个分区自上而下对应指标读数、prometheus 文本、OTLP 计数与日志抽查:

orders_placed_total 2.0 与 metrics 端点读数一致,一个来源两个出口,Pull 和 Push 各走各的。orders_processing 的 p50 和 p95 都是 0.301989888 秒,样本只有一个,两个分位数收敛到同一个值,这是正常现象;慢请求进来自动就能看到 p95 抬升,样本攒到几十个后分位数才呈现梯度。
验证产物我都留了底:验证脚本一条命令跑完整链路,跑完把产物(prometheus 文本、OTLP 接收计数、应用日志)原样存档,换环境复跑一遍就能对比取值。
这套接线的最后一步是换掉假收集器。OpenTelemetry Collector 官方发行版一个容器就能起,OTLP receiver 默认监听 4318,把三行 endpoint 里的 127.0.0.1 换成收集器地址,应用侧一行代码不改。收集器把 metrics 转给 Prometheus、traces 转给 Tempo、logs 转给 Loki,三件套在后端各就各位;假收集器验证过的接线部分(属性前缀、路径、批量节奏、traceId 注入)全部原样生效,替换的只是包的落点。告警规则建在指标上:http_server_requests_seconds_count 按 outcome 分组做增速告警,orders_placed_total 突降说明下单入口出问题,这些规则的取值口径与本文验证脚本读到的数字完全一致。
导出数字本身也挺有意思,我单独拎出来复盘。logs 计数 2 恰好等于建单次数 2,说明两笔订单的 OTLP 日志记录在 1 秒批量窗口里各自成批发出;traces 计数 3 小于请求数 6,是因为 schedule-delay 的批量把多个请求的 Span 合进同一批,收集器侧看到的是批次不是单个请求。你排查「导出量对不上请求数」时先看批量参数,再看采样比例,两个旋钮都在 management.opentelemetry.tracing.export 下面。
避坑指南
下面每一条都是我这次实测亲手踩到或差点踩到的,不是文档摘抄。
坑一,属性前缀三套并存,抄错一个信号全哑。 链路是 management.opentelemetry.tracing.export.otlp.,日志是 management.opentelemetry.logging.export.otlp.,指标是 management.otlp.metrics.export.*。照 3.x 老配置抄 management.otlp.tracing.endpoint,链路导出静默不生效,旧前缀只是兼容别名,文档里已不推荐。第一次接 OTLP 导出扑空,九成是这个原因。判别办法我吃了一次亏才记住:解开目标 jar 看 spring-configuration-metadata.json,别信记忆。
坑二,OTLP endpoint 要写完整信号路径。 只配 http://127.0.0.1:4318,traces 一个包都发不出。通用环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 会被自动追加 /v1/traces,但信号专属属性按字面使用。三行配置都带 /v1/xxx 后缀最稳。
坑三,计数器名以 created 结尾会被吞。 我把 Counter 起名 orders.created,prometheus 端点导出的名字却是 orders_total,created 这一节被 Prometheus 命名约定当成 OpenMetrics 的时间戳后缀处理掉了。后来改名 orders.placed,导出名 orders_placed_total 才正常。
坑四,应用日志不会自动桥接 OTLP。 SdkLoggerProvider 和导出器自动就位,/v1/logs 却一个包都没有。SLF4J 到 OTel 的桥接要引 instrumentation 系的 logback appender,或者像本文用 Logger API 手动发射。以为 starter 一加日志就上云的,等排障时才会发现日志平台是空的。
坑五,日志导出开关的默认值要查清。 management.logging.export.otlp.enabled 默认 true,配上 endpoint 就生效。测试环境没有收集器时会持续告警,我给测试用了三个属性整体关掉 OTel 导出,context 才安静。
坑六,http.server.requests 的 uri 标签是路由模板。 过滤失败请求时查 uri:/api/orders/1/fail 永远查不到,真实标签值是 uri:/api/orders/{id}/fail。metrics 端点的查询参数里传大括号还有二次坑:RestTestClient 把它当 URI 变量展开报错,curl 发裸大括号被 Tomcat 400 拒掉,得用 %7B%7D 编码。我从 prometheus 文本断言,最省事。
坑七,OTel 自动配置类的包名换了地方。 Boot 3 在 org.springframework.boot.actuate.autoconfigure.opentelemetry,4.1 搬到 org.springframework.boot.opentelemetry.autoconfigure 与 org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure。IDE 里按老包名排除自动配置会扑空。
坑八,注入 MeterRegistry 时留意注册的是哪个 registry。 主运行时同时存在 Simple、Prometheus、OTLP 多个 registry,业务代码拿到的 MeterRegistry 是组合视图,写进去各出口都能看到。单独断言某个 registry 的内容时,从对应端点读,别直接看业务代码里的对象。
坑九,Observation 的高基数标签撑爆指标。 order.id 这类每请求一个值的键,放进 lowCardinalityKeyValue 会让指标时间线爆炸。Observation 的键值分高基数低基数两档,订单号这类只能进 trace 的 attribute,指标标签只放 stage、outcome 这类有限枚举。
坑十,OTLP 导出失败不阻断业务。 收集器挂掉时应用照常服务,导出器按批重试后打 WARN(io.opentelemetry.exporter 调到 WARN 级别降噪)。这是可观测性组件的正确姿势,但也意味着信号丢了不会有人报错,收集器的存活要靠平台侧监控。
坑十一,分位数样本太少没有意义。 publishPercentiles(0.5, 0.95) 在样本只有 1 个时两个分位数输出同一个值。看 p95 要么攒样本,要么改用 percentileHistogram 交给后端聚合。
坑十二,prometheus 指标需要手动补依赖。 Boot 没有 prometheus starter,micrometer-registry-prometheus 不在依赖里时,exposure.include 写了 prometheus 也不会有这个端点,Actuator 的端点注册以对应 registry 的存在为前提。
坑十三,测试期记得整体关掉 OTel 导出。 @SpringBootTest 默认继承主配置,三行 endpoint 照样生效,测试期间每个用例都在往不存在的收集器发包。management.opentelemetry.enabled 一个开关退回 no-op,比逐个关导出器干净。
小结
三件套怎么接,我的建议按信号分头走。指标这条线最省心,Micrometer 门面稳定,Counter 和 Timer 声明在业务代码,actuator 端点读数、prometheus 刮擦、OTLP 推送三个出口共享同一份数据,接入成本接近零;链路走 Observation,一个 API 同时产出指标和 Span,业务代码不用关心后端是谁,采样比例和批量节奏按导出量权衡;日志最特殊,traceId 进控制台日志靠 MDC,进 OTLP 靠显式发射,SLF4J 桥接是官方声明不做的部分,接入前先把这条边界弄清楚。从 3.x 迁 4.x 的项目,把属性前缀的三套体系、starter 的模块重组、以 created 结尾的指标名陷阱过一遍,雷区基本就排完了。
没解决的事也直说:假收集器只证明包发到了本机 4318 端口,后端侧我还没接真 Collector,告警规则和可视化面板都停在纸面上。等我把 Collector 加 Prometheus、Tempo、Loki 的组合在自己环境里跑通,用同一套验证脚本对着真后端再测一轮,到时候回来补后端侧的接线实录。
参考链接
- Spring Boot 4.1 Actuator 文档:端点暴露与 management 属性总览
- Spring Boot 4.1 Observability 文档:OTel 三信号属性与环境变量映射表
- Micrometer Observation 概念:Observation API 与指标/链路分流设计
- OpenTelemetry OTLP 规范:/v1/traces、/v1/metrics、/v1/logs 协议定义
- spring-boot-starter-opentelemetry 4.1.1(Maven Central):本文坐标与依赖清单的制品实证来源