生产环境可观测性全栈复盘:一个订单从创建到支付的全链路追踪实战

引言

去年双十一压测,订单链路在 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 SDKCNCF 标准,一次接入三支柱全出;厂商中立
Trace 存储Jaeger / TempoJaeger 生态成熟;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
流量QPSrate(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_bytesCPU > 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 → TraceGrafana Panel Data Link → Jaeger仪表盘 Panel → Data Links
Trace → LogJaeger Span → LokiTempo/Jaeger → Log JSON 链接
Log → TraceLoki 日志行 traceId → JaegerGrafana 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_idspan_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 分钟定位"是哪个故障?评论区聊聊你们的实践和踩坑经验。


参考资料


标题:生产环境可观测性全栈复盘:一个订单从创建到支付的全链路追踪实战
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/02/1787991373285.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消