从「能跑」到「可观测」:服务端可观测性落地清单
可观测性不是「把日志、指标、链路都接上」。它是一个具体的能力:线上出问题时,你能不能在不改代码、不加日志、不重启服务的前提下,把原因定位到某一行代码或某一个依赖。
判断一个系统可观测性够不够,有个很实用的检验方法——问自己四个问题:
- 现在系统是否健康?(指标回答)
- 哪个环节慢/错了?(链路回答)
- 那一刻具体发生了什么?(日志回答)
- 这次变化是谁引入的?(变更事件回答)
第四个经常被漏掉。很多故障的根因是一次发布、一次配置推送、一次开关变更,如果这些事件没有进入可观测体系,你就得靠翻群聊记录来还原时间线。
一、三种信号,各管一段,不要互相替代
最常见的浪费是用日志干指标的活:把每个请求打一条日志,然后靠日志平台做聚合出 QPS 和 RT。这种做法在流量小的时候能用,量一上来存储成本和查询延迟都不能接受。
分工应该是这样:
| 信号 | 特点 | 回答的问题 | 成本模型 |
|---|---|---|---|
| Metrics | 预聚合、低基数、可长期保留 | 系统整体是否正常 | 与时间序列数量相关,与请求量无关 |
| Traces | 单请求粒度、可采样 | 慢在哪一跳 | 与请求量相关,靠采样控制 |
| Logs | 全文、高成本 | 那一刻的具体细节 | 与请求量线性相关,最贵 |
关键结论:指标的成本与请求量无关。1000 QPS 和 100000 QPS,只要标签组合不变,时间序列数量就不变。这是为什么指标应该覆盖全量,而日志和链路必须节制。
高基数是指标的头号杀手
// 错误: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 的四个黄金信号足够覆盖大部分问题:
@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);
}
}
}除了这四个,还有一类指标价值极高但常被忽略——队列与池的等待时间。它们是压力最早出现的地方:
// 数据库连接池等待时间:比 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),在查询端做聚合:
# Spring Boot: 上报直方图而不是客户端计算好的分位数
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.distribution.slo.http.server.requests=50ms,100ms,200ms,500ms,1s,3s# Prometheus 侧对全部实例做正确的分位数聚合
histogram_quantile(0.99,
sum(rate(http_server_requests_seconds_bucket[5m])) by (le, route)
)三、链路:采样策略比接入更重要
链路追踪接入很简单(OpenTelemetry Java Agent 一个参数就行),难点在于怎么在成本和有用性之间取平衡。
固定比例采样(比如 1%)的问题很明显:出错的请求和慢请求恰恰是你最想看的,而它们本来就是少数,1% 采样下大概率一条都没留。
合理的策略是分层的:
@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),优先用它。它先缓存完整链路,等请求结束后再根据「是否出错、是否超过延迟阈值」决定留不留,正好解决了头部采样看不到异常请求的问题:
# 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 注入日志上下文:
// 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();
}
}
}<!-- logback 里输出 traceId,日志平台就能按 traceId 聚合 -->
<pattern>%d{ISO8601} %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>把 traceId 写进响应头这一条,实际收益可能比前面所有配置加起来都大:用户报障时给你一个 traceId,你直接搜就行,不用再问「大概几点钟、用什么账号、点了哪个按钮」。
四、日志:从「打得多」到「打得准」
日志是三种信号里最贵的,成本和请求量成正比。控制成本的思路不是「少打」,而是分级打。
// 反例:每个请求都打,且信息是无结构的字符串
log.info("查询订单: " + orderNo + ", 用户: " + userId + ", 耗时: " + cost + "ms");三个问题:正常路径不需要逐条日志(指标已经覆盖)、字符串拼接无法结构化检索、cost 这种数据应该是指标。
改成这样:
// 正常路径不打日志,靠指标和链路
// 异常路径打结构化日志,带足够的定位信息
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 采样,好处是采样到的请求日志是完整的:
// 用 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);
}五、第四种信号:变更事件
前面说过,很多故障的根因是一次变更。把变更做成可观测信号,排查时能省掉大量时间。
做法是把变更打成时间点事件,在监控面板上和指标曲线画在同一个时间轴:
@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 而不是基于资源指标告警:
# 不好: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: 10mrunbook 链接这一项不要省。凌晨三点被叫起来的人需要的是「按这几步做」,不是一条只说明现象的告警。
七、落地顺序
按这个顺序推进,每一步都能立刻产生价值:
- 接入四个黄金信号 + traceId 回写响应头(一天工作量,收益最大)
- 加上队列/连接池的等待指标(最早暴露压力的地方)
- 接入链路追踪,配好尾部采样(能看到慢在哪一跳)
- 清理日志:正常路径去掉、业务异常去栈、改参数化(成本立刻降下来)
- 接入变更事件并叠加到面板(缩短根因定位时间)
- 把告警从资源指标改成 SLO,并给每条配 runbook
- 定期做告警复盘,删掉没人处理的
小结
可观测性的价值不在于接了多少工具,而在于故障时能不能不改代码就定位问题。
三个最容易做错的地方值得反复提醒:别用日志干指标的活(成本会失控)、别把高基数字段放进指标标签(时间序列会爆炸)、别做只报现象不要求行动的告警(真告警会被淹没)。
如果只能做一件事,做「traceId 注入日志 + 回写响应头」。它的投入产出比高得离谱。