生产环境可观测性全栈复盘:一个订单从创建到支付的全链路追踪实战
引言
去年双十一压测,订单链路在 3000 QPS 时出现零星超时。监控大盘显示一切"绿色正常"——各服务 QPS 正常、P99 < 200ms、错误率 0.01%。但用户侧反馈"下单偶尔转圈 5 秒"。
我们花了 4 小时定位问题。过程极其痛苦:
- 订单服务日志说"调用支付超时",支付服务日志说"没收到请求"——谁在说谎?
- 网关日志显示请求确实到了支付服务,耗时 3.2s——3.2 秒花在哪了?
- 支付服务的 JVM 监控正常、DB 监控正常、Redis 监控正常——那 3.2 秒到底在等什么?
最后发现是支付服务调用的某银行网关 TLS 握手偶发慢(银行侧负载均衡抖动),但定位过程暴露了一个致命问题:我们有监控、有日志、有告警,但它们是三个孤岛。Metric 告诉你"有问题",但看不到调用链;Log 能搜到报错,但串不起来跨服务的因果关系;Trace 压根没接。
那之后我们花了三周搭了完整的可观测性体系:OpenTelemetry 采集 + Jaeger 存 Trace + Prometheus 存 Metric + Loki 存 Log,全部用 traceId 串联。再遇到同类问题,从告警触发到根因定位,平均 3 分钟。
这篇文章用一条真实的订单链路(用户下单 → 订单服务 → 库存服务 → 支付服务 → 通知服务),从三个视角完整复盘可观测性体系怎么用:
- Trace 视角:一次请求的完整调用链,每个 Span 的耗时一目了然
- Metric 视角:各服务 QPS / 延迟 / 错误率的 Grafana 仪表盘
- Log 视角:Loki 中按 traceId 聚合的跨服务完整日志
最后附三个常见故障场景(支付超时 / 库存扣减失败 / 通知丢失)在可观测性平台上的一键定位方法。
一、可观测性三支柱:不是三个工具,是三个视角
1.1 三支柱各自能做什么、不能做什么
| 支柱 | 回答的问题 | 能做 | 不能做 |
|---|---|---|---|
| Metric | "有没有问题?问题有多严重?" | 全局趋势、告警触发、容量规划 | 看不到具体哪条请求出错、为什么出错 |
| Log | "出了什么错?错误细节是什么?" | 精确的错误堆栈、业务上下文 | 串联跨服务因果关系(除非带 traceId) |
| Trace | "请求经过了哪些服务?每一段花了多少时间?" | 调用链全貌、瓶颈定位、服务依赖关系 | 不适合做趋势统计和告警(采样率限制) |
三支柱单独存在都是残缺的。Metric 告诉你"支付服务 P99 飙到 3 秒了",但不告诉你哪条请求、为什么。Log 告诉你"支付超时了",但看不到上游订单服务等了多久、下游银行网关响应了多久。Trace 告诉你"整条链路 3.2 秒,其中 2.8 秒在银行网关",但不适合做"过去 1 小时多少请求超时"的统计。
真正的可观测性 = 三支柱用 traceId 串联起来:
Metric 告警:"支付服务 P99 > 3s"
↓ 点开告警里的 traceId 链接
Trace 定位:"traceId=abc123 的调用链,2.8s 在银行 TLS 握手"
↓ 点开 Trace 里某个 Span 的日志链接
Log 确认:"TLS handshake to bank-gw timeout after 2800ms, retrying..."
↓ 根因确认,开始修复
这就是"一键定位"的本质——三个视角之间用 traceId 做导航桥梁。
1.2 技术栈选型
| 层 | 选型 | 理由 |
|---|---|---|
| 采集 | OpenTelemetry SDK | CNCF 标准,一次接入三支柱全出;厂商中立 |
| Trace 存储 | Jaeger / Tempo | Jaeger 生态成熟;Tempo 与 Loki/Grafana 原生集成更佳 |
| Metric 存储 | Prometheus | 事实标准 |
| Log 存储 | Loki | 与 Grafana 同一 UI,LogQL 语法对标 PromQL |
| 展示 | Grafana | 一个面板看三支柱,traceId 互相跳转 |
二、业务场景:一条订单链路的完整拓扑
2.1 链路结构
用户 APP
│
▼
API 网关 (Spring Cloud Gateway)
│ span: gateway.receive
▼
订单服务 (order-service)
│ span: order.create
├──→ 库存服务 (inventory-service) span: inventory.deduct
│ └──→ MySQL span: db.update
├──→ 支付服务 (payment-service) span: payment.charge
│ ├──→ Redis (幂等检查) span: redis.get
│ └──→ 银行网关 (bank-gateway) span: http.post (外部调用)
└──→ 通知服务 (notify-service) span: notify.send
└──→ 消息队列 (Kafka) span: mq.produce
一次"创建订单"请求,经过 5 个服务、2 个中间件、1 个外部网关,产生 10+ 个 Span。如果没有 Trace,任何一个环节出问题都是"黑盒"。
2.2 OpenTelemetry 接入(以订单服务为例)
/**
* 订单服务创建订单的方法,OpenTelemetry 自动埋点 + 手动 Span
*
* 自动埋点:HTTP 入口、Feign 调用、Kafka 生产、JDBC 已由 OTel agent 自动生成 Span
* 手动埋点:业务语义的 Span 需要手动加,让 Trace 有业务可读性
*/
@Service
@RequiredArgsConstructor
public class OrderService {
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
private final NotifyClient notifyClient;
private final OrderMapper orderMapper;
public OrderVO createOrder(OrderRequest request) {
// OpenTelemetry 自动捕获 HTTP 入口 Span(gateway → order-service)
// 手动创建业务 Span:让 Trace 里看到"创建订单"这个业务动作
Span orderSpan = tracer.spanBuilder("order.create").startSpan();
try (Scope scope = orderSpan.makeCurrent()) {
// Span 属性:在 Jaeger/Tempo 里可直接搜索、过滤
orderSpan.setAttribute("order.userId", request.getUserId());
orderSpan.setAttribute("order.amount", request.getAmount());
orderSpan.setAttribute("order.productId", request.getProductId());
// 1. 扣库存(Feign 调用,OTel 自动生成 inventory.deduct Span)
inventoryClient.deduct(request.getProductId(), request.getQuantity());
// 2. 支付(Feign 调用,OTel 自动生成 payment.charge Span)
PaymentResult payment = paymentClient.charge(
request.getUserId(), request.getAmount(), request.getOrderId());
if (!payment.isSuccess()) {
orderSpan.recordException(new RuntimeException("支付失败"));
orderSpan.setStatus(StatusCode.ERROR, "payment failed");
// 回滚库存
inventoryClient.rollback(request.getProductId(), request.getQuantity());
throw new BizException("支付失败");
}
// 3. 落库
Order order = Order.from(request, payment.getTradeNo());
orderMapper.insert(order);
orderSpan.setAttribute("order.id", order.getId());
// 4. 异步通知(Kafka 生产,OTel 自动生成 mq.produce Span)
notifyClient.sendOrderCreatedEvent(order.getId());
orderSpan.setStatus(StatusCode.OK);
return OrderVO.from(order);
} catch (Exception e) {
orderSpan.recordException(e);
orderSpan.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
orderSpan.end();
}
}
}
关键细节:MDC 注入 traceId,让日志天然带上 traceId
/**
* OTel agent 自动把 traceId 注入 MDC(SLF4J MDC),
* logback 配置里引用 traceId 即可让每行日志自动携带
*/
// logback.xml pattern:
// %d{HH:mm:ss.SSS} [%X{trace_id}] [%X{span_id}] %-5level %logger - %msg%n
//
// 输出效果:
// 14:32:05.123 [a1b2c3d4e5f67890] [a1b2c3d4e5f6] INFO OrderService - 创建订单 userId=U5001
// ^^^^^^^^^^^^^^^^
// 这就是 traceId,Loki 里用它一搜全链路日志全出来
三、Trace 视角:一次请求的完整调用链
3.1 Jaeger 里的调用链长什么样
一次成功的订单创建请求,在 Jaeger 里的 Trace 全貌:
Trace: a1b2c3d4e5f67890 (总耗时 187ms)
│
├─ gateway.receive [0ms ─────────────── 187ms] 187ms
│ │
│ ├─ order.create [2ms ─────────────── 185ms] 183ms
│ │ │
│ │ ├─ inventory.deduct [5ms ────── 28ms] 23ms
│ │ │ └─ db.update [8ms ─── 22ms] 14ms
│ │ │
│ │ ├─ payment.charge [30ms ────────────── 152ms] 122ms
│ │ │ ├─ redis.get [31ms ─ 34ms] 3ms
│ │ │ └─ http.post (bank-gateway) [37ms ────────── 148ms] 111ms ← 最慢的段
│ │ │
│ │ ├─ db.insert [155ms ─ 162ms] 7ms
│ │ │
│ │ └─ mq.produce [165ms ─ 172ms] 7ms
│ │
│ └─ (gateway 写响应) [180ms ─ 187ms] 7ms
一条 Trace 回答的问题:
- 总耗时 187ms,其中 122ms 在支付服务 → 支付是瓶颈
- 支付服务里 111ms 花在银行网关 HTTP 调用 → 外部依赖是根因
- 库存扣减 23ms、DB insert 7ms、Kafka produce 7ms → 都正常
- 调用链顺序是串行的:库存 → 支付 → 落库 → 通知 → 库存可以和支付并行吗?(优化方向)
3.2 从 Trace 到根因:一次真实超时定位
压测时某条请求 Trace 总耗时 3.2 秒:
Trace: b2c3d4e5f6789012 (总耗时 3200ms) ⚠️
├─ gateway.receive [0ms ─────────────────────── 3200ms] 3200ms
│ └─ order.create [3ms ─────────────────────── 3197ms] 3194ms
│ ├─ inventory.deduct [5ms ── 28ms] 23ms ✓
│ ├─ payment.charge [30ms ───────────────────── 3120ms] 3090ms ⚠️
│ │ ├─ redis.get [31ms ─ 34ms] 3ms ✓
│ │ └─ http.post (bank-gateway) [37ms ──────────────────── 3110ms] 3073ms ❌
│ │ └─ 属性: http.status_code=504
│ │ └─ 事件: tls.handshake 事件标记
│ │ └─ 日志: "bank-gateway timeout after 3000ms"
│ ├─ db.insert [3122ms ─ 3128ms] 6ms ✓
│ └─ mq.produce [3130ms ─ 3135ms] 5ms ✓
一眼定位:3.2 秒中的 3.07 秒花在银行网关 HTTP 调用上,且返回了 504。Span 的属性里标注了 http.status_code=504、事件里记录了 TLS 握手。如果没有 Trace,这个问题的排查路径是:网关日志 → 订单日志 → 支付日志 → 银行网关日志,四个系统翻 4 小时。
3.3 Trace 的搜索与过滤
Jaeger/Tempo 支持按 Span 属性搜索,这是排查"特定条件下的慢请求"的利器:
// 搜索"金额大于 10000 且支付失败的 Trace"
trace.json | span.name = "payment.charge" AND span.status = ERROR
AND span.attr.order.amount > 10000
// 搜索"银行网关耗时超过 2 秒的 Trace"
trace.json | span.name = "http.post" AND span.attr.peer.service = "bank-gateway"
AND span.duration > 2s
生产中把这些搜索存成"书签",故障排查时一键调出。我们在 Grafana 里做了三个常用书签:
- "支付失败 Trace":
span.name=payment.charge AND status=ERROR - "慢请求 Trace":
duration > 1s - "库存回滚 Trace":
span.name=inventory.rollback
四、Metric 视角:QPS / 延迟 / 错误率仪表盘
4.1 四个黄金信号
Google SRE 的"四个黄金信号"是我们 Grafana 大盘的核心结构:
| 信号 | 含义 | PromQL | 告警阈值 |
|---|---|---|---|
| 延迟 | 请求处理时间 | histogram_quantile(0.99, rate(http_server_request_duration_seconds_bucket[5m])) | P99 > 1s |
| 流量 | QPS | rate(http_server_request_count_total[5m]) | 突增 200% / 突降 50% |
| 错误 | 错误率 | rate(http_server_request_count_total{status=~"5.."}[5m]) / rate(http_server_request_count_total[5m]) | > 1% |
| 饱和度 | 资源利用率 | process_cpu_usage / jvm_memory_used_bytes | CPU > 80% / Heap > 85% |
4.2 订单链路的 Grafana 仪表盘
一个面板看五个服务的核心指标,异常时一眼定位是哪个服务的问题:
┌─────────────────────────────────────────────────────────────────┐
│ 订单链路总览 时间范围: 最近1h │
├──────────────┬──────────┬──────────┬──────────┬──────────────────┤
│ 服务 │ QPS │ P99 延迟 │ 错误率 │ 状态 │
├──────────────┼──────────┼──────────┼──────────┼──────────────────┤
│ gateway │ 3,200 │ 15ms │ 0.02% │ 🟢 正常 │
│ order-svc │ 3,180 │ 180ms │ 0.01% │ 🟢 正常 │
│ inventory-svc│ 3,180 │ 22ms │ 0.00% │ 🟢 正常 │
│ payment-svc │ 3,180 │ 145ms │ 0.15% │ 🟡 错误率略高 │
│ notify-svc │ 3,180 │ 8ms │ 0.00% │ 🟢 正常 │
└──────────────┴──────────┴──────────┴──────────┴──────────────────┘
对应的核心 PromQL:
# ── 每个服务的 QPS(按服务名分组) ──
sum(rate(http_server_requests_seconds_count{job=~"gateway|order|inventory|payment|notify"}[5m]))
by (job)
# ── 每个服务的 P99 延迟 ──
histogram_quantile(0.99,
sum(rate(http_server_requests_seconds_bucket{job=~"gateway|order|inventory|payment|notify"}[5m]))
by (le, job))
# ── 每个服务的错误率 ──
sum(rate(http_server_requests_seconds_count{job=~"gateway|order|inventory|payment|notify", status=~"5.."}[5m]))
by (job)
/
sum(rate(http_server_requests_seconds_count{job=~"gateway|order|inventory|payment|notify"}[5m]))
by (job)
4.3 指标→Trace 的跳转
Grafana 里点一下面板就能跳到 Trace,这是"一键定位"的关键配置:
# Grafana Data Link 配置:从 Metric 面板跳转到 Jaeger/Tempo
# 在仪表盘的 Panel → Data Links 里添加:
{
"title": "查看慢请求 Trace",
"url": "http://jaeger:16686/search?service=${__field.labels.job}&limit=20&tags={\"error\":true}&lookback=1h",
"targetBlank": true
}
效果:在大盘上看到 payment-svc 的 P99 飙高,点"查看慢请求 Trace"直接跳到 Jaeger,已过滤好 payment-svc 的错误 Trace 列表。这就是 Metric → Trace 的桥。
4.4 自定义业务指标
除了 HTTP 通用指标,订单链路还需要业务指标——这些需要手动打点:
/**
* 业务指标打点:用 Micrometer(Spring Boot 内置)
* 这些指标最终被 Prometheus 采集,在 Grafana 里做业务监控
*/
@Component
@RequiredArgsConstructor
public class OrderMetrics {
private final MeterRegistry meterRegistry;
// 计数器:订单创建总数(按状态标签区分成功/失败)
private Counter orderCreatedTotal;
// 计时器:支付耗时分布
private Timer paymentLatency;
// 仪表:当前在途订单数
private AtomicInteger inFlightOrders = new AtomicInteger(0);
@PostConstruct
public void init() {
orderCreatedTotal = Counter.builder("order.created.total")
.tag("status", "success") // success / failed
.description("订单创建总数")
.register(meterRegistry);
paymentLatency = Timer.builder("payment.latency")
.description("支付耗时分布")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
Gauge.builder("order.in.flight", inFlightOrders, AtomicInteger::doubleValue)
.description("当前在途订单数")
.register(meterRegistry);
}
public void recordOrderCreated(boolean success) {
orderCreatedTotal.increment();
// 失败的另打一个标签
}
public Timer.Sample startPaymentTimer() {
return Timer.start(meterRegistry);
}
public void stopPaymentTimer(Timer.Sample sample) {
sample.stop(paymentLatency);
}
public void incrementInFlight() { inFlightOrders.incrementAndGet(); }
public void decrementInFlight() { inFlightOrders.decrementAndGet(); }
}
业务指标的 Grafana 面板:
┌────────────────────────────────────────────────────┐
│ 订单业务指标 时间: 最近1h │
├────────────────┬───────────┬───────────────────────┤
│ 订单创建成功率 │ 99.87% │ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁ │
│ 支付 P99 延迟 │ 148ms │ ▃▄▃▃▅▆▇█▇▆▅▄▃▃▂ │
│ 在途订单数 │ 42 │ ▁▂▃▄▅▆▇▆▅▄▃▂▁▂▃ │
│ 支付失败率 │ 0.13% │ ▁▁▁▁▂▁▁▁▁▁▁▁▁▁▁ │
└────────────────┴───────────┴───────────────────────┘
五、Log 视角:Loki 按 traceId 聚合全链路日志
5.1 为什么 Log 要和 Trace 关联
Trace 告诉你"哪一段慢",但不告诉你"慢的原因细节"。比如银行网关 Span 显示 3 秒、status=504——但"为什么 504"需要看日志:
// 支付服务日志(traceId 关联后,Loki 里一搜全出)
14:32:05.037 [b2c3d4e5f6789012] INFO PaymentService - 开始支付 orderId=ORD2024...
14:32:05.038 [b2c3d4e5f6789012] DEBUG PaymentService - 幂等检查通过, tradeNo不存在
14:32:05.040 [b2c3d4e5f6789012] INFO PaymentService - 调用银行网关 bankId=ICBC
14:32:05.041 [b2c3d4e5f6789012] DEBUG HttpClient - TLS握手开始 bank-gw.icbc.com:443
14:32:08.110 [b2c3d4e5f6789012] WARN HttpClient - TLS握手完成 耗时=3069ms ← 这里!
14:32:08.112 [b2c3d4e5f6789012] ERROR PaymentService - 银行网关返回504, body=Gateway Timeout
14:32:08.113 [b2c3d4e5f6789012] INFO PaymentService - 支付失败 orderId=ORD2024... reason=BANK_TIMEOUT
没有 traceId 的话,支付服务的日志只是孤立的一堆行,你不知道哪些行属于同一个请求。有了 traceId,一条 LogQL 搜出全链路五个服务的所有日志:
5.2 Loki LogQL 查询
# 按 traceId 搜索全链路日志(最常用的排查命令)
{job=~"gateway|order|inventory|payment|notify"} |= "b2c3d4e5f6789012"
# 输出(按时间排序,跨服务聚合):
14:32:05.001 [b2c3d4e5f6789012] gateway - 收到请求 POST /api/order/create
14:32:05.003 [b2c3d4e5f6789012] order-svc - 创建订单 userId=U5001 amount=9900
14:32:05.006 [b2c3d4e5f6789012] inventory-svc- 扣减库存 productId=P1001 qty=1
14:32:05.022 [b2c3d4e5f6789012] inventory-svc- 库存扣减成功, 剩余=99
14:32:05.031 [b2c3d4e5f6789012] payment-svc - 开始支付 orderId=ORD2024...
14:32:05.038 [b2c3d4e5f6789012] payment-svc - 幂等检查通过
14:32:05.040 [b2c3d4e5f6789012] payment-svc - 调用银行网关 bankId=ICBC
14:32:05.041 [b2c3d4e5f6789012] payment-svc - TLS握手开始
14:32:08.110 [b2c3d4e5f6789012] payment-svc - TLS握手完成 耗时=3069ms ← 瓶颈
14:32:08.112 [b2c3d4e5f6789012] payment-svc - 银行网关返回504
14:32:08.113 [b2c3d4e5f6789012] payment-svc - 支付失败 reason=BANK_TIMEOUT
14:32:08.115 [b2c3d4e5f6789012] order-svc - 支付失败,回滚库存
14:32:08.120 [b2c3d4e5f6789012] inventory-svc- 库存回滚成功
14:32:08.122 [b2c3d4e5f6789012] order-svc - 订单创建失败 reason=PAYMENT_FAILED
14:32:08.125 [b2c3d4e5f6789012] gateway - 返回响应 500 耗时=3122ms
14 行日志,完整还原了一次请求从进入到失败的每一步。这就是 traceId 串联日志的威力。
5.3 Loki 配置:结构化日志 + 标签索引
日志要被高效搜索,需要结构化输出 + 合理标签:
<!-- logback.xml:JSON 格式输出,traceId/spanId 作为字段 -->
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
<http>
<url>http://loki:3100/loki/api/v1/push</url>
</http>
<format>
<label>
<!-- 标签:job 和 level 做索引(高基数标签如 traceId 不做索引) -->
<pattern>job=order-service,level=%level</pattern>
</label>
<message>
<pattern>
{
"ts":"%d{yyyy-MM-dd'T'HH:mm:ss.SSS'Z'}",
"traceId":"%X{trace_id}",
"spanId":"%X{span_id}",
"logger":"%logger",
"msg":"%msg",
"stacktrace":"%ex"
}
</pattern>
</message>
</format>
</appender>
关键设计:traceId 不做 Loki 标签(label),而是放在日志 JSON 体里。因为 traceId 是高基数值(每条请求一个),做标签会让 Loki 索引爆炸。查询时用 |= 做全文过滤——Loki 的全文过滤是流式扫描,高基数下比标签索引更高效。
5.4 Grafana 里 Log → Trace 的跳转
在 Grafana 的 Log 面板里,每行日志的 traceId 字段自动变成可点击链接:
# Grafana 日志面板 → Derived Field 配置
{
"name": "Trace",
"matcher_regex": "traceId\":\"([a-f0-9]+)\"",
"url": "/explore?orgId=1&left={\"datasource\":\"Jaeger\",\"queries":[{"query":"$${__value.raw}"}]}"
}
效果:在 Loki 搜索结果里看到某条错误日志,点它的 traceId 链接直接跳到 Jaeger 看完整调用链。Log → Trace 的桥搭好了。
六、三视角联动:一键定位故障
6.1 场景一:支付超时
现象:Grafana 告警——"payment-svc P99 > 3s"
一键定位流程:
① Metric 告警点击 → payment-svc P99 面板
看到:P99 从 150ms 飙到 3200ms,开始时间 14:30
↓
② 面板 Data Link "查看慢请求 Trace" → Jaeger
过滤:service=payment-svc, duration>2s
看到:最慢的 Trace b2c3d4e5f6789012, 总耗时 3.2s
↓
③ Trace 里找到最慢的 Span
看到:http.post (bank-gateway) 耗时 3073ms, status=504
↓
④ 点 Span 的 Log 链接 → Loki
搜:traceId=b2c3d4e5f6789012
看到:"TLS握手完成 耗时=3069ms" → 银行网关 TLS 握手慢
↓
⑤ 根因确认:银行网关侧 TLS 抖动
处置:联系银行确认;临时增大支付超时到 5s + 加重试
总耗时:3 分钟。 没有这套体系时同样的问题花了 4 小时。
6.2 场景二:库存扣减失败
现象:业务反馈——"下单偶发报错'库存不足',但库存明明够"
一键定位流程:
① Loki 搜索错误日志
搜:{job="inventory-svc"} |= "库存不足" | json | line_format "{{.msg}}"
看到:多笔订单报"库存不足",traceId 分别是 abc123 / def456 / ghi789
↓
② 点 traceId abc123 → Jaeger
看到 Trace:
├─ order.create 45ms
│ └─ inventory.deduct 42ms ⚠️ status=ERROR
│ └─ db.update 40ms
│ 属性: db.statement="UPDATE stock SET qty=qty-? WHERE product_id=? AND qty>=?"
│ 事件: affected_rows=0 ← 扣减影响行数为 0
↓
③ Trace 里看 inventory.deduct 的日志
搜:traceId=abc123
"扣减库存 productId=P1001 qty=1"
"UPDATE 影响 0 行, 库存不足"
↓
④ 查同一商品同一时段的库存操作
Loki 搜:{job="inventory-svc"} |= "P1001"
发现:同一秒内有 3 条扣减请求,库存只有 2 个
↓
⑤ 根因确认:并发扣减竞争(库存=2,3 个请求同时通过 qty>=1 检查但只有 2 个能扣成功)
处置:DB 层面加乐观锁版本号 / Redis 预扣减
6.3 场景三:通知丢失
现象:用户反馈——"支付成功了但没收到短信通知"
一键定位流程:
① 查订单的 traceId(从订单详情页或按 orderId 搜日志)
Loki 搜:{job="order-svc"} |= "ORD20240822001"
找到:traceId=jkl012, 日志显示"通知服务已发送"
↓
② 点 traceId → Jaeger
看到 Trace:
├─ order.create 180ms
│ └─ mq.produce 7ms ← Kafka 生产成功
│
// Trace 到这里就断了!notify-service 的 Span 不在这条 Trace 里
// 因为 Kafka 是异步消费,消费侧是另一个 trace context
↓
③ 这就是异步消息追踪的特殊之处
查 notify-service 的消费日志
Loki 搜:{job="notify-svc"} |= "ORD20240822001"
↓
情况A:找到日志"消费消息 orderId=ORD20240822001"
→ 但 SMS 发送失败,日志显示"短信网关返回 500"
→ 根因:短信网关故障
情况B:没找到消费日志
→ 消息没被消费,查 Kafka lag
→ Grafana Kafka 面板:notify-topic lag > 5000
→ 根因:消费者实例 OOM 重启,积压未处理
↓
④ 处置:
情况A:重发短信 + 短信网关告警
情况B:扩容消费者 + 补偿积压消息
异步消息的 Trace 链路会断——生产者和消费者不在同一个 Trace 上下文。OpenTelemetry 的 Kafka instrumentation 会通过消息头传递 trace 上下文,但需要消费侧也接入 OTel agent 才能接续。生产侧 Trace 到 mq.produce 结束,消费侧 Trace 以 mq.consume 开始,两者通过消息头里的 traceId 关联。在 Jaeger 里搜索时可以用"子串搜索"找到关联的另一半。
七、可观测性体系全景图
把三支柱和跳转关系画一张全景图,这就是我们生产环境的完整可观测性拓扑:
┌──────────────────────────────────────────────────────────────────────┐
│ Grafana 统一面板 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Metric 面板 │ │ Trace 面板 │ │ Log 面板 │ │
│ │ (Prometheus) │ │ (Tempo) │ │ (Loki) │ │
│ │ │ │ │ │ │ │
│ │ QPS/P99/错误率│ │ 调用链/Span │ │ traceId聚合日志 │ │
│ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │Data Link│─┼──┼──→│Trace │─┼──┼──│ 日志 │ │ │
│ │ │→ Trace │ │ │ │ │ │ │ │ │ │ │
│ │ └─────────┘ │ │ │ Span │─┼──┼──│ 每行有traceId链接 │ │
│ │ │ │ │→ Log │ │ │ └──┬───┘ │ │
│ │ │ │ └──────┘ │ │ │点traceId │ │
│ │ │ │ ↑ │ │ │→ 跳Trace │ │
│ └──────────────┘ └──────┼──────┘ └─────┼────────────────────┘ │
│ │ │ │
│ 三支柱用 traceId 串联 ←────┘ │
└──────────────────────────────────────────────────────────────────────┘
↑ ↑ ↑
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│Prometheus│ │ Tempo │ │ Loki │
│ (Metric)│ │ (Trace) │ │ (Log) │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└────────────┬───────┴────────────────────┘
│
┌──────┴──────┐
│OpenTelemetry│ ← 一次接入,三支柱全出
│ Agent │ 自动埋点 HTTP/DB/Redis/Kafka
└─────────────┘
三座桥的配置清单:
| 桥 | 方向 | 配置位置 |
|---|---|---|
| Metric → Trace | Grafana Panel Data Link → Jaeger | 仪表盘 Panel → Data Links |
| Trace → Log | Jaeger Span → Loki | Tempo/Jaeger → Log JSON 链接 |
| Log → Trace | Loki 日志行 traceId → Jaeger | Grafana Log Derived Field |
三座桥搭好,排查故障时在三个视角之间自由跳转,不再"翻四个系统四小时"。
八、常见问题
8.1 OpenTelemetry 的性能开销大吗?
自动埋点的开销约 3~5% CPU 和 10~20MB 内存/实例。Span 采集通常配 10~20% 采样率(高流量接口)到 100%(低流量管理接口)。关键配置:tail-based sampling——按链路特征采样而非头部随机采样,保证"慢请求、错误请求"的 Trace 100% 保留,正常请求采样 1~5%。这样存储成本可控,排查问题不缺数据。
8.2 traceId 怎么自动传到日志里的?
OpenTelemetry Java Agent 自动把 traceId 写入 MDC(Mapped Diagnostic Context),变量名 trace_id 和 span_id。logback 的 pattern 里用 %X{trace_id} 引用即可。不需要改业务代码,Agent 在 JVM 启动时 -javaagent:otel-agent.jar 挂载自动完成。
8.3 Kafka 异步消息的 Trace 链路怎么不断?
OTel 的 Kafka instrumentation 在生产消息时把 trace 上下文写入消息头(traceparent W3C 标准头),消费侧的 OTel agent 自动读取并接续 Trace。前提:消费侧也挂了 OTel agent。如果消费侧没接 OTel,链路在 mq.produce 处就断了——这种情况用 orderId 在 Loki 里跨服务搜日志做"弱关联"。
8.4 Loki 和 ELK 怎么选?
| | Loki | ELK |
|--|------|-----|
| 架构 | 只索引标签,日志体存对象存储 | 全文索引,日志体存本地磁盘 |
| 成本 | 低(对象存储 + 只索引标签) | 高(全文索引吃内存和磁盘) |
| 查询 | LogQL 流式扫描,适合"按标签+关键词" | 全文检索,适合"任意字段搜索" |
| 和 Trace 关联 | Grafana 原生集成,traceId 跳转丝滑 | 需要额外配置 Kibana → Jaeger |
| 适用 | 微服务 + traceId 关联场景 | 日志分析平台、复杂全文检索 |
我们选 Loki 的核心原因:和 Grafana/Tempo 原生集成,三支柱跳转零摩擦。ELK 搜日志更强,但跨系统跳转体验差。
8.5 没有接入 OpenTelemetry,怎么快速给日志加 traceId?
如果暂时接不了 OTel,最简方案:网关生成 traceId(UUID)放入 X-Trace-Id 请求头,各服务用 Filter 提取并写入 MDC。这是"穷人版 Trace",没有 Span 调用链,但 Log 按 traceId 聚合能做。后续接 OTel 时可以平滑迁移——OTel 的 traceId 是兼容 W3C Trace Context 标准的。
8.6 采样率太低,排查时找不到 Trace 怎么办?
两个策略:① tail-based sampling 保证错误和慢请求的 Trace 100% 保留,正常请求低采样——排查时大多数是排查"异常请求",它们一定有 Trace;② 临时调高采样:故障期间通过配置中心把采样率从 10% 调到 100%,故障过后调回;③ 强制采样:特定接口(如支付、下单)的请求头加 trace-sampling=true 标记,OTel collector 识别后强制 100% 采集。
九、总结
三支柱速查卡
┌──────────┬────────────────────────────┬──────────────────────────┐
│ 支柱 │ 回答什么问题 │ 关键工具 │
├──────────┼────────────────────────────┼──────────────────────────┤
│ Metric │ 有没有问题?多严重? │ Prometheus + Grafana │
│ Trace │ 哪段慢?哪个服务?为什么? │ Jaeger / Tempo │
│ Log │ 具体错了什么?细节是什么? │ Loki │
├──────────┼────────────────────────────┼──────────────────────────┤
│ 串联桥梁 │ traceId(三支柱共用) │ OpenTelemetry MDC 自动注入│
└──────────┴────────────────────────────┴──────────────────────────┘
一键定位三步法
① Metric 告警 → 看哪个服务的哪个指标异常
② 点 Data Link → 跳 Trace,找最慢/出错的 Span
③ 点 Span → 跳 Loki,按 traceId 看全链路日志确认根因
三步走完,从"告警"到"根因",3 分钟。
关键数据
- 接入前:同类故障排查平均 4 小时
- 接入后:平均 3 分钟(Metric→Trace→Log 三步走完)
- OTel 性能开销:CPU 3~5%,内存 10~20MB/实例
- tail-based sampling:错误和慢请求 100% 采样,正常请求 1~5%
- Loki 日志查询:按 traceId 全文过滤,5 个服务 14 行日志 < 200ms
一句话
可观测性不是"装三个工具",而是"用 traceId 把三个视角串成一条路"。Metric 告诉你哪里着火了,Trace 告诉你火从哪间房蔓延的,Log 告诉你是什么烧着的——三个视角之间用 traceId 一键跳转,3 分钟从告警到根因。
给团队的建议
| 阶段 | 建议 |
|---|---|
| 零可观测性 | 先接 OTel agent + Prometheus + Loki,最小成本拿到 Metric 和 Log |
| 有监控无 Trace | 接 OTel + Jaeger/Tempo,把 traceId 注入日志 MDC |
| 三支柱都有但不互通 | 配置 Grafana Data Link,搭通三座桥 |
| 三支柱已互通 | 上 tail-based sampling 优化成本 + 故障排查书签化 |
| 成熟阶段 | 自定义业务指标 + SLO 告警 + 自动根因分析 |
互动话题:你们团队的故障排查平均要多久?有没有被"日志分散在五六个系统"折磨过?接了可观测性之后印象最深的"3 分钟定位"是哪个故障?评论区聊聊你们的实践和踩坑经验。
参考资料
- OpenTelemetry 官方文档
- Jaeger 官方文档
- Grafana Tempo(Trace 存储)
- Grafana Loki 官方文档
- Google SRE Book:四个黄金信号
- W3C Trace Context 标准
- OpenTelemetry Tail-Based Sampling
- Micrometer 指标打点文档
标题:生产环境可观测性全栈复盘:一个订单从创建到支付的全链路追踪实战
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/02/1787991373285.html
公众号:服务端技术精选
- 引言
- 一、可观测性三支柱:不是三个工具,是三个视角
- 1.1 三支柱各自能做什么、不能做什么
- 1.2 技术栈选型
- 二、业务场景:一条订单链路的完整拓扑
- 2.1 链路结构
- 2.2 OpenTelemetry 接入(以订单服务为例)
- 三、Trace 视角:一次请求的完整调用链
- 3.1 Jaeger 里的调用链长什么样
- 3.2 从 Trace 到根因:一次真实超时定位
- 3.3 Trace 的搜索与过滤
- 四、Metric 视角:QPS / 延迟 / 错误率仪表盘
- 4.1 四个黄金信号
- 4.2 订单链路的 Grafana 仪表盘
- 4.3 指标→Trace 的跳转
- 4.4 自定义业务指标
- 五、Log 视角:Loki 按 traceId 聚合全链路日志
- 5.1 为什么 Log 要和 Trace 关联
- 5.2 Loki LogQL 查询
- 5.3 Loki 配置:结构化日志 + 标签索引
- 5.4 Grafana 里 Log → Trace 的跳转
- 六、三视角联动:一键定位故障
- 6.1 场景一:支付超时
- 6.2 场景二:库存扣减失败
- 6.3 场景三:通知丢失
- 七、可观测性体系全景图
- 八、常见问题
- 8.1 OpenTelemetry 的性能开销大吗?
- 8.2 traceId 怎么自动传到日志里的?
- 8.3 Kafka 异步消息的 Trace 链路怎么不断?
- 8.4 Loki 和 ELK 怎么选?
- 8.5 没有接入 OpenTelemetry,怎么快速给日志加 traceId?
- 8.6 采样率太低,排查时找不到 Trace 怎么办?
- 九、总结
- 三支柱速查卡
- 一键定位三步法
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论