跳到正文

企业级可观测性与故障定位 ​

一句话:可观测性不是多接几套监控,而是把日志、指标、追踪这三件事各归其位:日志回答「发生了什么」,指标回答「有多严重、多频繁」,追踪回答「慢在哪一跳」;换来的是分钟级定位,代价是上下文透传、采样和存储要长期投入。

适用版本:Spring Boot 3.2.x / Spring Cloud 2023.0.x / Micrometer 1.12.x / Micrometer Tracing 1.2.x。下文 x-trace-id、x-debug 等 Header 名和 TraceContext 等类名均为示例约定,落地时按团队规范替换。

为什么需要 ​

一次下单超时,网关只留下 504,服务 A 说调 B 超时,B 说数据库慢,C 没有日志。四个应用、多台实例,只能挨个 grep,最后先重启一台「试试」——证据没了,问题还在。根因不是「日志不够多」,而是三个问题被混在一起回答:只靠日志能看清单条请求,却算不出错误率,聚合要扫全量,成本随流量线性涨;只靠指标能做聚合和告警,却还原不出一条请求经过了哪些服务;只靠追踪能看调用链,却默认采样,也不记录业务结果,判断不了影响面。所以三者不互相替代,而是用同一个 traceId 串起来,各存各的。

核心机制 ​

三件事的分工 ​

信号回答的问题适合不适合
日志单条请求发生了什么、异常栈和关键参数定位失败原因、审计关键状态变化聚合统计、告警(成本高、基数大)
指标有多严重、多频繁、趋势如何聚合、看板、告警、容量规划还原单条请求的调用路径
追踪慢在哪一跳、每跳耗时和父子关系跨服务定位瓶颈、看依赖拓扑精确告警、审计(采样导致缺失)

上下文模型 ​

一次请求带五个字段(示例约定):traceId 标识整条链路,跨服务不变;spanId 标识当前这一跳;parentSpanId 指向上游,用来还原调用树;sampled 决定是否上报,入口决定后向下传递,避免只有半条链路;debug 用于临时强制记录。入口(网关或第一个过滤器)读取并校验上游 Header,没有就生成;跨进程只靠 Header 传递;同进程用 ThreadLocal(或 OpenTelemetry Context)保存,并镜像一份到 MDC 供日志框架读取。只有 traceId 没有 parentSpanId,只能知道「同一次请求」,还原不出先后和父子关系。外部客户端传来的 traceId 不能无条件信任:在网关重新生成或做格式校验,避免伪造 ID 造成日志串链或注入。

透传点:每个点都可能丢 ​

位置做法常见丢失原因
网关过滤器生成/复用 traceId,写响应头过滤器顺序靠后,异常请求没经过
Feign / RestTemplate请求拦截器写 Header只配了 Feign,漏了 RestTemplate/WebClient
消息生产/消费消息 Header 写上下文,消费端重建只传业务字段,没传 trace 字段
线程池 / @AsyncTaskDecorator 复制上下文ThreadLocal 不继承,线程复用导致串号
定时任务每次执行新建 trace,或关联批次 ID没有入口请求,上下文为空

上下文只在三个地方转移:跨进程靠 Header,同进程靠 ThreadLocal/MDC,换线程靠显式复制。任何一处漏掉,trace 就在那里断,日志里的 traceId 变成空值。最容易被忽略的是线程池和 MQ,因为它们不在 HTTP 拦截器的覆盖范围里。

日志关联 ​

MDC 里放 traceId、spanId,日志格式统一输出;异步任务提交时复制 MDC,任务结束清理,否则复用的线程会把上一条请求的 traceId 带给下一条。日志只记录边界和状态变化(入口、出口、重试、降级、状态机迁移),不要每个方法都打一遍。参数要脱敏:不打全量报文、证件号、卡号、token;需要排查时打字段名、长度、hash 或掩码摘要。日志里泄露的敏感信息,比缺日志更难收拾。

采样与调试 ​

全量追踪的存储和网络成本随 QPS 线性增长,所以入口做头部采样(head-based sampling):低流量可以 100%,高流量常见 1%–10%(经验值,按预算调)。采样标记要向下透传,否则每个服务各自决策,链路会残缺。

头部采样的盲区是:入口决定时还不知道请求会失败,错误请求未必被采到。要让错误和慢请求必留,有两个办法:在 Collector 做尾部采样(tail sampling,按错误/耗时策略强制保留),或单独保留错误指标和错误日志,不依赖 trace。不要假设「trace 里有全部错误」。

临时调试开关(x-debug: 1)用于绕过采样、记录方法级路径,必须带 TTL、权限和审计:谁能开、开了多久、记录了什么。长期打开会放大日志量、拖慢链路,还会把内部结构暴露给调用方。

指标分层 ​

层典型指标视角
资源CPU、内存、磁盘、网络USE:使用率、饱和度、错误
进程JVM 堆/GC、线程数、连接池、线程池队列USE
服务QPS、延迟、错误率RED:Rate、Errors、Duration
业务下单成功率、支付成功率、库存扣减失败率业务北极星

资源层用来解释原因,服务层和业务层用来判断影响。只看 CPU 会误判:CPU 高但业务正常,不需要半夜叫人;CPU 正常但支付成功率跌了,必须马上处理。高基数标签(userId、orderId、原始 URL、异常 message)不要进指标,否则时序数量爆炸;这些放到日志或追踪里,用 exemplar 从指标跳到具体 trace。

运行时端点 ​

Spring Boot 3.2 默认只通过 Web 暴露 health,management.endpoint.health.show-details 默认 never。其余端点按需打开,并加鉴权、放内网或独立管理端口。

  • health/liveness 失败重启,health/readiness 失败摘流量;不要把下游依赖放进 liveness,否则下游抖动会导致整批重启。
  • metrics / prometheus 用于查询和抓取,高基数标签会拖垮抓取;env、loggers、heapdump 能读配置、改日志级别、导内存,等于半个 shell,必须单独授权。
  • threaddump、beans 用于查死锁、阻塞和 Bean 依赖;自定义端点(路由表、限流规则、Feign 客户端实例状态)方便排障,写操作要先确认会不会改变流量。

实战场景 ​

一、一次“下单偶发超时”查了三小时:链路在 MQ 之后消失 ​

现象:客服反馈下单偶发超时(约 1% 请求,经验值,需校准)。团队先看网关 504 曲线,再看订单服务日志说“调库存超时”,库存说“数据库慢”,风控服务完全没有相关日志。四个服务、12 个实例挨个 grep,半小时后有人提议“先重启一台试试”——重启之后现场没了,问题仍在。

排查:按固定顺序走,每个问题只用对应信号回答。

步骤用哪个信号本例的卡点
确认影响面与开始时间指标:入口错误率、P99 按服务维度只有服务维度,没有实例维度,看不出是否单实例问题
找慢在哪一跳追踪:一条失败请求的 traceIdtrace 到 MQ 生产端就断,消费侧看不到
判断为什么慢进程指标:线程池队列、连接池等待、GC 停顿这些指标当时没采,只能靠猜
定位具体原因日志:异常栈与关键参数日志没有 traceId,无法与前面三步对齐

事后统计,四个服务里 11 个有 traceId 输出,但 3 个用自建线程池、2 个消费者没从消息头恢复上下文;日志格式统一率约 60%。所谓“查了三小时”,本质是在缺失的信号之间来回补位。

对症做法:

java
// 1. 补齐透传点:MQ 生产写头、消费重建;线程池用 TaskDecorator 复制
// producer
messageBuilder.setHeader("x-trace-id", MDC.get("traceId"));
// consumer
MDC.put("traceId", String.valueOf_RECEIVED_HEADER);
try { handle(msg); } finally { MDC.clear(); }        // 线程复用必须清,否则串号

// 2. 实例维度进标签(低基数、可控):service + instance(ip/pod),不要加 userId
management.metrics.tags: { service: ${spring.application.name}, instance: ${POD_NAME} }

// 3. 进程层指标先补三样:线程池 active/queue/reject、连接池 active/idle/pending、GC 停顿
ExecutorServiceMetrics.monitor(meterRegistry, bizPool, "biz");
yaml
# 4. 日志格式统一:traceId/spanId 进 pattern,级别前缀一处定义
logging.pattern.level: "%5p [traceId=%X{traceId:-},spanId=%X{spanId:-},svc=${spring.application.name}]"

排障顺序要写成 runbook 并演练:入口错误率 → 一条失败 traceId → 最长/首个报错 span → 该服务的线程池/连接池/GC → 日志下钻。止血优先于定位(摘流量、回滚、降级),但不要一上来重启——重启会清掉线程栈、堆与连接状态。

复盘要点:复盘的验收标准是那三个问题必须能答出来——哪一跳、什么资源、何时开始;答不出就是缺口,要落成待办。覆盖率要变成长期指标:入口请求 traceId 非空比例、下游收到请求 traceId 非空比例,低于 99% 告警(示例阈值)。

二、一个异常 message 标签打爆 Prometheus ​

现象:给错误监控加上 error_message 标签后第三天,抓取开始超时,Prometheus 进程内存从 4G 涨到 12G,query 延迟到 20 秒,告警评估滞后 5 分钟以上;订单服务只是加了个新异常类型,指标却翻倍增长。

排查:时序基数爆炸有明确的定位路径。

定位层取证据判定
谁在涨prometheus_tsdb_head_series 走势;按指标名拆 series 数单个指标占绝大部分序列即为高基数标签
哪个标签topk(10, count by (__name__)({__name__=~"http_server_requests.*"})) 与 label 值枚举error_message/uri/orderId 出现在标签里
增长速度每次发布后 series 增量;异常 message 是否含动态内容含 ID、时间戳、用户输入 → 无上限
抓取代价单实例 /metrics 响应体大小与抓取耗时超过 10MB / 秒级即不可持续
告警滞后告警规则评估时长、查询 P99症状与影响面判断被拖慢

对症做法:

java
// 1. 用路由模板而不是原始 URL;异常用类型码而不是 message
// 错误写法:.tag("uri", request.getRequestURI())     // /orders/1001、/orders/1002 ...
// 正确写法:由 Spring 的 uri 模板得到 /orders/{id}
meterRegistry.counter("biz.error", "code", e.getErrorCode().name(),   // 有限枚举
                               "stage", stage.name()).increment();
yaml
# 2. 从指标里剔除高基数来源,需要下钻时用 exemplar 从指标跳到具体 trace
management:
  metrics:
    tags: { application: ${spring.application.name} }
  otlp:
    tracing: { export: { enabled: true } }
server:
  tomcat:
    accesslog:
      enabled: true      # 原始 URL、参数进日志,不进指标
yaml
# 3. 抓取侧兜底:限制可抓取指标、拆分抓取任务、给高基数指标单独短保留
- job_name: app-metrics
  metric_relabel_configs:
    - source_labels: [__name__]
      regex: "http_server_requests_seconds_count"
      action: keep                       # 白名单,避免无意引入新的高基数序列

复盘要点:判据是“series 增量”:每次发布或新增埋点后,头部序列数变化要有记录,突增即回滚埋点。规则要写进埋点规范——高基数值(userId、orderId、原始 URL、异常 message)一律进日志或追踪,不进指标标签;从指标下钻具体请求用 exemplar。

三、heapdump 与 env:可观测端点变成事故入口 ​

现象:安全扫描报告指出生产服务的 http://<内网>:8080/actuator/env 未授权可访问,响应里包含数据库口令与第三方密钥;同一扫描发现 /actuator/heapdump 可下载。事后复盘另一次故障:有人为排查内存问题在生产实例直接触发 heapdump,进程 STW 数秒、磁盘写满导致同机其他服务异常。

排查:端点暴露面是配置决定的,一次核对就能看清。

定位层取证据判定
暴露范围management.endpoints.web.exposure.include 实际值;curl /actuator 列出可见端点写成 * 即为全暴露(Boot 3.2 默认只暴露 health)
管理端口management.server.port 是否独立、绑定地址是否内网与应用端口共用意味着业务流量也能打到
鉴权与网络端点是否有认证、是否有网络策略无鉴权 + 内网可达仍不等于安全,横向移动成本低
敏感内容show-details 取值;env/configprops/heapdump 是否可达这三个端点等于半个 shell
运行时改动loggers 是否可写、临时级别是否自动恢复改完不恢复会长期放大日志量
磁盘与停顿heapdump 落地路径容量、实例内存规格触发一次即是可用性事件

对症做法:

yaml
management:
  server:
    port: 9090                     # 与应用端口隔离
    address: 10.0.0.12             # 绑内网网卡地址(按实例实际内网 IP 注入)
  endpoints:
    web:
      exposure:
        include: health,info,prometheus     # 白名单,不用 *
  endpoint:
    health:
      show-details: when-authorized
      probes: { enabled: true }
    env:      { enabled: false }   # 需要时临时开、按角色鉴权并审计
    configprops: { enabled: false }
    heapdump: { enabled: false }   # 生产不开放;改由受控流程在单实例上采集
    loggers:  { enabled: true }    # Boot 3.4+ 可用 access: read-only 收紧写权限
yaml
# 敏感配置不进明文来源:口令由 KMS/Vault 注入,避免被 env 端点读出
spring:
  datasource:
    password: ${DB_PASSWORD}          # 由外部密钥系统注入,不落配置中心明文

采集内存快照的正规路径:预先在少量实例上开 -XX:+HeapDumpOnOutOfMemoryError,或在容量允许时把实例先从 readiness 摘除再采集,避免在承接流量的节点上直接触发。

复盘要点:端点安全要作为发布门禁项,用脚本核对“可见端点清单 ⊆ 白名单”,而不是靠记忆;loggers 的临时级别设置必须带自动恢复(TTL)。密钥泄露的修复代价远高于一次扫描告警,配置中心也不该存明文口令,见 认证、加密与端点安全。

最小上手 ​

前提:Spring Boot 3.2.x,引入 spring-boot-starter-actuator、micrometer-tracing-bridge-brave(或 micrometer-tracing-bridge-otel)、micrometer-registry-prometheus。下面用最小过滤器演示 MDC 写入(生产直接用追踪库自动完成,不要自己造轮子;省略 import):

java
@Component
public class TraceFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = req.getHeader("x-trace-id");
        if (!StringUtils.hasText(traceId)) traceId = UUID.randomUUID().toString().replace("-", "");
        MDC.put("traceId", traceId);
        MDC.put("spanId", Long.toHexString(ThreadLocalRandom.current().nextLong()));
        resp.setHeader("x-trace-id", traceId);
        try { chain.doFilter(req, resp); }
        finally { MDC.remove("traceId"); MDC.remove("spanId"); }
    }
}

日志格式(logging.pattern.level 会插入默认 console pattern):

yaml
logging.pattern.level: "%5p [traceId=%X{traceId:-},spanId=%X{spanId:-}]"

验证响应头、日志和健康端点:

bash
curl -i -H 'x-trace-id: 4f2c9a1b7d3e4f60' http://localhost:8080/api/orders/1
# 期望响应头与日志都出现 4f2c9a1b7d3e4f60
curl -s localhost:9090/actuator/health | jq

Actuator 最小暴露(示例绑本机;跨机抓取改为内网地址并加网络策略):

yaml
management:
  server: { port: 9090, address: 127.0.0.1 }
  endpoints.web.exposure.include: health,info,metrics,prometheus
  endpoint.health:
    show-details: when-authorized
    probes.enabled: true

生产动作 ​

采样、保留期与成本:management.tracing.sampling.probability=0.1(Spring Boot 3.2 默认 0.1);高流量 1%–10%,低流量可以 100%(经验值)。追踪热存 7 天、冷存 30 天是常见起点(经验值),按查询频率和合规要求调。日志不要全量长期保留:入口、出口、错误全量,调试日志采样或短保留。

高基数标签:userId、orderId、原始 URL、异常 message 不进指标标签;用路由模板(/orders/{id})、状态码、方法、实例等低基数字段。需要从指标跳到具体请求时用 exemplar,不要靠加标签。

异步与 MQ 丢上下文的检查:发一条带 x-trace-id 的请求,在所有相关服务的日志里 grep 同一个 ID;缺哪一段,就查那个环节的拦截器、TaskDecorator、消息 Header。再做覆盖率指标:入口请求中 traceId 非空的比例、下游收到的请求中 traceId 非空的比例,低于 99% 告警(示例阈值)。

端点安全:management.endpoints.web.exposure.include 只开必要项,默认只留 health;管理端口独立、绑内网、加鉴权;env、heapdump、loggers 单独授权并审计;loggers 的临时级别要有自动恢复。上线前逐项核对 99-生产检查清单。

告警按症状和 SLO:错误率、P99、饱和度直接对应用户感知,CPU/GC 是原因不是症状,不要按单个 CPU 指标告警;SLO 用多窗口 burn rate(如 1h 和 5m 同时超阈值)减少抖动。分 P1/P2/P3,每条带 runbook 链接(看哪个看板、查哪个端点、找谁);同根因告警分组抑制、维护窗口静默,避免风暴。

故障复盘:必须能回答三个问题——哪一跳(哪个服务、哪个 span)、什么资源(线程池、连接池、GC、磁盘、DB 锁)、何时开始(第一个异常指标的时间点)。答不出这三个,说明可观测性还有缺口。

排障路径(按顺序,不要跳):先看入口错误率和 P99,确认影响面和开始时间;拿一个失败请求的 traceId,找耗时最长和第一个报错的 span;到该服务看线程池队列、连接池等待、GC 停顿、CPU/磁盘;最后下钻日志看异常栈和关键参数。需要止血时先摘流量、回滚或降级;不要一上来重启,重启会清掉线程栈、堆和连接状态,证据没了。

边界 ​

  • 追踪有采样:不能假设每条请求都有 trace,trace 数量也不等于请求量;用指标算流量,用 trace 定位个例。
  • 日志有成本:全量长期保留既贵又慢,必须分级、采样、归档;但错误和关键状态不能省。
  • 追踪不等于根因分析:它告诉你慢在哪一跳,不告诉你为什么慢;线程池满、锁等待、GC 需要进程指标和线程栈来回答。
  • 没有业务指标,链路再全也判断不了影响面:支付成功率、下单成功率这类指标必须单独建,不能从 trace 推导。
  • 调试开关是临时手段,也不是越多越好:它不能替代正常日志和指标,长期开启会拖慢链路、放大日志、暴露内部结构;每个指标、每条日志都要对应一个会做的决策,没人看的看板只会增加维护成本。

讲清楚 ​

可观测性是把日志、指标、追踪三件事分开用:日志回答单条请求发生了什么,指标回答有多严重、多频繁,追踪回答慢在哪一跳。三者靠 traceId 和 spanId 串起来:入口生成,跨进程走 Header,同进程走 ThreadLocal 和 MDC,换线程、进 MQ 时必须显式复制,漏一处链路就断。追踪通常采样,所以 trace 数量不等于流量,错误请求也未必被采到;要必留就用尾部采样,或单独保留错误指标和日志。指标按资源、进程、服务、业务分层,告警只看症状和 SLO,不看单个 CPU。排障顺序是先看入口错误率,再按 traceId 找慢 span,再看线程池、连接池和 GC,最后下钻日志;不要一上来重启。

把一门技术讲透到既懂也会用