楼层: 首页/ 软件技术/ Spring Boot 3.5/ 生产可观测与 API 文档:让服务"看得见、说得清"
17

生产可观测与 API 文档:让服务"看得见、说得清"

Actuator · Micrometer · springdoc

本地跑通不等于能上线。上线之后你会立刻面对三个问题:它现在还活着吗?它慢在哪里?别人怎么知道它有哪些接口?这一章就是把这三件事的工程做法补齐——Actuator 回答健康与运行状态,Micrometer 回答"慢在哪里",springdoc 回答"接口是什么"。

Actuator + Micrometer Observation:Boot 3 为什么不用 Sleuth 了

先把这个容易踩的坑说清楚:网上大量讲 spring-cloud-starter-sleuth 的教程,在 Boot 3 里已经不适用了。Sleuth 项目已完成使命,链路追踪能力被上移到 Spring 生态的标准观测 API——Micrometer Observation。

维度旧方案(Sleuth)新方案(Observation API)
所在项目spring-cloud-starter-sleuth(Spring Cloud 体系)micrometer-observation,由 Boot 的 Actuator starter 直接带来(不依赖 Spring Cloud)
核心抽象Trace / Span 两层,绑定到 Brave 或 OTel 实现Observation 一个概念统管 Metrics 与 Traces
埋点方式Tracer.nextSpan() 之类,与具体追踪库耦合ObservationRegistry + @Observed 注解,同一段代码同时产出指标和追踪
与 Boot 集成要额外引 Spring Cloud 依赖并配 SleuthBoot 3 内置自动配置:HTTP 请求、JDBC、Redis、定时任务开箱即被观测
导出到哪里需再配 Zipkin / 其他后端通过 Micrometer Registry 导出:Prometheus(指标)、OTLP/Zipkin(追踪)
<!-- 指标:Actuator + Prometheus 注册表 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> <!-- 追踪:用 OTLP 导出到 OpenTelemetry Collector(推荐) --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> </dependency>
management: # 采样率:生产千万别设 1.0,全量采样会把存储和带宽打爆 tracing: sampling: probability: 0.1 observations: key-values: application: ${spring.application.name} otlp: tracing: endpoint: http://otel-collector.internal:4318/v1/traces endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: when-authorized probes: enabled: true

自定义埋点:给关键业务方法加观测

@Service public class PaymentService { // ① 注解式:方法被调用时就产生一个 observation // 自动带来耗时指标(payment.duration)与一条 span @Observed(name = "payment", contextualName = "pay-order") public PayResult pay(PayCommand cmd) { return gateway.submit(cmd); } // ② 编程式:需要加自定义标签(如渠道、是否成功)时用 private final ObservationRegistry registry; public PaymentService(ObservationRegistry registry) { this.registry = registry; } public PayResult payWithTags(PayCommand cmd) { return Observation.createNotStarted() .observationConvention(new DefaultPaymentConvention()) .lowCardinalityKeyValue("channel", cmd.getChannel()) // 低基数:渠道枚举 .observe(registry, () -> gateway.submit(cmd)); // 注意:标签值不要用用户 ID、订单号这类高基数值 // 否则 Prometheus 的时序数量会爆炸 } }

论可观测三支柱,以及 Observation API 真正解决的问题

① 三支柱各回答一个问题:Metrics(指标)回答"整体情况怎么样"——QPS、错误率、P99 延迟;Traces(追踪)回答"这一次请求慢在哪一步";Logs(日志)回答"当时到底出了什么错"。三者缺一不可:只有指标会不知道根因,只有追踪会看不到全局。

② 以前的痛点是"要写两遍代码":加一个指标要 MeterRegistry.counter(...),加一个追踪要 Tracer.nextSpan(),两套 API、两套上下文,容易漏、容易不一致。

③ Observation API 的贡献是"一次埋点、两种产出":一个 Observation 同时驱动 Metrics 与 Traces。业务代码只关心"我在观测什么",具体产出什么、发到哪里,由配置决定。这是从"工具耦合"走向"语义抽象"的一步。

④ 别忘了:Boot 3 已经帮你埋好了大部分关键点。HTTP 请求、JDBC 查询、Redis 命令、@Scheduled 任务、连接池状态都自动产生指标和 span。你需要自己埋的,只有"业务语义"这层——支付成功率、库存扣减耗时、消息处理延迟。

端点暴露与自定义 /actuator/info:把"是哪个版本"钉死

线上排查第一步永远是"现在跑的是哪个版本"。如果 /actuator/info 里能直接看到构建时间与 Git 提交号,就能省掉无数次"你确定发布了吗"的对话。

management: endpoints: web: exposure: # 只开需要的。写 "*" 等于把 env/heapdump/threaddump 一起公开 include: health,info,metrics,prometheus,loggers exclude: env,beans,configprops,heapdump,threaddump,shutdown base-path: /actuator endpoint: health: show-details: when-authorized # 默认 never;always 会泄露内网地址与依赖清单 probes: enabled: true info: env: enabled: true info: app: name: order-service version: @project.version@ # Maven 资源过滤注入 java: version: ${java.version} # 强烈建议:管理端点走独立端口,只在内网开放 server: port: 8081 address: 127.0.0.1
<!-- 让 /actuator/info 自动带上 Git 信息 --> <plugin> <groupId>io.github.git-commit-id</groupId> <artifactId>git-commit-id-maven-plugin</artifactId> <configuration> <generateGitPropertiesFile>true</generateGitPropertiesFile> <failOnNoGitDirectory>false</failOnNoGitDirectory> <!-- CI 里没有 .git 时不失败 --> </configuration> </plugin>
# 配置完后,一条命令看出"线上到底是哪个提交" curl -s localhost:8081/actuator/info | jq # { # "app": { "name": "order-service", "version": "1.4.2" }, # "git": { "branch": "release/1.4", # "commit": { "id": "3f9c1ab", "time": "2026-09-20T10:12:33Z" } }, # "java": { "version": "21.0.4" } # }
端点类别暴露建议
/health只读、无敏感信息(当 show-details=never)可暴露给 LB / K8s 探针。要 always 时务必限制访问来源。
/info只读元信息可暴露。前提是别把 AK/SK 之类塞进 info.* 配置里。
/metrics、/prometheus运行时指标限内网,供 Prometheus 抓取;公网暴露等于把内部结构图送人。
/loggers可写:能动态改日志级别限内网 + 鉴权。排障利器,但属于"能改变运行行为"的接口。
/env、/configprops、/beans配置与容器内部结构不要暴露。可能含明文密钥,脱敏并不完全可靠。
/heapdump、/threaddump内存与线程快照绝对不要暴露。heapdump 里能捞出内存中的密码和令牌。
/shutdown可写:远程关闭应用保持禁用。优雅停机用 server.shutdown=graceful,不要开这个远程开关。
自定义 info 贡献者与探针的两个坑

① info.* 里塞敏感信息:/actuator/info 常被当作"无害端点"放行到公网。如果你在里面写了 info.db.url、info.sso.secret,等于公开配置。只放版本、构建、环境标识这类可真公开的信息。

② 实现 InfoContributor 时做了重活:有人写一个"数据库连接数贡献者",每次访问 /info 都去查库。info 应该是静态的或缓存好的,动态数据属于 metrics 的范畴。

优雅停机:让正在处理的请求跑完

默认情况下,应用收到 SIGTERM 会立刻退出——正在处理的请求被硬生生切断,用户看到 502/连接重置。滚动发布时如果每个实例都这样,就会产生一批"发版期间随机失败"的请求。

server: shutdown: graceful # 关键开关:先停止接收新请求,再等待在途请求完成 spring: lifecycle: timeout-per-shutdown-phase: 30s # 每个阶段最多等 30 秒 # Tip:Java 21 上可以顺手开启虚拟线程,对高并发 I/O 场景提升明显 threads: virtual: enabled: true
阶段优雅停机期间发生什么
① 停止接收新请求Web 容器停止接受新连接(对 Tomcat 来说是暂停 connector)。此时健康检查仍然在响应,等你被摘出负载均衡。
② 等待在途请求已进入的请求继续执行,最长等 timeout-per-shutdown-phase。超时未完成的会被强制中断。
③ 关闭 Bean按依赖逆序销毁,@PreDestroy 里可以主动做收尾(停止拉取消息、断开 MQ 连接、通知下游)。消费类组件要在这里停止拉新消息,否则会拉到一半就断。
④ 进程退出返回退出码,容器编排平台认为该副本已下线。

论单开 graceful 还不够,必须配上 preStop 与 readiness 探针

① 顺序反了会白等:如果 K8s 还在往这个实例打流量,你把 connector 关了,新请求就会打进来但拿不到响应。正确顺序是:先让 readiness 探针失败 → 等负载均衡把流量摘掉 → 再关闭 connector 与进程。K8s 里就是"收到 SIGTERM 前,preStop 钩子先 sleep 几秒让 LB 收敛"。

② 超时时间要和编排平台对齐:timeout-per-shutdown-phase 必须小于 K8s 的 terminationGracePeriodSeconds(默认 30 秒)。否则 K8s 在进度中途就 SIGKILL,优雅停机降级为强制杀进程。经验值:停机等待 20~25 秒,K8s grace period 给 40~45 秒。

③ 长连接和长任务要单独处理:WebSocket 连接、SSE 流、正在执行的长耗时定时任务不会因为停机而暂停。做法:在 @PreDestroy 或健康状态里标记"正在下线",向客户端推送"服务将重启请重连",并让定时任务抢占一个"停止调度"标志。否则用户会看到连接毫无预兆地断掉。

④ 优雅停机只保证"不切断",不保证"不重复":正在消费消息的任务如果在停机前完成了业务处理但没能提交位点,重启后会重复消费。所以消息消费者本来就必须幂等,优雅停机只是减少了重复概率,不是免掉幂等需求的理由。

springdoc-openapi:接口文档跟着代码走

前面 Spring 核心页已经讲过注解怎么用,这一节补 Boot 项目里的集成细节:路径怎么改、生产怎么关、多实例/网关下要注意什么。

再强调一次:Boot 3 不要用 springfox(早已停止维护,与 PathPatternParser、jakarta.* 不兼容)。用 springdoc 的 webmvc-ui starter。

springdoc: api-docs: path: /v3/api-docs # OpenAPI JSON 地址 enabled: true swagger-ui: # 界面入口。默认访问 /swagger-ui.html 会 302 到 /swagger-ui/index.html path: /swagger-ui.html operations-sorter: method # 按 HTTP 方法排序,比字母序好找 tags-sorter: alpha try-it-out-enabled: true # 允许在页面上直接发起请求 filter: true # 显示搜索框,接口多时必备 # 生产环境必须关掉(或用 profile 隔离) swagger-ui.enabled: false # 用 profile 做隔离的常见做法:只在非生产 profile 打开 --- spring: config: activate: on-profile: prod springdoc: api-docs.enabled: false swagger-ui.enabled: false
// 安全配置里给文档路径放行(仅非生产 profile 生效) @Bean @Profile("!prod") public SecurityFilterChain docChain(HttpSecurity http) throws Exception { http .securityMatcher("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**") .authorizeHttpRequests(a -> a.anyRequest().permitAll()) .csrf(AbstractHttpConfigurer::disable); return http.build(); }
文档在 Boot 项目里的四个坑

① 加了安全框架文档页面打不开:Swagger UI 的静态资源与 /v3/api-docs 也要在 SecurityFilterChain 里 permitAll,否则页面白屏或只有一堆 401。但放行范围要精确到这几个路径,别顺手 /swagger/** 匹配过宽。

② 生产忘了关,接口清单外泄:接口路径、参数结构、错误码、甚至示例值全都公开,攻击者连目录扫描都省了。把"生产必须关闭 springdoc"写进发布检查清单。

③ 网关后面文档里的服务器地址是错的:前端从网关访问,文档里的 Try it out 却直接打到了后端地址(或 localhost)。解法:配 Server 对象指定外部可访问的 base url,或通过 X-Forwarded-* 头让框架推断。

④ 注解里的 example 和真实校验冲突:比如 @Schema(example = "20260920") 而字段是 LocalDate。联调时前端会把 example 当契约,导致误报 bug。example 一定要是能真正通过校验的值。

@ConfigurationProperties + configuration-processor:把配置变成有类型的对象

很多人还在用一堆 @Value("${...}") 散在各个类里。问题有三个:没有类型、没有校验、没有补全。写错一个属性名,要到运行时才报错。@ConfigurationProperties 把一组相关配置收成一个类型安全的对象,是 Boot 里更该用的方式。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> <!-- 只用于编译期生成元数据,不打进运行时 --> </dependency> <!-- Gradle 用 annotationProcessor 配置 --> <!-- annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' -->

写法一:Java Bean 风格(带默认值与校验)

@ConfigurationProperties(prefix = "app.payment") @Validated public class PaymentProperties { // JSR-303 校验:启动时就失败,比上线后第一次调用才发现好得多 @NotBlank private String gatewayUrl; @Min(1) @Max(60) private int timeoutSeconds = 5; // 默认值写在字段上 @NotNull private Retry retry = new Retry(); public static class Retry { private int maxAttempts = 3; private Duration backoff = Duration.ofMillis(200); // getter / setter 省略 } // getter / setter 省略 } // 启动类或配置类上开启扫描 @SpringBootApplication @ConfigurationPropertiesScan // 自动扫描 @ConfigurationProperties,省掉 @EnableConfigurationProperties public class Application { }

写法二:Record 风格(不可变,Spring Boot 3 推荐)

@ConfigurationProperties(prefix = "app.payment") public record PaymentProps( @NotBlank String gatewayUrl, @DefaultValue Duration timeout, Retry retry ) { // record 是构造器绑定:必填项缺失时启动直接失败,不会留个 null 到处传 public record Retry(@DefaultValue("3") int maxAttempts, @DefaultValue("200ms") Duration backoff) { } }
# application.yml:IDE 里会有属性名补全与类型校验(元数据生效时) app: payment: gateway-url: https://pay.internal/v2 timeout: 5s retry: max-attempts: 3 backoff: 200ms

怎么确认元数据真的生成了

# 编译后检查生成的文件是否存在 ls target/classes/META-INF/spring-configuration-metadata.json # 内容大致长这样:你的每个属性都有 name / type / description # { "name": "app.payment.timeout", "type": "java.time.Duration", # "description": "支付网关超时时间", "defaultValue": "5s" } # 顺着这个文件还能反向检查:写错属性名时 IDE 会标红
要点说明
松散绑定gateway-url、gatewayUrl、GATEWAY_URL 都能绑到同一个字段,环境变量场景很关键。
构造器绑定 vs Setter 绑定只有一个有参构造器时走构造器绑定(record 天然如此),否则走 setter。record 风格下必填项缺失会启动失败,比留 null 更安全。
@DefaultValue为构造器绑定提供默认值。注意它的值是字符串,Duration 之类按 "200ms" 的格式写。
元数据文件的作用spring-configuration-metadata.json 生成在 META-INF 下,IDE 靠它做属性补全、文档工具靠它生成配置表。没有它,开发者只能去翻源码或猜。
调试期查看实际值不要靠 /actuator/env 去查(那是高危端点)。直接用 IDE 断点或启动时打印一次脱敏后的配置摘要。
配置属性的四个坑

① 忘了 @ConfigurationPropertiesScan 或 @EnableConfigurationProperties:属性类不会被注册,注入时"找不到 Bean"。这是这一节最常见的启动失败。

② 在 @ConfigurationProperties 类上加 @Component:能用,但会让这个纯粹的配置载体承担 Bean 角色,也使它在测试里难以单独构造。推荐用 @ConfigurationPropertiesScan 统一扫描。

③ 以为配置属性会热更新:@ConfigurationProperties 绑定在启动时完成,运行期改配置文件不会生效。需要动态配置请用配置中心客户端并显式刷新(@RefreshScope 之类),别指望它自动重绑。

④ 把密钥写进配置并在 /actuator/env 暴露:就算有脱敏,属性源列表本身也是信息泄露。密钥应通过环境变量或密钥管理服务注入,且 env 端点保持不暴露。

记
生产可观测与文档一句话总结

① Boot 3 用 Micrometer Observation,不用 Sleuth;Observation 一次埋点同时产出指标与追踪,关键埋点 Boot 已自动完成。

② 追踪采样率生产务必下调(如 0.1),全量采样会打爆存储;标签值不要用高基数(用户 ID、订单号)。

③ Actuator 默认只开 health/info,要什么显式开什么;env/heapdump/shutdown 永远不开。

④ 把版本与 Git 提交号接进 /actuator/info,这是排查"到底发的哪个包"最省事的一招。

⑤ 优雅停机要配 server.shutdown=graceful + timeout-per-shutdown-phase,且超时必须小于 K8s 的 grace period;顺序是"先摘流量、再停进程"。

⑥ Boot 3 的文档用 springdoc,路径用 springdoc.api-docs.path 与 springdoc.swagger-ui.path 控制;生产必须关闭。

⑦ 配置用 @ConfigurationProperties + configuration-processor:有类型、有校验、有补全,比满屏 @Value 可控得多。

章末面试 · 可观测与配置(4 题)

1.(概念题)Spring Boot 3 为什么不再需要 Sleuth?Observation API 解决了什么问题?

查看答案

答案:Sleuth 属于 Spring Cloud 体系,把链路追踪与具体实现(Brave/OTel)耦合在一起,而且指标与追踪要写两套 API。这套能力被上移为 Spring 生态标准的 Micrometer Observation:一个 Observation 同时驱动 Metrics 与 Traces,业务代码只描述"在观测什么",产出形式与导出去向交给配置决定。Boot 3 的 Actuator starter 直接带来它,不依赖 Spring Cloud。解析:关键回答是"一次埋点、两种产出,且与具体追踪库解耦"。

2.(排错题)给一个接口加了耗时观测,线上发现 Prometheus 的时序数量暴涨,几周就把存储打满。最可能的原因是什么?

查看答案

答案:标签(tag)用了高基数值,例如把用户 ID、订单号、请求 ID 当成 label。Prometheus 会为每一组不同的标签组合创建一条独立时序,用户 ID 有多少个就产生多少条时序,指标数量瞬间爆炸。修法:① 标签只用低基数维度(渠道、状态码、接口路径模板而不是真实路径);② 高基数的明细信息放进追踪的 span 属性或日志,而不是指标标签;③ 检查是否有把 @PathVariable 的原始值写进 URI 标签——应该用路径模板(/orders/{id})而不是 /orders/1001。解析:"标签必须是低基数"是使用指标时的第一原则。

3.(场景题)配置了 server.shutdown=graceful,但滚动发布时用户仍偶发收到 502。列出至少三个可能原因。

查看答案

可能原因:① 顺序不对——K8s 还在把流量打进这个实例时进程就关了 connector,新请求打进来却没有响应;应该先用 readiness 失败摘流量、或用 preStop 钩子 sleep 几秒收敛,再停机。② 超时不匹配——timeout-per-shutdown-phase 大于 K8s 的 terminationGracePeriodSeconds,K8s 提前 SIGKILL,优雅停机失效。③ 长连接没处理——WebSocket/SSE 连接不会因为停机而暂停,客户端只看到异常断开。④ 请求超过了等待时间——有慢接口跑 60 秒而等待只给了 30 秒,被强制中断。解析:优雅停机是一个"端到端链路"的配合动作,缺任何一环都会漏请求。

4.(判断题)用 @ConfigurationProperties 写的配置类,改了 application.yml 里的值,线上通过 /actuator/refresh 就能生效。对吗?

查看答案

答案:不对(至少不是自动成立的)。@ConfigurationProperties 的绑定发生在启动时,运行期修改配置文件或环境变量不会自动重绑。/actuator/refresh 是 Spring Cloud Context 提供的端点,且只对 @RefreshScope 标记的 Bean 或特定绑定机制生效。想要动态配置,需要引入配置中心客户端并显式声明可刷新作用域,同时评估"配置变更时业务是否能安全切换"。解析:很多"配置热更新失效"的问题,根因是把"能读到配置"和"能重绑配置"混为一谈了。