多服务系统上线后,最常遇到的一个问题不是功能不会写,而是请求报错时根本不知道去哪里查。日志散落在十几个服务节点里,数据库慢 SQL 有一句提示,但一次请求到底经过了哪些服务、在哪一层耗时最高、是哪个环节抛了异常,往往只能靠经验猜。如果把一次请求看成一条从入口流向出口的线索,它在服务之间跨越一个又一个环节时,链路追踪系统就是那位鉴定师:它不眨眼,也不遗忘,会一直看着这条线索留下的全部痕迹。这就是 OpenTelemetry、Jaeger、Prometheus、Grafana 这类可观测性组件存在的意义。这篇文章会从零搭建一套支持 Trace、指标和日志关联的追踪体系,并用一条慢请求演示如何从故障的“残虹”里定位根因。
1. 先理解:日志、指标和链路追踪为什么缺一不可
1.1 三种数据信号决定了排障模式
在可观测性领域,日志、指标和链路追踪不是三个互相替代的方案,而是三种粒度不同的证据来源。
日志面向“事件”,记录的是某个时间点发生的具体事情,例如“数据库连接池获取连接超时”“用户 ID 不存在”。日志能回答“发生了什么”,却不携带全局调用结构。日志量越大,靠关键词人肉拼接不同服务日志的成本越高。
指标面向“聚合”,把一段时间内的状态压缩成数值,例如 QPS、P95 延迟、5xx 错误率。指标适合做趋势判断和告警,通常用 Prometheus 一类的时序数据库存储。但指标只能回答“系统整体是否健康”,无法回答“这一条具体请求经过了哪些服务”。
链路追踪面向“请求”,它把一次请求从入口开始,经过的每个组件都记录成一个 Span,再按照调用关系组成一棵树。链路追踪能回答“这条请求去了哪里、每段耗时多少、哪个 Span 出错”。它需要额外的上下文传递机制和存储,所以实现成本最高,但单请求排障时价值也最大。
| 维度 | 日志 | 指标 | 链路追踪 |
|---|---|---|---|
| 记录单位 | 事件 | 聚合数值 | 请求或事务 |
| 典型问题 | 具体异常堆栈是什么 | 系统整体健康度如何 | 某次请求的完整路径和耗时 |
| 查询方式 | 关键词、时间范围 | PromQL 查询 | trace_id 搜索 |
| 存储成本 | 高 | 低 | 高 |
| 主要场景 | 错误详情回溯 | 趋势、告警 | 单请求根因定位 |
1.2 Trace 和 Span 的最小模型
理解链路追踪,先要理解两个核心概念:Trace 和 Span。
一个 Trace 代表一次完整请求,从客户端进入第一个服务开始,到最后一个服务返回结束。一个 Trace 由多个 Span 组成,每个 Span 代表一个具体操作,例如“接收 HTTP 请求”“查询数据库”“调用下游接口”。
Span 与 Span 之间通过parent_span_id形成父子关系。入口服务创建的 Span 是根 Span,下游服务创建的 Span 挂在根 Span 下面,最终形成一棵调用树。
下面是一个最小 Span 结构,用于理解字段含义:
{ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "parent_span_id": "d2c3f8f7a2f1e9b4", "name": "HTTP GET /api/order", "start_time": 1710000000000000000, "end_time": 1710000000000800000, "status": "OK" }其中trace_id在整个链路中保持不变,span_id是当前操作的唯一标识,parent_span_id指向调用方。只要这些 ID 能顺着调用链传递下去,不同服务的 Span 就能在链路追踪平台里被拼成完整调用图。
1.3 为什么仅靠日志不够
很多团队在业务早期只用日志系统,觉得加一个requestId就能关联所有节点。日志分散在多个服务的文件或索引里,按时间混排后顺序并不可靠;各节点时钟存在偏差时,前后关系甚至会被颠倒;没有父子关系时,阻塞点也很难判断。
更关键的是日志缺少“上下文自动传递”机制。服务 A 调用服务 B,需要在代码里手动把requestId放入 HTTP Header,B 服务再手动取值塞进日志。只要有一个服务忘记传,链路就断了。链路追踪框架则在协议层解决了这个问题,让 Trace 上下文在 HTTP、RPC、消息队列之间自动传播。这也是它比“人肉传递 requestId”更可靠的原因。
2. 环境准备:先把可观测性底座跑起来
2.1 组件清单与分工
本文使用 4 个核心组件搭建本地可观测性环境。它们各自承担不同职责:
| 组件 | 作用 | 选择理由 |
|---|---|---|
| OpenTelemetry Collector | 接收客户端上报的 Trace 和指标数据,做批量、过滤、脱敏后转发 | 统一数据入口,避免每个服务直连存储 |
| Jaeger | 链路数据存储和查询 UI | 开发环境用 all-in-one 模式即可快速启动 |
| Prometheus | 指标抓取和告警计算 | 社区标准指标存储,和 Grafana 配合成熟 |
| Grafana | 指标面板、告警展示 | 统一可视化入口,支持 Prometheus 数据源 |
这里先明确一个原则:服务端 SDK 不直接写数据库,而是把数据上报给 Collector。生产环境下,Collector 承担采样、脱敏、批量发送,并隔离业务网络和存储网络。学习环境为了简单,也可以让 SDK 直接上报 Jaeger,但建议一开始就按 Collector 模式搭,后续迁移成本低。
2.2 docker-compose 启动链路存储和指标组件
在本地新建一个目录,例如observability-demo,然后创建docker-compose.yml:
version: "3.8" services: otel-collector: image: otel/opentelemetry-collector-contrib:0.80.0 command: ["--config=/etc/otelcol-contrib/config.yaml"] volumes: - ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml ports: - "4317:4317" - "4318:4318" depends_on: - jaeger jaeger: image: jaegertracing/all-in-one:1.48 ports: - "16686:16686" - "14250:14250" prometheus: image: prom/prometheus:v2.47.0 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - ./alert.rules.yml:/etc/prometheus/alert.rules.yml ports: - "9090:9090" grafana: image: grafana/grafana:10.1.0 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin镜像版本会随时间更新,落地前建议先确认当前稳定版本。不要直接照搬一个旧版本用于生产,版本落后可能导致 OTLP 协议字段不兼容。
2.3 OpenTelemetry Collector 的配置要点
在同一个目录创建otel-collector.yaml:
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 5s send_batch_size: 1024 exporters: otlp/jaeger: endpoint: jaeger:4317 tls: insecure: true service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlp/jaeger]这份配置只处理 Trace 数据,核心是 OTLP Receiver。业务服务的 OpenTelemetry SDK 通过 gRPC 或 HTTP 协议把 Span 数据发给 Collector,Collector 经过批处理后转发给 Jaeger。
batchprocessor 很重要。它把多条 Span 合并成一个大请求再发送,能显著降低下游存储的压力。生产环境还需要在这里加入资源属性、脱敏规则和采样逻辑。
2.4 验证基础设施是否就绪
启动全部组件:
docker compose up -d docker compose ps确认服务状态为 running 后,做三个健康检查:
curl http://localhost:9090/-/healthy curl http://localhost:3000/api/health浏览器访问以下地址:
- Jaeger UI:http://localhost:16686
- Grafana:http://localhost:3000 ,默认账号
admin,密码admin - Prometheus:http://localhost:9090
注意:Docker Compose 中各组件镜像版本要与后续使用的 OpenTelemetry SDK 兼容。版本相差过大时,OTLP 数据可能无法正常解析,问题会表现为 Jaeger 搜索不到任何 Trace。
3. 给服务埋点:让请求留下完整足迹
3.1 自动埋点与手动埋点的选择
OpenTelemetry 提供两种埋点方式,适合不同场景。
自动埋点通过 Java Agent、Node.js SDK 等机制拦截主流 HTTP 框架、数据库客户端、消息队列客户端,自动为每次外部调用生成 Span。它对业务代码侵入极小,适合快速接入。手动埋点则由开发者在代码里显式创建 Span,记录具有业务语义的操作,例如“校验库存”“计算优惠金额”。
生产环境通常两者结合:自动埋点保证主流程不漏,手动埋点补充业务关键步骤。
3.2 最小 Spring Boot 服务
下面创建一个最小订单服务,模拟一次跨服务调用。这个服务会分别调用用户服务和库存服务。
Maven 依赖只需要 Spring Web 和 Actuator 相关基础依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.2</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> </dependencies>控制器代码:
@RestController @RequestMapping("/api") public class OrderController { private final RestTemplate restTemplate; public OrderController(RestTemplateBuilder builder) { this.restTemplate = builder.build(); } @GetMapping("/order") public Map<String, Object> order(@RequestParam String userId, @RequestParam String productId) { Map<String, Object> user = restTemplate.getForObject( "http://localhost:8082/api/user?userId=" + userId, Map.class); Map<String, Object> stock = restTemplate.getForObject( "http://localhost:8083/api/stock?productId=" + productId, Map.class); Map<String, Object> result = new HashMap<>(); result.put("user", user); result.put("stock", stock); return result; } }启动时通过 Java Agent 自动埋点,这是最省事的接入方式:
java -javaagent:opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -Dotel.traces.exporter=otlp \ -Dotel.exporter.otlp.endpoint=http://localhost:4317 \ -jar order-service.jar关键参数说明:
| 参数 | 含义 | 注意事项 |
|---|---|---|
otel.service.name | 服务名称,Jaeger 中按服务检索时依赖它 | 不要在不同环境使用相同名称 |
otel.traces.exporter | Trace 导出方式 | 这里使用otlp |
otel.exporter.otlp.endpoint | Collector 地址 | 本地是 4317 端口,容器内使用服务名 |
otel.traces.sampler | 采样器 | 开发环境可设为always_on |
3.3 手动创建 Span 捕获业务步骤
自动埋点能覆盖 HTTP 调用,但业务方法内部的耗时和异常并不会自动生成 Span。对于需要重点关注的步骤,应该手动埋点。
注入 OpenTelemetry 的Tracer:
@Autowired private Tracer tracer; public void checkStock(String productId) { Span span = tracer.spanBuilder("checkStock") .setAttribute("product.id", productId) .startSpan(); try (Scope scope = span.makeCurrent()) { // 模拟库存查询 Thread.sleep(300); } catch (Exception e) { span.recordException(e); span.setStatus(StatusCode.ERROR); throw new RuntimeException(e); } finally { span.end(); } }这段代码的关键点有两个。makeCurrent()的作用是把当前 Span 放入上下文,让后续自动创建的 Span 自动挂到它下面;finally中调用end()则保证异常时 Span 也能正常关闭。忘记调用end(),Span 不会上报,耗时统计会失真。
注意:不要在高频业务方法里为每一行代码都创建 Span,粒度控制在“一个外部调用”或“一次关键业务操作”即可,否则 Span 数量会迅速膨胀,存储和排查成本都会上升。
4. 数据是怎么串起来的:上下文传递机制
4.1 W3C Trace Context 协议
要让不同服务生成的 Span 拼成一条链路,关键是上下文在服务间传递。OpenTelemetry 默认遵循 W3C Trace Context 标准,通过 HTTP Header 传递 Trace 信息。
一次标准请求头如下:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01字段拆解:
| 字段段 | 示例值 | 含义 |
|---|---|---|
| 版本号 | 00 | 协议版本 |
| trace-id | 4bf92f3577b34da6a3ce929d0e0e4736 | 32 位十六进制,全局唯一 |
| parent-id | 00f067aa0ba902b7 | 16 位十六进制,调用方 Span ID |
| flags | 01 | 01表示采样,00表示不采样 |
服务 A 发起 HTTP 调用前,SDK 会把当前上下文写入traceparent;服务 B 收到请求后读取该 Header,并把它作为自己新 Span 的父级。这样,一条 Trace 的 ID 就能跨越多个服务保持不变。
4.2 自动传播与断链场景
OpenTelemetry 的 Java Agent 会自动为 RestTemplate、Apache HttpClient、OkHttp 等常见客户端注入 Trace Header,所以上面的订单服务只要能访问用户服务和库存服务,链路通常会自动串起来。
最容易断链的场景包括:
- 使用了框架本身不支持的 HTTP 客户端。
- 代码中使用了自定义线程池,子线程没有继承上下文。
- 通过消息队列发送事件,消费方无法直接继承生产者上下文。
- 使用了异步非阻塞框架,但没有把 Context 传递给回调。
4.3 异步线程和消息队列如何传递上下文
在线程池中,父线程的Context.current()不会自动传给子线程。需要手动把 Context 捕获后放到子线程中。
示例:
ExecutorService executor = Executors.newFixedThreadPool(4); public void asyncProcess(Order order) { Context context = Context.current(); executor.submit(() -> { try (Scope scope = context.makeCurrent()) { sendOrderMessage(order); } }); }这里在提交任务前先拿到Context.current(),然后在子线程中通过context.makeCurrent()恢复上下文。这样子线程内创建的 Span 才能挂到父链路上。
对于消息队列,需要在消息头中透传 Trace 上下文。Kafka 场景下,OpenTelemetry 提供了对应的拦截器,也可以手动在 Producer 和 Consumer 中注入、解析traceparent。
5. 指标与告警:让监控不只停留在“看链路”
5.1 用 Prometheus 收集服务指标
链路追踪解决“单请求怎么看”的问题,指标解决“整体是否健康”的问题。为了让这两个维度能配合,需要让 Prometheus 抓取服务的指标数据。
在 Spring Boot 服务中启用 Actuator 和 Prometheus 注册表后,服务会暴露/actuator/prometheus端点。Prometheus 配置如下:
global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: "demo-services" metrics_path: "/actuator/prometheus" static_configs: - targets: - "host.docker.internal:8081" - "host.docker.internal:8082" - "host.docker.internal:8083"这里的端口对应订单、用户、库存三个服务的端口。Docker 容器内访问宿主机服务需要使用host.docker.internal,如果服务本身部署在宿主机进程中,也可以直接写localhost:8081。
验证是否抓取成功,可以在 Prometheus 页面执行查询:
up结果中对应 target 的up应为1。
5.2 Grafana 面板查询示例
在 Grafana 中添加 Prometheus 数据源,然后建立 Dashboard。常用的三个指标查询如下。
接口 QPS:
sum(rate(http_server_requests_seconds_count[1m])) by (uri)5xx 错误率:
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (uri) / sum(rate(http_server_requests_seconds_count[5m])) by (uri)P95 延迟:
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))这三个查询分别覆盖流量、错误和延迟,是接口监控的基本盘。实际面板中建议按服务、实例、接口维度分组,避免指标叠加后无法区分来源。
5.3 告警规则配置
在alert.rules.yml中定义一条基础告警,当接口 5xx 错误率超过 5% 并持续 5 分钟时触发:
groups: - name: demo-api-alerts rules: - alert: ApiErrorRateHigh expr: | sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m])) > 0.05 for: 5m labels: severity: warning annotations: summary: "API 5xx 错误率超过5%,持续5分钟"告警规则的核心是表达式的准确性,而不是把阈值拍脑袋写死。建议先观察两周基线数据,再根据业务容忍度调整阈值,避免告警频繁误报导致团队麻木。
6. 实战排查:从“残虹”定位慢请求根因
6.1 构造一个可复现的慢请求
为了演示完整排查链路,在库存服务中制造一个慢操作:
@GetMapping("/api/stock") public Map<String, Object> checkStock(@RequestParam String productId) throws InterruptedException { if ("p-001".equals(productId)) { Thread.sleep(800); } Map<String, Object> result = new HashMap<>(); result.put("productId", productId); result.put("stock", 10); return result; }启动订单、用户、库存三个服务后,访问:
curl "http://localhost:8081/api/order?userId=u-100&productId=p-001"预期订单接口耗时在 800 毫秒以上,但具体慢在哪层,需要看链路数据。
6.2 在 Jaeger 中按请求路径检索链路
打开 Jaeger UI,按照以下路径搜索:
- Service 选择
order-service。 - Operation 选择
GET /api/order。 - 点击 Find Traces。
搜索到 Trace 后,点击进入链路详情。瀑布视图会显示每个 Span 的耗时和父子关系。
正常情况下的结果类似:
HTTP GET /api/order:约 800msHTTP GET /api/stock:约 800msHTTP GET /api/user:约 5ms
从瀑布图可以快速判断瓶颈在库存服务的下游调用。再点开库存服务的 Span,查看属性里记录的http.target、http.status_code等数据,确认是接口逻辑慢还是数据库访问慢。
6.3 结合日志中的 trace_id 定位异常点
如果链路中存在异常,Jaeger 只能显示哪个 Span 报错,具体堆栈还要看日志。要让日志和 Trace 关联起来,需要在日志格式中输出 trace_id。
在logback-spring.xml中配置:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%t] [%X{trace_id:-}] [%X{span_id:-}] %logger{36} - %msg%n</pattern>使用 OpenTelemetry Java Agent 启动服务时,它会自动把trace_id和span_id写入日志 MDC。业务代码不需要额外处理,日志行中就会出现可检索的 Trace ID。
排障顺序:
- 在某条日志中复制
trace_id。 - 在日志平台搜索该
trace_id,得到这次请求在所有服务中打印的日志。 - 在 Jaeger 搜索框粘贴同一个
trace_id,查看链路结构。 - 结合错误日志和 Span 耗时,区分是业务异常、依赖慢还是数据结构问题。
注意:在分析 Span 时间差之前,先确认所有节点时间一致。节点时钟不同步会导致 Span 排序错乱,耗时统计也会失真。
6.4 一套完整的排障顺序
实际生产中,建议按以下顺序处理一次跨服务故障:
- 先看入口服务有没有收到请求,确认请求是否到达系统。
- 再看入口服务有没有完整 Trace,确认采集链路是否正常。
- 沿着链路瀑布图逐层查看 Span 耗时和状态码。
- 筛选错误 Span,查看其中的异常属性和日志关联。
- 返回指标系统查看该接口的错误率和延迟趋势,判断是偶发还是持续恶化。
- 最后回到代码,修复根因并部署验证。
这套顺序比直接翻日志更高效,因为链路结构先给了你一张“地图”,日志只是定位到节点后的细节补充。
7. 常见问题排查清单
7.1 高频问题的现象、原因和处理方式
以下表格整理了全链路追踪体系搭建过程中最常见的几类问题。
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| Jaeger 搜索不到 Trace | 启动参数没配置,或 SDK 版本与 Collector 不兼容 | 查看 Collector 日志,检查启动参数 | 确认otel.exporter.otlp.endpoint地址正确,统一升级 SDK |
| 链路在服务边界断开 | HTTP 客户端不被 SDK 支持,或异步线程未传上下文 | 检查调用方日志是否出现 trace_id | 升级 SDK,或手动传播 Context |
| Span 时间乱序 | 各节点时钟不一致 | 执行date对比容器时间 | 为所有节点配置 NTP 时间同步 |
| Trace 里有 Span 但缺业务细节 | 只用了自动埋点 | 查看 Span 属性 | 在关键业务方法手动创建 Span |
| 日志中无 trace_id | Logback pattern 没配置 MDC,或服务没用 Agent 启动 | 查看日志输出格式 | 修改 pattern,确认带-javaagent启动 |
| 开发环境链路不完整 | 采样率太低 | 检查 Trace 数量与请求量比例 | 开发环境设置OTEL_TRACES_SAMPLER=always_on |
| Collector 长时间内存增长 | batch 配置不合理 | 查看容器内存监控 | 调整 batch timeout 和 send_batch_size,或扩容 |
7.2 通用排查步骤
遇到链路数据异常时,优先按这个顺序排查:
- 确认服务是否使用带埋点的 Agent 或 SDK 启动。
- 确认
otel.service.name是否设置,且是否与 Jaeger 中看到的服务名一致。 - 确认客户端能访问 Collector 的 4317 或 4318 端口。
- 确认 Collector 的 pipeline 是否同时配置了 receiver、processor、exporter。
- 确认采样器配置没有把请求全部丢弃。
- 确认下游服务和上游服务版本一致,避免 OTLP 字段解析失败。
这条链路每一步都能验证,问题通常能在一两轮检查内定位。
8. 生产环境落地:采样、安全和扩展
8.1 采样策略怎么选
链路数据量远大于日志量,生产环境不可能也不应该全量保存所有请求的 Trace。采样是必须做的。
头部采样在入口服务根据 trace_id 哈希决定是否