可观测性三大支柱实战——日志、指标、追踪,如何在 Java 服务中全套接入
可观测性三大支柱实战——日志、指标、追踪,如何在 Java 服务中全套接入
适读人群:Java 后端工程师、SRE | 阅读时长:约22分钟 | 核心价值:从零开始在 Java 服务里建立完整的可观测性体系,不再靠日志找问题
我有一段很痛苦的经历,想先讲给你听。
那是我刚入行的第三年,负责一个电商平台的支付服务。有一天凌晨两点,收到报警说支付成功率下降了 15%。我爬起来打开电脑,开始排查。
我的排查工具:SSH 到服务器,tail -f /var/log/payment-service.log,然后用眼睛扫日志。
日志是这样的:
2023-03-15 02:13:45 INFO Processing payment request for order 20230315-8823
2023-03-15 02:13:45 INFO Payment processing started, amount: 299.00
2023-03-15 02:13:47 ERROR Payment failed for order 20230315-8823没有错误原因,没有堆栈信息,没有上下文。我不知道是数据库超时、第三方支付接口失败,还是代码逻辑问题。
那次排查花了两个半小时,最后发现是一个第三方支付接口的证书过期了,但中间走了很多弯路。
如果当时有完善的可观测性体系——结构化日志、关键指标、分布式追踪——我可能 10 分钟就能定位到问题。
这篇文章就是把我这些年积累的可观测性实践,完整地写出来。
可观测性三大支柱
日志(Logs):记录"发生了什么",离散的事件记录
指标(Metrics):反映"系统状态如何",时序数据
追踪(Traces):展示"请求走过的路径",分布式调用链
三者各有侧重,缺一不可。只有日志,你能知道出了什么错,但不知道错误的频率和趋势;只有指标,你能知道成功率下降了,但不知道具体哪个请求出了问题;只有追踪,你能看到调用链,但缺少业务上下文。
第一支柱:结构化日志
为什么要结构化日志
普通日志:
2024-01-15 10:23:45 INFO User 12345 placed order 98765, amount 299.00, payment method ALIPAY结构化日志(JSON):
{
"timestamp": "2024-01-15T10:23:45.123Z",
"level": "INFO",
"service": "payment-service",
"message": "Order placed",
"userId": "12345",
"orderId": "98765",
"amount": 299.00,
"paymentMethod": "ALIPAY",
"traceId": "abc123def456",
"spanId": "789xyz"
}结构化日志的优势:可以被 ELK、Loki 等系统解析,按任意字段过滤、聚合。比如:"查看过去 1 小时,支付方式为 ALIPAY 且金额大于 1000 的失败订单"——这在普通日志里要用正则表达式解析,在结构化日志里就是一条简单的查询。
Spring Boot 配置结构化日志
添加依赖:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>src/main/resources/logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProfile name="!local">
<!-- 非本地环境使用 JSON 格式 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel/>
<loggerName>
<shortenedLoggerNameLength>40</shortenedLoggerNameLength>
</loggerName>
<message/>
<mdc/> <!-- 包含 MDC 中的所有字段 -->
<stackTrace>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>30</maxDepthPerThrowable>
<maxLength>2048</maxLength>
<shortenedClassNameLength>40</shortenedClassNameLength>
</throwableConverter>
</stackTrace>
<arguments/>
</providers>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</springProfile>
<springProfile name="local">
<!-- 本地环境用易读格式 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
</springProfile>
</configuration>用 MDC 传播业务上下文
MDC(Mapped Diagnostic Context)是 Logback 的一个机制,可以给当前线程的所有日志自动附加上下文信息:
// 在请求入口处设置 MDC
@Component
public class MdcFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 从请求头获取 TraceId(由 API 网关或调用方传入)
String traceId = httpRequest.getHeader("X-Trace-Id");
if (traceId == null) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
String userId = httpRequest.getHeader("X-User-Id");
try {
MDC.put("traceId", traceId);
MDC.put("userId", userId);
MDC.put("requestPath", httpRequest.getRequestURI());
chain.doFilter(request, response);
} finally {
MDC.clear(); // 请求结束清除 MDC,防止内存泄漏
}
}
}设置好 MDC 之后,这个请求产生的所有日志都会自动包含 traceId、userId 等字段,不需要在每行日志里手动写。
第二支柱:Metrics 指标
Spring Boot Actuator + Micrometer
Spring Boot 2+ 内置了 Micrometer,可以对接多种指标系统(Prometheus、Datadog、CloudWatch 等)。
添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>application.yml 配置:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active}访问 http://localhost:8080/actuator/prometheus 就能看到所有指标。
自定义业务指标
框架默认的 JVM、HTTP、数据库指标很好,但业务指标更有价值——比如"支付成功率"、"每分钟订单数":
@Service
@RequiredArgsConstructor
public class PaymentService {
private final MeterRegistry meterRegistry;
private final Counter paymentSuccessCounter;
private final Counter paymentFailureCounter;
private final Timer paymentTimer;
public PaymentService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.paymentSuccessCounter = Counter.builder("payment.total")
.tag("status", "success")
.description("Total successful payments")
.register(meterRegistry);
this.paymentFailureCounter = Counter.builder("payment.total")
.tag("status", "failure")
.description("Total failed payments")
.register(meterRegistry);
this.paymentTimer = Timer.builder("payment.duration")
.description("Payment processing duration")
.publishPercentiles(0.5, 0.95, 0.99) // 记录 P50, P95, P99
.register(meterRegistry);
}
public PaymentResult processPayment(PaymentRequest request) {
return paymentTimer.record(() -> {
try {
PaymentResult result = doProcessPayment(request);
paymentSuccessCounter.increment();
// 记录支付金额分布
meterRegistry.summary("payment.amount",
"currency", request.getCurrency(),
"method", request.getPaymentMethod())
.record(request.getAmount().doubleValue());
return result;
} catch (Exception e) {
paymentFailureCounter.increment();
// 记录失败原因
Counter.builder("payment.error")
.tag("reason", e.getClass().getSimpleName())
.register(meterRegistry)
.increment();
throw e;
}
});
}
}第三支柱:分布式追踪
Spring Boot 3 + Micrometer Tracing
Spring Boot 3 集成了 Micrometer Tracing,配置非常简单:
<!-- 核心依赖 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<!-- 上报到 Zipkin -->
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>management:
tracing:
sampling:
probability: 0.1 # 采样率 10%,生产环境不能 100% 采样
zipkin:
tracing:
endpoint: http://zipkin:9411/api/v2/spans配置好之后,Spring MVC、RestTemplate、Spring Data JPA、Kafka 等常用组件都会自动注入 Span,不需要手动写代码。
手动创建 Span
对于业务关键路径,可以手动创建 Span 来获取更细粒度的追踪信息:
@Service
@RequiredArgsConstructor
public class OrderService {
private final Tracer tracer;
public OrderResult createOrder(CreateOrderRequest request) {
// 为第三方风控调用创建独立 Span
Span riskSpan = tracer.nextSpan().name("risk-check").start();
try (Tracer.SpanInScope ws = tracer.withSpan(riskSpan)) {
riskSpan.tag("userId", request.getUserId());
riskSpan.tag("amount", String.valueOf(request.getAmount()));
RiskResult riskResult = riskService.check(request);
riskSpan.tag("riskScore", String.valueOf(riskResult.getScore()));
if (!riskResult.isPassed()) {
riskSpan.tag("rejected", "true");
throw new RiskCheckFailedException("Risk check failed");
}
} catch (Exception e) {
riskSpan.error(e);
throw e;
} finally {
riskSpan.end(); // 必须 end,否则 Span 不会被上报
}
// 继续处理订单...
}
}踩坑实录
踩坑一:日志量爆炸
刚上了结构化日志,所有服务改成 DEBUG 级别,一周后日志存储账单翻了十倍。
生产环境日志级别策略:
ERROR:程序异常,必须记录WARN:需要注意的问题(如重试、降级)INFO:业务关键路径(请求进入、重要操作完成)DEBUG:只在本地开发环境开启
不同包的日志级别可以分别设置:
logging:
level:
root: WARN # 默认只记录 WARN 以上
com.company.service: INFO # 自己的 service 包记录 INFO
org.springframework.web: WARN # 框架日志只记录 WARN踩坑二:Prometheus 指标 cardinality 爆炸
我有个同事在给 Prometheus 指标加 tag 的时候,把订单 ID 作为 tag:
meterRegistry.counter("order.processed", "orderId", orderId).increment();订单 ID 是唯一的,每个订单产生一个新的 time series,Prometheus 的内存瞬间飙升,几分钟后 OOM 崩溃了。
Prometheus 指标的 tag(label)必须是低基数(low cardinality)——取值范围有限的值。订单 ID、用户 ID、请求路径(如果路径包含动态参数)都是高基数,绝对不能作为 label。
合法的 label:支付方式(ALIPAY/WECHAT/CARD,5 个以内)、订单状态(SUCCESS/FAILED/PENDING)、环境名等。
踩坑三:生产环境 100% 采样率把 Zipkin 打垮
我们有个服务 QPS 比较高,在接入分布式追踪时把采样率设成了 1.0(100%)。高峰时每秒产生几千个 Span,把 Zipkin 直接打垮了。
生产环境采样率设置建议:
- 正常情况:1%-10%(用于分析整体调用链模式)
- 排查问题时:临时调高到 100%
- 对于关键用户/关键路径:可以用自适应采样,保证关键路径被全量采样
Spring Boot 支持动态调整采样率:
@Component
public class DynamicSampler implements Sampler {
@Value("${tracing.sampling.rate:0.01}")
private float samplingRate;
@Override
public boolean isSampled(long traceId) {
return Math.random() < samplingRate;
}
}通过 Spring Cloud Config 动态下发 tracing.sampling.rate,不重启服务就能调整采样率。
深度解析:可观测性的成熟度模型
可观测性不是一个非此即彼的东西,它有成熟度的阶梯。理解这个阶梯,可以帮助你判断团队现在处于什么阶段,以及下一步应该做什么。
Level 0:无可观测性
出了问题靠猜,或者 SSH 到机器上临时看日志。没有集中的日志系统,没有任何指标收集,没有告警。
这个阶段的特征:每次排查问题都是"冒险",高度依赖写代码的人来排查(因为只有他知道去哪里看)。
Level 1:基础日志聚合
有集中的日志系统(ELK、Loki),日志可以搜索。有基础的服务器指标(CPU、内存、磁盘)。
这个阶段可以解决大部分排查问题的需求,但仍然主要依靠人工搜索,没有主动告警。
Level 2:指标告警
有 Prometheus + Grafana,有基于指标的告警(服务不可用、CPU 过高)。有了主动感知问题的能力。
大多数团队停在这个阶段,认为"监控做好了"。但这个阶段的告警通常噪音很多,因为告警的是内部状态(CPU、内存),而不是用户感知到的问题。
Level 3:以用户为中心的可观测性
告警基于 SLO(Service Level Objectives)——用户感知到的请求成功率、延迟。有分布式追踪,能快速定位跨服务的问题。日志、指标、追踪三者关联起来,形成完整的诊断工具链。
Level 4:主动式可观测性(Proactive Observability)
在用户报告问题之前,系统能自动发现潜在问题(异常检测、容量预测)。有混沌工程实践,主动验证系统在各种故障场景下的可观测性是否足够。
大多数团队的目标是 Level 3,Level 4 是大厂的实践。
深度解析:为什么很多团队的日志难以排查问题
同样是有日志,有的团队能 5 分钟定位问题,有的团队翻了 2 小时还没头绪。差异在哪里?
问题一:缺少上下文关联
日志写了错误信息,但没有关联的请求 ID、用户 ID、操作 ID。出了问题,你知道"有错误发生了",但不知道是哪个请求、哪个用户遇到的。
解决方法:用 MDC 把请求上下文(request ID、user ID、trace ID)自动附加到所有日志里。这样可以用 trace ID 一条命令查出某个请求的完整日志链路。
问题二:日志级别混乱
有的团队所有日志都是 INFO 级别,信息量太大,有价值的信息被淹没。有的团队滥用 ERROR 级别,一些预期的异常(用户输入错误、业务规则拒绝)也打 ERROR,告警泛滥。
解决方法:明确各级别的使用规范:TRACE/DEBUG 给开发调试用,INFO 给关键业务事件,WARN 给需要关注但不需要立刻处理的情况,ERROR 给影响服务质量的问题(需要人工介入)。
问题三:日志没有结构
纯文本日志在搜索时只能用全文搜索,效率低,而且难以做聚合分析(比如"过去一小时某类错误发生了多少次")。
解决方法:输出 JSON 结构化日志,字段化所有关键信息,在 ELK 或 Loki 里可以精确按字段过滤和聚合。
把三者关联起来
可观测性的最大价值,是把三者关联起来。
具体做法:在日志里包含 traceId,在 Prometheus 指标的 label 里不记录 traceId(高基数),但在 Grafana 里配置,当你点击某个异常指标时,自动跳转到对应时间段的日志查询。
Grafana 的 Exemplar 功能可以在指标上附加追踪 ID,直接从 Prometheus 图表跳转到 Jaeger 的具体 Trace:
// 在记录指标时附加 traceId(作为 Exemplar,不作为 label)
Timer.Sample sample = Timer.start(meterRegistry);
// ... 执行业务逻辑 ...
sample.stop(Timer.builder("http.request.duration")
.tag("status", String.valueOf(response.getStatus()))
.withRegistry(meterRegistry));深度解析:可观测性的成本与收益
引入完整的可观测性体系,是有实际成本的:存储日志需要磁盘空间,Prometheus 时序数据库需要内存,分布式追踪会给每个请求增加少量延迟(通常 1-5ms)。在资源紧张的团队里,这些成本会引发疑问:"我们真的需要这些吗?"
值得用数字来回答这个问题。
平均定位时间(MTTD)的变化
没有可观测性时,一个生产问题的平均定位时间通常在 30 分钟到几小时之间。有了结构化日志 + Prometheus 告警 + 追踪,定位时间通常压缩到 5-15 分钟。这个差距在高频发生的小问题上积累非常显著——一个团队每周有 5 次这样的事件,每次多花 45 分钟,一年就是 195 小时的额外排查时间。这还没算上凌晨两点被叫起来排查问题的精神损耗。
存储成本的量化
结构化日志比普通文本日志稍大(JSON 格式有额外字段),但现代日志系统(Loki、Elasticsearch)都支持压缩存储,实际磁盘占用比原始大小小很多。更重要的是:日志可以分级保留,热数据(最近 7 天)放高性能存储,冷数据(7-30 天)放廉价存储(S3),超过 30 天的归档或删除。这样日志成本是可控的。
Prometheus 的存储问题更需要关注。默认配置下,Prometheus 会保留 15 天的数据,对于有几百个指标、几千个时间序列的中等规模服务,每天产生几十 MB 的数据。这个量通常不是问题。真正的问题是"高基数标签"——如果你不小心把 user_id 或 request_id 作为 Prometheus 的 label,每个唯一 ID 就是一条时间序列,几百万用户就是几百万条时间序列,内存直接爆。所以 Prometheus 的 label 要严格控制,绝对不能包含唯一标识符。
追踪的采样策略
100% 采样所有请求的 Trace 会产生大量数据。实际上,对于健康的请求,追踪信息往往价值不高——我们更想保留的是"慢请求"和"错误请求"的 Trace。
OpenTelemetry 支持"基于尾部采样"(Tail-based Sampling):先收集所有 Trace 数据,在最后决定是否保留。这样可以做到:正常请求只保留 1%,错误请求和延迟超过 500ms 的请求保留 100%。这个策略能大幅降低追踪存储成本,同时保留了最有价值的数据。
深度解析:可观测性驱动开发(Observability-Driven Development)
一个更高阶的实践是"可观测性驱动开发"——在写功能代码的同时,把可观测性需求一起设计进去。
传统的做法是:先写功能,上线之后发现监控不够,再补日志、加指标、接入追踪。这种事后补救往往不彻底,而且在生产环境打补丁本身就有风险。
可观测性驱动开发的做法是:在设计功能时,同时回答三个问题:
- 这个功能的关键业务事件是什么? 比如"订单创建成功"、"支付发起"、"支付回调接收"。这些事件都要有结构化日志,包含完整的业务上下文(用户 ID、订单 ID、金额)。
- 我需要监控哪些指标来知道这个功能运行正常? 比如支付成功率、支付耗时 P99、第三方接口的错误率。这些指标要在代码里埋点,并且在 Grafana 里有对应的 Dashboard。
- 如果这个功能出了问题,Trace 需要覆盖哪些关键路径? 确保追踪 span 能覆盖外部接口调用、数据库查询等关键 I/O 操作。
把这三个问题作为 PR 的 checklist,让可观测性从"事后补救"变成"功能交付的标准"。这样,每个新功能上线的时候,它的监控体系也已经就绪,不需要等到出了问题才去补。
这种实践对团队文化的要求较高——需要工程师把可观测性视为代码质量的一部分,而不是运维的责任。实现这个文化转变,需要 Tech Lead 以身作则,在 code review 时检查可观测性指标的覆盖情况,就像检查单元测试覆盖率一样。
可观测性最终是为了减少"未知的未知"——你不知道你不知道什么。有了完善的可观测性,当问题发生时,你至少知道去哪里找数据、能找到什么数据。这种"心理安全感"在生产事故响应中是非常重要的,它让工程师可以冷静、系统地排查,而不是在黑暗中随机猜测。
总结
日志、指标、追踪——三位一体,缺一不可。
日志告诉你"发生了什么",指标告诉你"规模有多大",追踪告诉你"路径是什么"。
从我的经验来看,建立可观测性体系最难的不是技术,而是习惯。工程师要养成"写代码的同时考虑可观测性"的意识——每个重要的业务操作都应该有日志、有指标。这个意识比任何工具都重要。
