Skip to content

从「能跑」到「可观测」:服务端可观测性落地清单 ​

可观测性不是「把日志、指标、链路都接上」。它是一个具体的能力:线上出问题时,你能不能在不改代码、不加日志、不重启服务的前提下,把原因定位到某一行代码或某一个依赖。

判断一个系统可观测性够不够,有个很实用的检验方法——问自己四个问题:

  1. 现在系统是否健康?(指标回答)
  2. 哪个环节慢/错了?(链路回答)
  3. 那一刻具体发生了什么?(日志回答)
  4. 这次变化是谁引入的?(变更事件回答)

第四个经常被漏掉。很多故障的根因是一次发布、一次配置推送、一次开关变更,如果这些事件没有进入可观测体系,你就得靠翻群聊记录来还原时间线。

Metrics预聚合 · 低基数Traces单请求 · 可采样Logs全文 · 最贵变更事件发布 · 配置 · 开关系统是否健康哪个环节慢或错那一刻的细节这次变化谁引入成本模型:指标与时间序列数量相关;链路与请求量相关(靠采样控制);日志与请求量线性相关最常见的浪费:用日志做聚合算 QPS 与 RT——流量上来后存储与查询都撑不住最高性价比的一件事:traceId 注入日志并回写响应头,把三者串起来
图 1 · 指标回答「是否健康」,链路回答「哪一跳慢」,日志回答「那一刻发生了什么」,变更事件回答「是谁引入的」

一、三种信号,各管一段,不要互相替代 ​

最常见的浪费是用日志干指标的活:把每个请求打一条日志,然后靠日志平台做聚合出 QPS 和 RT。这种做法在流量小的时候能用,量一上来存储成本和查询延迟都不能接受。

分工应该是这样:

信号特点回答的问题成本模型
Metrics预聚合、低基数、可长期保留系统整体是否正常与时间序列数量相关,与请求量无关
Traces单请求粒度、可采样慢在哪一跳与请求量相关,靠采样控制
Logs全文、高成本那一刻的具体细节与请求量线性相关,最贵

关键结论:指标的成本与请求量无关。1000 QPS 和 100000 QPS,只要标签组合不变,时间序列数量就不变。这是为什么指标应该覆盖全量,而日志和链路必须节制。

高基数是指标的头号杀手 ​

java
// 错误:userId 作为标签,一百万用户就是一百万条时间序列
meterRegistry.counter("order.created", "userId", order.getUserId()).increment();

// 错误:把订单号、traceId、URL 里的路径参数放进标签,都是同一类问题
meterRegistry.counter("api.call", "path", request.getRequestURI()).increment();

// 正确:标签只放有限枚举值
meterRegistry.counter("order.created",
        "channel", order.getChannel(),      // APP / H5 / 小程序,3 个值
        "payType", order.getPayType()       // 支付方式,个位数
).increment();

// 正确:路径要用模板而不是实际值
meterRegistry.counter("api.call", "route", "/api/orders/{id}").increment();

判断标准:这个标签的可能取值是否有明确上限,且小于几十。userId、订单号、IP、完整 URL 都不满足,它们属于链路和日志。

二、指标:先把这四个建起来 ​

不要一上来铺一百个指标。Google SRE 的四个黄金信号足够覆盖大部分问题:

java
@Component
public class MetricsFilter implements Filter {

    private final MeterRegistry registry;

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
        String route = routeTemplate(request);      // 关键:转成路由模板
        long start = System.nanoTime();

        // 信号 3:饱和度 —— 当前在处理的请求数
        inFlight.incrementAndGet();
        try {
            chain.doFilter(req, res);
        } finally {
            inFlight.decrementAndGet();
            int status = response.getStatus();

            // 信号 1:延迟(用 Timer,自带分位数)
            // 信号 2:错误率(靠 status 标签区分)
            // 信号 4:流量(Timer 的 count 就是 QPS)
            Timer.builder("http.server.requests")
                    .tag("route", route)
                    .tag("method", request.getMethod())
                    .tag("status", String.valueOf(status))
                    .publishPercentiles(0.5, 0.95, 0.99)
                    .register(registry)
                    .record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
        }
    }
}

除了这四个,还有一类指标价值极高但常被忽略——队列与池的等待时间。它们是压力最早出现的地方:

java
// 数据库连接池等待时间:比 CPU、内存更早反映压力
@Bean
MeterBinder hikariMetrics(HikariDataSource ds) {
    return registry -> {
        Gauge.builder("db.pool.pending", ds, d -> d.getHikariPoolMXBean().getThreadsAwaitingConnection())
             .register(registry);
        Gauge.builder("db.pool.active", ds, d -> d.getHikariPoolMXBean().getActiveConnections())
             .register(registry);
    };
}

// MQ 消费延迟:消息产生时间到被消费的时间差
public void onMessage(Message msg) {
    long lag = System.currentTimeMillis() - msg.getProduceTime();
    registry.timer("mq.consume.lag", "topic", msg.getTopic())
            .record(lag, TimeUnit.MILLISECONDS);
    // ...
}

关于分位数的一个坑 ​

不要对 p99 做跨实例的算术平均。10 个实例的 p99 求平均,得到的数字没有任何统计意义。

正确做法是让指标库上报直方图桶(histogram buckets),在查询端做聚合:

properties
# Spring Boot: 上报直方图而不是客户端计算好的分位数
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.distribution.slo.http.server.requests=50ms,100ms,200ms,500ms,1s,3s
txt
# Prometheus 侧对全部实例做正确的分位数聚合
histogram_quantile(0.99,
  sum(rate(http_server_requests_seconds_bucket[5m])) by (le, route)
)

三、链路:采样策略比接入更重要 ​

链路追踪接入很简单(OpenTelemetry Java Agent 一个参数就行),难点在于怎么在成本和有用性之间取平衡。

固定比例采样(比如 1%)的问题很明显:出错的请求和慢请求恰恰是你最想看的,而它们本来就是少数,1% 采样下大概率一条都没留。

合理的策略是分层的:

java
@Bean
Sampler sampler() {
    return new Sampler() {
        @Override
        public SamplingResult shouldSample(Context ctx, String traceId, String name,
                                           SpanKind kind, Attributes attrs,
                                           List<LinkData> links) {
            // 1. 上游已决定采样,跟随,保证链路完整
            SpanContext parent = Span.fromContext(ctx).getSpanContext();
            if (parent.isValid() && parent.isSampled()) {
                return SamplingResult.recordAndSample();
            }
            // 2. 关键业务路径全采
            String route = attrs.get(HTTP_ROUTE);
            if (route != null && CRITICAL_ROUTES.contains(route)) {
                return SamplingResult.recordAndSample();
            }
            // 3. 健康检查等噪音直接丢
            if (route != null && NOISE_ROUTES.contains(route)) {
                return SamplingResult.drop();
            }
            // 4. 其余按比例
            return ratioBased.shouldSample(ctx, traceId, name, kind, attrs, links);
        }
    };
}

如果你的采集端支持尾部采样(tail-based sampling),优先用它。它先缓存完整链路,等请求结束后再根据「是否出错、是否超过延迟阈值」决定留不留,正好解决了头部采样看不到异常请求的问题:

yaml
# OpenTelemetry Collector:尾部采样配置
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors           # 出错的全留
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow             # 慢请求全留
        type: latency
        latency: { threshold_ms: 1000 }
      - name: baseline         # 正常请求留 1%
        type: probabilistic
        probabilistic: { sampling_percentage: 1 }

链路要能和日志、指标对上 ​

这是三种信号能否协同的关键。做法很简单——把 traceId 注入日志上下文:

java
// OpenTelemetry 的 logback appender 会自动注入,手动做也不难
public class TraceIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String traceId = Span.current().getSpanContext().getTraceId();
        MDC.put("traceId", traceId);
        // 回写到响应头,用户报障时直接提供这个 id
        ((HttpServletResponse) res).setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}
xml
<!-- logback 里输出 traceId,日志平台就能按 traceId 聚合 -->
<pattern>%d{ISO8601} %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>

把 traceId 写进响应头这一条,实际收益可能比前面所有配置加起来都大:用户报障时给你一个 traceId,你直接搜就行,不用再问「大概几点钟、用什么账号、点了哪个按钮」。

四、日志:从「打得多」到「打得准」 ​

日志是三种信号里最贵的,成本和请求量成正比。控制成本的思路不是「少打」,而是分级打。

java
// 反例:每个请求都打,且信息是无结构的字符串
log.info("查询订单: " + orderNo + ", 用户: " + userId + ", 耗时: " + cost + "ms");

三个问题:正常路径不需要逐条日志(指标已经覆盖)、字符串拼接无法结构化检索、cost 这种数据应该是指标。

改成这样:

java
// 正常路径不打日志,靠指标和链路
// 异常路径打结构化日志,带足够的定位信息
try {
    return orderService.query(orderNo);
} catch (OrderNotFoundException e) {
    // 业务异常:WARN,不带栈(栈没有信息量,还很占空间)
    log.warn("订单不存在 orderNo={} userId={}", orderNo, userId);
    throw e;
} catch (Exception e) {
    // 系统异常:ERROR,带栈
    log.error("订单查询失败 orderNo={} userId={}", orderNo, userId, e);
    throw e;
}

两条经验值得单独说:

业务异常不要打栈。 「订单不存在」这种异常,栈信息全是框架调用链,对定位毫无帮助,但每条要占几 KB。高频业务异常打栈是日志膨胀的常见原因。

用参数化而不是字符串拼接。 log.warn("orderNo={}", orderNo) 除了避免不必要的字符串构造,更重要的是日志平台能把 orderNo 提取成可检索字段。

采样日志:需要正常路径样本时 ​

有时确实想看正常请求的细节,可以按 traceId 采样,好处是采样到的请求日志是完整的:

java
// 用 traceId 的哈希决定,同一请求的所有日志要么全留要么全不留
private boolean shouldLogDetail(String traceId) {
    return Math.abs(traceId.hashCode() % 100) < 1;   // 1%
}

if (shouldLogDetail(MDC.get("traceId"))) {
    log.info("订单查询明细 orderNo={} items={} coupons={}", orderNo, items, coupons);
}

五、第四种信号:变更事件 ​

前面说过,很多故障的根因是一次变更。把变更做成可观测信号,排查时能省掉大量时间。

做法是把变更打成时间点事件,在监控面板上和指标曲线画在同一个时间轴:

java
@Component
public class ChangeEventPublisher {

    // 发布、配置推送、开关变更、灰度调整,都发一个事件
    public void publish(ChangeEvent event) {
        registry.counter("deploy.event",
                "type", event.type(),         // RELEASE / CONFIG / FEATURE_FLAG
                "service", event.service(),
                "operator", event.operator()
        ).increment();

        log.info("变更事件 type={} service={} version={} operator={} detail={}",
                event.type(), event.service(), event.version(),
                event.operator(), event.detail());
    }
}

Grafana 里用 annotation 把这些事件叠加到图表上,「RT 从这个点开始上涨」和「这个点有一次配置推送」摆在一起,结论往往是秒级得出的。

六、告警:宁少勿滥 ​

告警的唯一目的是让人采取行动。一条不需要采取行动的告警,除了消耗注意力没有任何价值,而且会让真正重要的告警被淹没。

判断一条告警该不该存在,就问:收到它之后我要做什么?如果答案是「看一眼,没事」,删掉它。

推荐基于 SLO 而不是基于资源指标告警:

yaml
# 不好:CPU 高不代表用户受影响,且经常误报
- alert: HighCPU
  expr: cpu_usage > 80

# 好:直接对用户体验告警
- alert: OrderApiErrorBudgetBurning
  expr: |
    sum(rate(http_server_requests_seconds_count{route="/api/orders",status=~"5.."}[5m]))
    /
    sum(rate(http_server_requests_seconds_count{route="/api/orders"}[5m]))
    > 0.01
  for: 5m
  annotations:
    summary: "订单接口错误率超过 1%,持续 5 分钟"
    runbook: "https://wiki.internal/runbook/order-api-5xx"

- alert: OrderApiLatencyDegraded
  expr: |
    histogram_quantile(0.99,
      sum(rate(http_server_requests_seconds_bucket{route="/api/orders"}[5m])) by (le)
    ) > 1
  for: 10m

runbook 链接这一项不要省。凌晨三点被叫起来的人需要的是「按这几步做」,不是一条只说明现象的告警。

七、落地顺序 ​

按这个顺序推进,每一步都能立刻产生价值:

  1. 接入四个黄金信号 + traceId 回写响应头(一天工作量,收益最大)
  2. 加上队列/连接池的等待指标(最早暴露压力的地方)
  3. 接入链路追踪,配好尾部采样(能看到慢在哪一跳)
  4. 清理日志:正常路径去掉、业务异常去栈、改参数化(成本立刻降下来)
  5. 接入变更事件并叠加到面板(缩短根因定位时间)
  6. 把告警从资源指标改成 SLO,并给每条配 runbook
  7. 定期做告警复盘,删掉没人处理的

小结 ​

可观测性的价值不在于接了多少工具,而在于故障时能不能不改代码就定位问题。

三个最容易做错的地方值得反复提醒:别用日志干指标的活(成本会失控)、别把高基数字段放进指标标签(时间序列会爆炸)、别做只报现象不要求行动的告警(真告警会被淹没)。

如果只能做一件事,做「traceId 注入日志 + 回写响应头」。它的投入产出比高得离谱。

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权