可觀測性基礎 (Logs / Metrics / Traces)
分散式系統在生產環境出事時,你永遠不會剛好掛著 debugger。可觀測性(Observability)處理的核心問題,是如何在不重新部署程式碼的前提下,單靠系統對外輸出的三種訊號——Metrics、Logs 與 Traces——在幾分鐘內完成「發現異常 → 縮小爆炸範圍 → 定位單一請求根因」的閉環,同時不讓觀測基礎設施本身的帳單比業務機器還貴。
三大訊號的定位與問題邊界
每種訊號回答哪一類問題、為什麼缺一不可,以及在系統設計版圖中的座標。
在單體應用的年代,排查問題幾乎等於「SSH 進機器看 /var/log/app.log + 跑 top」。但當一個結帳請求會橫跨 API Gateway、訂單服務、風控模型、庫存資料庫與外部金流共十幾個節點時,沒有任何一台機器的本機日誌能單獨回答「為什麼這筆訂單卡了 4 秒」。
傳統監控(Monitoring)處理的是已知的未知(Known Unknowns):你事先知道 CPU 會滿、硬碟會爆、5xx 錯誤率會升高,所以預先寫好告警規則;而可觀測性(Observability)處理的是未知的未知(Unknown Unknowns):只有當「iOS 18.1 客戶端 + 東京區域 + 特定促銷券組合」才會觸發的死鎖,你不可能事先為這個組合寫一條專屬告警,必須靠訊號的任意維度切片與關聯分析把問題挖出來。
三大訊號的資料模型與成本光譜
Logs、Metrics、Traces 常被稱為可觀測性的三大支柱(Three Pillars),但工程師在架構設計上真正該記住的不是名詞,而是它們用什麼儲存成本,換取回答哪一類問題的能力:
| 訊號類型 | 回答的核心問題 | 底層資料模型 | 成本決定因子 | AWS / GCP 託管代表與開源對照 |
|---|---|---|---|---|
| Metrics(指標) | 「系統現在有沒有壞?哪個面相壞了?」(What & When) | 時間戳 + 數值 + 低基數標籤集(Time Series) | 活躍時間序列數(Active Series),與請求量 QPS 脫鉤 | AWS CloudWatch Metrics / AMP GCP Cloud Monitoring 開源:Prometheus / VictoriaMetrics |
| Traces(追蹤) | 「時間花在呼叫鏈的哪一跳?誰拖慢了誰?」(Where) | 以 trace_id 串起的有向無環樹(DAG of Spans) |
請求數 × Span 數 × 採樣率 | AWS X-Ray GCP Cloud Trace 開源:Jaeger / Grafana Tempo |
| Logs(日誌) | 「那一刻在該節點內部的確切上下文與錯誤原因是什麼?」(Why) | 時間戳 + 結構化 JSON 鍵值對(無固定維度上限) | 資料寫入總位元組(GB Ingested)與索引欄位數 | AWS CloudWatch Logs / OpenSearch GCP Cloud Logging 開源:ELK / ClickHouse / Grafana Loki |
三大訊號的工程折衷:Metrics 將單筆請求壓縮成統計數字以換取極低查詢延遲與固定成本;Logs 保留全部細節但成本隨流量線性暴增;Traces 則專門紀錄跨服務的因果關係。
若只留 Logs:你要算「過去 1 小時全站 P99 延遲」就必須即時掃描數百 GB 的文字,既慢又貴,不適合當秒級告警源。若只留 Metrics:你知道 http_requests_total{status="500"} 飆高了,但因為 Metrics 不能放 user_id 或堆疊追蹤(Stack Trace),你永遠查不出是哪一筆請求、哪一行程式碼拋出例外。三者分工的本質,就是在「聚合統計速度」與「單筆事件上下文」之間做分層儲存。
Metrics 資料模型與百分位數學陷阱
Counter、Gauge、Histogram 與 Summary 的本質差異,以及為什麼對 P99 取平均會在面試直接出局。
時序資料庫(TSDB,如 Prometheus、CloudWatch Metrics 底層儲存)中的每一條時間序列(Time Series),都是由指標名稱(Metric Name)+ 一組排序後的 key="value" 標籤(Labels / Dimensions)唯一決定的。序列裡面只存放按時間排序的 (timestamp, float64) 樣本點。
四種核心指標型別
- Counter(單調遞增計數器):只增不減,唯一歸零的時機是行程重啟(Process Restart)。例如
http_requests_total、payment_errors_total。查詢時絕不直接看絕對值,而是透過rate(metric[5m])計算每秒增長斜率(QPS),TSDB 會自動偵測並補償重啟造成的數值重置(Counter Reset)。 - Gauge(瞬時水位計):可增可減的當下快照。例如
memory_usage_bytes、goroutines_active、佇列當前堆積深度。適合取跨時間的avg_over_time或max_over_time,但對 Gauge 算rate()毫無物理意義。 - Histogram(直方圖):在採集端預先切好固定延遲桶位(Buckets,如
le="0.05"、le="0.1"、le="0.5"、le="+Inf"),每個桶位本質上都是一個獨立的 Counter,統計「落入該上界以下的累積請求數」。此外還會附帶_sum(總耗時)與_count(總請求數)兩條序列。 - Summary(客戶端摘要):由應用程式在本地滑動視窗內直接算好
quantile="0.99"後上報一個浮點數。雖然省去了桶位序列,但無法跨實例聚合,在分散式多副本環境中幾乎已被 Histogram 取代。
面試必考陷阱:為什麼百分位數(Percentiles)絕對不能直接平均或相加?
假設訂單服務有兩台實例 Pod A 與 Pod B。在過去 1 分鐘內:
- Pod A(正常節點,流量佔 99%):處理了 9900 筆請求,每筆都在
10 ms內完成,其本機算出的 P99_A =10 ms。 - Pod B(發生資料庫連線池飢餓,流量佔 1%):只處理了 100 筆請求,但全部卡死到
2000 ms才回應,其本機算出的 P99_B =2000 ms。
如果每個 Pod 用 Summary 型別各自上報自己的 P99,而你在儀表板上寫出「把所有 Pod 的 P99 取平均」:
但實際上,全站這 10000 筆請求裡面,有 9900 筆(剛好 99%)是 10 ms,剩下 100 筆(剛好 1%)是 2000 ms。全站真正的 P99 其實落在第 9900 筆與第 9901 筆的交界處(即 10 ms 附近,而 P99.1 才會跳到 2000 ms);反過來,若 Pod B 處理了 200 筆慢請求(佔全站 2%),全站真實 P99 應該是 2000 ms,取平均算出來的 1005 ms 則會嚴重低估災情!
百分位數是不可加(Non-additive)的統計量,但「落在 100ms 以下的請求筆數」是可加(Additive)的計數!因此必須使用 Histogram(或在 AWS CloudWatch 使用底層同樣儲存桶位結構的 p99 統計、GCP Cloud Monitoring 的 Distribution 型別),先把所有 Pod 在同一個 le 桶位的計數器加總,最後才在完整的全站累積分佈上做線性內插:
le 桶位跨實例加總(sum by (le)),再由累積分佈反推全站 P95/P99。
高基數爆炸(High Cardinality Explosion)
為什麼在 Metric Label 塞進 user_id 或 trace_id,會瞬間拖垮 Prometheus 記憶體並讓 CloudWatch 帳單破表。
TSDB 之所以能毫秒級回答「過去 6 小時每秒請求率」,是因為它為每一組唯一的標籤組合在記憶體中維護一個獨立的 Inverted Index 條目與壓縮 Chunk(例如 Gorilla XOR 壓縮需要同一條時間序列連續寫入許多點,壓縮率才會高)。
當你在程式碼裡寫下一行看似無害的指標打點:
http_request_duration_seconds_bucket{service="checkout", method="POST", status="200", user_id="u_948102"}
總活躍時間序列數(Active Time Series Cardinality)並不是各標籤可能取值數的「相加」,而是笛卡兒積(Cartesian Product)相乘,再乘上實例數與直方圖桶位數:
只要其中一個標籤是無界或高基數欄位(例如 user_id、order_id、ip_address、request_id、或是未歸一化的完整 URL Path /orders/849201),乘積就會從幾千條瞬間暴增到數千萬條。更糟的是,每個 user_id 只出現一兩次就不再產生新樣本,形成大量「只有 1 個數據點的短命序列(Churn)」——TSDB 既無法有效壓縮,索引表還會常駐在記憶體中,最終導致 Prometheus OOM 崩潰,或在按 Custom Metric 條數計費的 AWS CloudWatch(每條每月約 $0.30 USD)產出數十萬美元的天價帳單。
key="value" 標籤或數值)
語法解析正常
key="value"拆解器說明:一行帶有 _bucket 後綴的 Prometheus 指標,在底層會先乘上直方圖桶位數(10 個桶位加上 _sum 與 _count 共 12 條序列)與 Pod 實例數,再依序乘上每個標籤的基數。試著在上方點擊關閉 user_id 標籤,觀察活躍時間序列數如何從上億條瞬間降回數萬條的安全水位。
service、status、region、歸一化後的 route);凡是跟隨用戶或單筆請求成長的高基數 ID(user_id、trace_id)一律放進 Logs 與 Traces。
Push vs Pull 收集模型與日誌管線設計
Prometheus 與 CloudWatch 的推拉取捨,以及高吞吐日誌管線如何做緩衝、削峰與冷熱分層。
Metrics 收集的兩大流派:Pull(拉取)vs Push(推送)
系統設計面試常問:「監控系統應該讓 Server 主動去拉(Pull),還是讓應用程式主動往外推(Push)?」這不是宗教戰爭,而是針對不同執行環境的工程取捨:
- 運作方式:應用程式只暴露
GET /metricsHTTP 端點;Collector 透過服務發現(如 Kubernetes SD)定期主動抓取。 - 核心優勢 1(內建健康檢查):抓不到就代表目標掛了(
up == 0),不需額外心跳機制。 - 核心優勢 2(防壓垮保護):收集端自己決定抓取頻率與並行度,不會因為上游流量暴增而被萬台機器同時推送打爆。
- 天然短板:生命週期短於抓取間隔(如 15 秒)的無伺服器函數(AWS Lambda)或批次 CronJob,還沒被抓就已經銷毀了;跨 VPC / NAT 防火牆時也難以反向連入。
- 運作方式:應用程式內的 SDK 或本機 Sidecar / Daemon Agent(如 CloudWatch Agent、OTel Collector)將指標打包透過網路主動發往後端接收器。
- 核心優勢 1(短命任務友善):FaaS(Lambda / Cloud Run functions)與短批次任務可在結束前主動 flush 最後一批指標。
- 核心優勢 2(網路方向單純):只需要工作節點具備對外(Egress)連線至收集端 LB 的權限,跨多雲或邊緣節點部署容易。
- 天然短板:全站故障重啟或流量洪峰時,數千台節點同時重試推送會造成接收端雪崩(需靠接收端限流與客戶端退避保護);且「沒收到數據」無法直接區分是節點閒置、網路斷線還是進程崩潰。
現代架構通常採混合模式:Kubernetes 內部常駐服務由 DaemonSet / StatefulSet 的 OTel Collector 就近 Pull,再由 Collector 統一批次透過 OTLP Push 到遠端託管 TSDB(如 Amazon Managed Service for Prometheus 或 GCP Cloud Monitoring)。
高吞吐日誌收集管線(Log Ingestion Pipeline)
與每隔 15 秒才產生一個數字的 Metrics 不同,每次請求都可能寫出數行結構化 JSON 日誌。若業務服務在處理請求的關鍵路徑上,直接同步透過網路呼叫 Elasticsearch 或 CloudWatch Logs API,一旦日誌後端發生 GC 停頓或網路抖動,就會直接阻塞甚至拖垮業務執行緒。
因此,生產級日誌管線必定遵守「本地非同步落盤 + Agent 採集 + 訊息佇列削峰 + 冷熱分層儲存」的四段式架構:
零網路阻塞
Tail + 遮罩 PII + 批次
吸收故障期日誌洪峰
即時排障全文檢索
Athena / BigQuery 稽核
日誌管線的核心設計原則:業務程式永遠不直接連遠端日誌庫;當系統雪崩導致錯誤日誌暴增 50 倍時,Buffer Queue 負責擋下寫入尖峰,保護後端索引叢集不在此時跟著崩潰(佇列分區與保留機制詳見 Kafka 架構篇,倒排索引與分片寫入成本詳見搜尋與分析引擎篇)。
在 Serverless 或高併發環境中,若不想維護獨立的指標推送通道,AWS CloudWatch 提供 Embedded Metric Format(EMF):應用程式只需往 stdout 印出一行符合 EMF 規範的 JSON 日誌,CloudWatch Logs 接收時會自動從中解析並抽取出高頻聚合的 CloudWatch Metrics(只保留低基數維度),同時整行原始 JSON(含高基數的 request_id、user_id)則留在 Logs 供事後查詢。GCP Cloud Logging 的 Log-based Metrics 也扮演完全相同的角色——一次寫入,同時滿足低成本告警與高明細排障。
stdout,由節點上的輕量 Agent(Fluent Bit / OTel Collector)收集並經佇列削峰後,依查詢熱度分流至短期熱索引(OpenSearch)與長期冷物件儲存(S3 / GCS)。
OpenTelemetry 統一採集與三大訊號的關聯串接
如何用 trace_id 與 Exemplars 把獨立的三座資料孤島串成一條無縫的排障動線。
早期企業最大的痛點是「三套 Agent、三份帳單、三座孤島」:Prometheus SDK 採 Metrics、Fluentd 採 Logs、各雲端廠商專屬的 X-Ray 或 Zipkin SDK 採 Traces。不僅侵入業務程式碼,更嚴重的是當告警響起時,工程師在 Grafana 看到 P99 飆高,卻無法一鍵跳轉到引發該尖峰的那筆 Trace 與對應的 Error Log。
OpenTelemetry(OTel)的解耦設計
CNCF 的 OpenTelemetry 透過將「產生訊號的 API/SDK」與「接收儲存的後端 Vendor」徹底解耦,成為現代雲原生系統的事實標準:
- 統一語意約定(Semantic Conventions):強制規定 HTTP 狀態碼一律叫
http.response.status_code、服務名一律透過 Resource Attributeservice.name標記,確保三種訊號擁有完全一致的元資料欄位。 - OTel Collector(中樞路由器):透過
Receivers → Processors → Exporters管線,應用程式只要以標準 OTLP(OpenTelemetry Protocol,基於 gRPC/Protobuf 或 HTTP)將三種訊號發給本機 Collector,Collector 就能在記憶體中完成批次壓縮、敏感欄位脫敏(Redaction)、過濾高基數標籤,最後一分多路同時導出到 AWS CloudWatch / X-Ray、GCP Cloud Operations 或自建的 Prometheus / Tempo / Loki。
靠 Exemplars 與 trace_id 走完黃金排障閉環
既然我們在第 3 節強調「絕對不能把 trace_id 放進 Metric Label」,那要怎麼從一條聚合後的 Histogram 指標跳轉到具體的某筆慢請求 Trace?答案是 Exemplars(樣本錨點)。
當應用程式記錄一筆耗時 1.8s 的請求進 Histogram 的 le="2.0" 桶位時,OTel SDK 會在該桶位旁順手附帶一個輕量的 Exemplar 抽樣註記:# {trace_id="4bf92f3577b34da6"} 1.82 1730678400。它不會建立新的時間序列(因此零基數爆炸風險),而是在圖表上留下一顆可點擊的取樣星點:
sequenceDiagram
participant Eng as 值班工程師
participant TSDB as Metrics (Prometheus/CW)
participant Trace as Traces (Tempo/X-Ray)
participant Log as Logs (OpenSearch/Loki)
TSDB->>Eng: 告警:checkout P99 > 1.5s
Eng->>TSDB: 點擊 le="2.0" 桶位上的 Exemplar
TSDB-->>Eng: 取得抽樣 trace_id="4bf92f..."
Eng->>Trace: 以 trace_id 查詢完整呼叫樹
Trace-->>Eng: 定位 inventory-db span 耗時 1.6s (status=ERROR)
Eng->>Log: 以 trace_id + span_id 過濾結構化日誌
Log-->>Eng: 取得死鎖 SQL 與完整 Stack Trace
三大訊號的串聯關鍵:Metrics 透過 Exemplar 夾帶抽樣的 trace_id 跳入 Traces;結構化 Logs 則在每一行 JSON 自動注入當前 Context 的 trace_id 與 span_id,讓最後一步檢索只需精準比對 ID,不必大海撈針(跨進程的 W3C Trace Context 標頭傳播與頭尾採樣策略,由分散式追蹤篇專門深入;而如何用這些指標計算 Error Budget 與多視窗燃燒率告警,則由 SLI/SLO 與告警篇負責)。
trace_id);從 Traces 跳到 Logs 則靠結構化日誌自動注入的 trace_id + span_id。
面試高頻考點與生產環境常見陷阱
系統設計面試中關於可觀測性成本、可靠性與架構取捨的經典追問。
req.url(如 /api/v1/users/98172/orders/4412)當作 path 標籤打入 Prometheus,等同於把高基數 ID 塞進指標。必須改用路由樣板(Route Template,如 /api/v1/users/:uid/orders/:oid),並為 404 未知路徑統一收斂為 route="NOT_FOUND",防止惡意掃描器隨便打亂數 URL 就把監控系統打到 OOM。printf("User %s failed to pay %d", uid, amt),會迫使收集管線(Fluentd / Logstash)消耗大量 CPU 跑正規表示式(Grok parsing),且一旦開發者改動輸出字串格式,告警與儀表板立刻靜默壞死。生產環境應一律輸出單行 JSON,並統一欄位命名。面試深度追問 FAQ
Q1 面試官問:「我們的日誌與指標帳單每個月高達 10 萬美金,你會如何系統性地砍掉 70% 成本卻不犧牲排障能力?」 ▸
第一刀砍 Metrics 基數(通常佔帳單大頭):盤點 Top 10 序列數最多的指標,移除無界標籤(user_id、pod_ip、未歸一化路徑)與從未被任何 Dashboard 或 Alert 引用的殭屍指標;對不需要細分到 Pod 層級的業務指標,在 OTel Collector 層先做預聚合(Pre-aggregation)再寫入遠端 TSDB。
第二刀砍 Logs 寫入量與熱儲存天數:將高頻成功的 200 OK 存取日誌(Access Logs)改為抽樣寫入或只留 Metrics;嚴格限制生產環境預設為 INFO/WARN,透過動態配置中心在需要排障時才對特定服務或特定用戶開啟 5 分鐘的 DEBUG;並將 OpenSearch / CloudWatch 標準熱索引縮短至 3~7 天,其餘全部轉入 S3 / GCS Parquet 冷儲存。
Q2 AWS CloudWatch 與自建 Prometheus + Grafana Stack(或 AMP/AMG)在架構選型上怎麼權衡? ▸
AWS CloudWatch(以及 GCP Cloud Operations)的優勢是與雲端底座零配置整合(EC2、ALB、RDS、Lambda 原生開箱即用)、完全免運維,且支援 EMF 從日誌直接抽指標;但缺點是按 Custom Metric 條數與 API 呼叫計費,在 Kubernetes 高動態容器環境下若不嚴格控管標籤維度,成本極易失控,且跨雲查詢語法不通用。
Prometheus 生態(或託管版 AWS AMP / GCP Managed Service for Prometheus)天生為容器與微服務設計,具備強大的 PromQL 多維度聚合與豐富的開源 Exporter 生態;當時間序列達數十萬至數百萬規模時,搭配物件儲存後端(Thanos / Mimir / VictoriaMetrics)的單位序列成本遠低於傳統 Custom Metrics。實務上最常見的架構是:雲端託管資源(RDS / ALB)留在 CloudWatch,Kubernetes 內的微服務統一走 OTel + Prometheus 相容儲存,最後由同一座 Grafana 統一查詢。
Q3 當系統遭遇瞬間 20 倍流量的災難性故障,Error Logs 暴增時,如何確保可觀測性管線自己不會先崩潰? ▸
必須在管線的三個層級設計背壓(Backpressure)與優雅降級:
1. 應用端絕不阻塞:寫往本機環形緩衝區或 stdout,若緩衝區滿則寧可丟棄舊日誌並將 dropped_logs_total 計數器 +1,也絕不阻塞業務處理執行緒。
2. 採集端動態歸併與限流:在 Node Agent(Vector / OTel Collector)啟用重複錯誤折疊(Deduplication / Throttling),例如同一秒內完全相同的 DB 連線超時 Stack Trace 只取樣發送前 10 筆並附上重複次數。
3. 佇列削峰與優先級分流:將 ERROR/FATAL 日誌與一般 INFO 日誌分流至不同 Kafka Topic,確保在檢索叢集達到寫入上限時,優先保障錯誤日誌與核心 Metrics 通道暢通。