生产可观测与 API 文档:让服务"看得见、说得清"
本地跑通不等于能上线。上线之后你会立刻面对三个问题:它现在还活着吗?它慢在哪里?别人怎么知道它有哪些接口?这一章就是把这三件事的工程做法补齐——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 依赖并配 Sleuth | Boot 3 内置自动配置:HTTP 请求、JDBC、Redis、定时任务开箱即被观测 |
| 导出到哪里 | 需再配 Zipkin / 其他后端 | 通过 Micrometer Registry 导出:Prometheus(指标)、OTLP/Zipkin(追踪) |
自定义埋点:给关键业务方法加观测
论可观测三支柱,以及 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 提交号,就能省掉无数次"你确定发布了吗"的对话。
| 端点 | 类别 | 暴露建议 |
|---|---|---|
/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.* 里塞敏感信息:/actuator/info 常被当作"无害端点"放行到公网。如果你在里面写了 info.db.url、info.sso.secret,等于公开配置。只放版本、构建、环境标识这类可真公开的信息。
② 实现 InfoContributor 时做了重活:有人写一个"数据库连接数贡献者",每次访问 /info 都去查库。info 应该是静态的或缓存好的,动态数据属于 metrics 的范畴。
优雅停机:让正在处理的请求跑完
默认情况下,应用收到 SIGTERM 会立刻退出——正在处理的请求被硬生生切断,用户看到 502/连接重置。滚动发布时如果每个实例都这样,就会产生一批"发版期间随机失败"的请求。
| 阶段 | 优雅停机期间发生什么 |
|---|---|
| ① 停止接收新请求 | 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。
① 加了安全框架文档页面打不开: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 里更该用的方式。
写法一:Java Bean 风格(带默认值与校验)
写法二:Record 风格(不可变,Spring Boot 3 推荐)
怎么确认元数据真的生成了
| 要点 | 说明 |
|---|---|
| 松散绑定 | 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 可控得多。
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 或特定绑定机制生效。想要动态配置,需要引入配置中心客户端并显式声明可刷新作用域,同时评估"配置变更时业务是否能安全切换"。解析:很多"配置热更新失效"的问题,根因是把"能读到配置"和"能重绑配置"混为一谈了。