結論先講
微服務的 debug 難度是指數級成長的。 單體時代,一個請求從頭到尾在同一個 process 裡,stack trace 就是你的好朋友。拆成 5 個服務之後,一個請求跨了 5 個 container、5 份 log、5 個 error handling 機制——你需要分散式 Tracing 才能拼出完整的故事。
單體 vs 微服務:debug 體驗差多少
單體:一個 stack trace 就夠
Error: insufficient stock
at OrderService.createOrder (order.js:42)
at ProductService.checkStock (product.js:18)
at Controller.handleRequest (controller.js:7)
一目了然,從 controller 到 service 到具體的行數。
微服務:你面對的是這個
# 訂單服務的 log
[14:03:22.001] POST /orders → 200 (342ms)
# 庫存服務的 log
[14:03:22.150] POST /stock/reserve → 500 (45ms)
# 通知服務的 log
[14:03:22.350] POST /notify → timeout (3000ms)
三個服務、三台機器、三份 log。時間看起來對得上,但你怎麼確定這三筆 log 屬於同一個使用者請求?靠時間戳排序?如果同一秒有 100 個請求呢?
這正是 第 28 篇 提到的痛:從單體拆出來之後,監控和 debug 的難度暴增。
Tracing 的核心概念
Trace 和 Span
Trace(一個完整的請求路徑)
├── Span A: API Gateway (12ms)
│ ├── Span B: 訂單服務 (300ms)
│ │ ├── Span C: DB 查詢 (25ms)
│ │ └── Span D: 庫存服務呼叫 (200ms)
│ │ └── Span E: Redis 快取查詢 (3ms)
│ └── Span F: 通知服務 (50ms)
- Trace:一個使用者請求的完整旅程,用一個唯一的 Trace ID 標記
- Span:Trace 中的一個工作單元(一次 HTTP 呼叫、一次 DB 查詢、一次快取讀取)
- Parent-Child:Span 之間有父子關係,構成樹狀結構
Trace ID 怎麼傳遞
使用者 → API Gateway
(產生 Trace ID: abc-123)
Header: traceparent: 00-abc123-span01-01
API Gateway → 訂單服務
Header: traceparent: 00-abc123-span02-01
訂單服務 → 庫存服務
Header: traceparent: 00-abc123-span03-01
訂單服務 → 通知服務
Header: traceparent: 00-abc123-span04-01
關鍵是 Context Propagation:每個服務收到請求時,從 HTTP header 取出 Trace ID,繼續往下傳。W3C Trace Context 標準用的 header 是 traceparent。
OpenTelemetry:現在的標準答案
以前有 Jaeger Client、Zipkin Client、各家 APM 自己的 SDK⋯⋯現在全部統一到 OpenTelemetry(OTel)。
架構
你的服務 (OTel SDK)
↓ (OTLP 協議)
OTel Collector
↓
後端 (Jaeger / Zipkin / Grafana Tempo / Datadog)
OTel SDK 負責產生 Trace 資料,Collector 負責收集和轉發,後端負責存儲和展示。
Node.js 設定範例
// tracing.js — 在應用啟動前載入
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: 'http://otel-collector:4318/v1/traces',
}),
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
],
});
sdk.start();# 啟動時
node --require ./tracing.js app.jsAuto-instrumentation 會自動幫你追蹤 HTTP 進出、Express route、DB 查詢——不用改業務邏輯。
Go 設定範例
// main.go
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/trace"
)
func initTracer() func() {
exporter, _ := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("otel-collector:4318"),
otlptracehttp.WithInsecure(),
)
tp := trace.NewTracerProvider(trace.WithBatcher(exporter))
otel.SetTracerProvider(tp)
return func() { tp.Shutdown(ctx) }
}Docker Compose 快速搭建
# docker-compose.tracing.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- "4317:4317" # gRPC
- "4318:4318" # HTTP
volumes:
- ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4317" # 接收 OTLP
environment:
- COLLECTOR_OTLP_ENABLED=true# otel-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp/jaeger]打開 http://localhost:16686 就能看到 Jaeger UI,搜尋 Trace、看 Span 時間分佈、找出瓶頸。
實戰:找出慢在哪裡
場景:下單 API 回應時間從 200ms 飆到 2 秒
打開 Jaeger,搜尋 POST /orders,找到一筆 2 秒的 Trace:
Trace: POST /orders (2014ms)
├── API Gateway (8ms)
├── 訂單服務 (1998ms)
│ ├── 驗證使用者 (15ms)
│ ├── 查詢商品 (22ms)
│ ├── 呼叫庫存服務 (1900ms) ← 瓶頸!
│ │ └── Redis GET (2ms)
│ │ └── DB UPDATE (1850ms) ← 真正的元凶
│ └── 寫入訂單 (40ms)
└── 通知服務 (5ms, async)
一眼就看到:庫存服務的 DB UPDATE 花了 1850ms。打開那筆 Span 的 attributes,看到是 UPDATE products SET stock = stock - 1 WHERE id = 123 的 lock contention。
沒有 Tracing,你可能花半天在訂單服務上加 log、重啟、測試,最後才發現根本不是訂單服務的問題。
踩過的坑
1. 忘記傳遞 Context
// 錯誤:手動發 HTTP 請求沒帶 traceparent
const response = await fetch('http://stock-service/reserve', {
method: 'POST',
body: JSON.stringify(data),
});
// 正確:用 instrumented 的 HTTP client
// OTel 的 HttpInstrumentation 會自動注入 header
// 如果用原生 fetch,要手動注入
const { context, propagation } = require('@opentelemetry/api');
const headers = {};
propagation.inject(context.active(), headers);2. Sampling 沒設好
生產環境不可能 100% 的請求都記錄 Trace——成本太高。通常用:
- Head-based sampling:請求進來時隨機決定要不要記錄(例如 10%)
- Tail-based sampling:等請求結束後,根據結果決定(例如只記錄 error 或 > 1 秒的)
Tail-based 更精準但更複雜,需要 OTel Collector 支援。
3. 非同步呼叫的 Context 斷掉
Message Queue(Kafka、RabbitMQ)不會自動傳遞 Trace Context。你需要手動把 traceparent 塞進 message header:
// Producer
const headers = {};
propagation.inject(context.active(), headers);
await kafka.send({
topic: 'order-events',
messages: [{ value: payload, headers }],
});
// Consumer
const extractedContext = propagation.extract(ROOT_CONTEXT, message.headers);
context.with(extractedContext, () => {
// 在這個 context 下的所有操作都會連到同一個 Trace
processMessage(message);
});Jaeger vs Zipkin vs Grafana Tempo
| 維度 | Jaeger | Zipkin | Grafana Tempo |
|---|---|---|---|
| 維護者 | CNCF | 社群 | Grafana Labs |
| 存儲後端 | Cassandra / ES / Kafka | MySQL / ES / Cassandra | Object Storage (S3, GCS) |
| 成本 | 中(要維護 ES) | 中 | 低(只要 S3) |
| 與 Grafana 整合 | 可以 | 可以 | 原生 |
| 適合場景 | 通用 | 輕量入門 | 已有 Grafana 的團隊 |
如果你照 第 05 篇 已經搭好 Grafana,用 Tempo 整合最無痛。
下一篇
集中 Log 不再拼湊三份 log — Trace 告訴你「哪裡慢了」,Log 告訴你「為什麼慢」。但 5 個服務的 log 散在 5 台機器上,你要怎麼集中起來?
本系列文章
完整 68 篇目錄見 系列首頁
← 上一篇:Docker Compose 上生產的正確姿勢 → 下一篇:可觀測性(二):集中 Log 不再拼湊三份 log