回答"系统现在怎么样": 指标发现症状, 追踪还原旅程, 日志定位细节, SLO 定义什么叫健康 — 三支柱靠同一个 trace_id 缝成一条证据链
把可观测性想成医院的诊断体系: 指标(Metrics)是体温计和心电图, 便宜、每秒都在量, 负责喊"病人不对劲"; 追踪(Tracing)是全身增强 CT, 把一次"血液循环"(请求)从头到尾拍下来, 看清堵在哪一段血管; 日志(Logging)是病历本, 记下当时的每一句话, 还原全部细节。
三支柱靠同一个 trace_id 缝在一起: 告警发现症状 → 追踪定位段落 → 日志取证细节, 把"SSH 十台机器 grep 四小时"变成"三分钟一条链路"。而 SLO 是"什么叫健康"的定义: 错误预算烧得快, 就收紧发布、提高告警级别。它解决的是"吵闹但无用的监控"; 它警惕的新问题是——采样会把恰好出事的那条链路丢掉, 高基数标签会把监控系统本身压垮。
{"ts":"2026-09-26T03:12:44.312Z","level":"error","svc":"payments",
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","msg":"sql timeout","elapsed_ms":640}
# 关键: 单行 JSON + trace_id — 才能从日志一跳反查回追踪链Counter(只涨)、Gauge(瞬时值)、Histogram(分布)。最便宜、最适合报警, 但看不到个体请求。 rate(http_requests_total{job="orders"}[5m])
# → 342.7 (请求/秒)
# 关键: Counter 只会涨, 算速率必须用 rate() 包一层histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) # → 0.987 (单位: 秒 — bucket 定义的单位) # 关键: 必须 sum by (le) 聚合所有实例, 否则分位数失真
ctx, span := tracer.Start(ctx, "POST /pay") span.SetAttributes(attribute.Int("retry.count", 3)) defer span.End() // 关键: ctx 一路透传 — 子 span 自动挂在父 span 之下
OTLP 协议上报, 跨服务靠 W3C traceparent 头传播——换语言、换厂商都认这一张票。 traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 # 版本-trace_id(32hex)-parent_span_id(16hex)-标志 # 关键: W3C 标准头 — 跨语言/跨公司都能续上同一条链
rid := r.Header.Get("X-Request-ID") if rid == "" { rid = uuid.NewString() } w.Header().Set("X-Request-ID", rid) // 关键: 出口回写响应头 — 用户报障贴 ID 即可全链路捞日志
延迟 / 流量 / 错误 / 饱和度。任何服务Healthy与否, 四个数字先说话; 延迟必须同时看均值和尾部分位。 Latency — P99 慢请求 // → 890ms (阈值 300ms) Traffic — QPS / 连接数 // → 3.2k req/s Errors — 错误率 // → 1.7% Saturation — 队列 / 连接池水位 // → 连接池 92% // 关键: 延迟必须看分位数, 平均值会吃掉尾部
RED 面向服务(请求在受罪吗): Rate、Errors、Duration; USE 面向资源(机器累不累): Utilization、Saturation、Errors。两套问法对应两张大盘。 sum(rate(http_requests_total[5m])) # RED: Rate sum(rate(http_requests_total{code=~"5.."}[5m])) # RED: Errors node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes # USE: U # 关键: 服务看 RED, 资源看 USE — 别混着问
SLI 是测量指标, SLO 是内部目标, SLA 是对外合同。错误预算 = 允许失败的额度: 没烧完就大胆发版, 烧完就冻结——把"稳不稳"变成数字决策。 slo: 可用性 99.9% / 28 天滚动窗口 预算 = 40320 分钟 × 0.001 = 40.3 分钟 # → 全月只允许坏 40 分钟 # 关键: SLA(合同) > SLO(内部) > SLI(测量) — 别倒着用
for:)+ 处置手册(runbook)。分级要狠: 能打电话的只有 page 级——每条告警都必须"值得叫醒一个人"。 - alert: PaymentsP99High expr: histogram_quantile(0.99, rate(pay_latency_seconds_bucket[5m])) > 0.35 for: 5m # 连续 5 分钟才算真, 抖动不报 labels: { severity: page } annotations: runbook: "https://wiki/ops/payments-p99" # 关键: for: + runbook — 没有处置手册的告警不许上线
头部采样: 入口掷骰子 — 10% 通过 → 90% 请求根本没有链路
尾部采样: 全量采集, 结束后按规则留 — 错误 100% 留, 慢 100% 留, 正常采 1%
# 关键: 故障排查要的是"出错的链路", 只有尾部采样保得住topk(5, sum by (service) (rate(errors_total[5m]))) # → errors 排名前 5 的服务 (错误率/秒) # 关键: 大盘第一屏只回答"现在有没有事", 30 秒看懂
12 个服务各写各的埋点, 链路到网关就断。统一 OpenTelemetry SDK + W3C traceparent 传播, 一次接入全链路串起来。
tp, err := newOtlpExporter(ctx, "otel-collector:4317") // OTLP gRPC 上报 if err != nil { log.Fatal(err) } otel.SetTracerProvider(tp) prop := propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}) // W3C traceparent otel.SetTextMapPropagator(prop) handler := otelhttp.NewHandler(mux, "orders", otelhttp.WithTracerProvider(tp)) // 每个请求自动起 span http.ListenAndServe(":8080", handler) // → 网关透传 traceparent, 12 个服务的 span 自动串成一棵树
接口没指标, 容量规划和报警全靠猜。按 RED 方法补齐速率、错误、耗时三个指标, 桶按 SLO 350ms 设计。
var ordersTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "orders_requests_total", }, []string{"handler", "code"}) var ordersDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Name: "orders_request_duration_seconds", Buckets: []float64{.05, .1, .2, .35, .5, 1, 2.5}, // 按 SLO 350ms 设桶 }, []string{"handler"}) prometheus.MustRegister(ordersTotal, ordersDur) // 中间件里: ordersTotal.WithLabelValues("/order", "200").Inc()
以前"CPU 高了就报", 值班同学麻木。重写告警规则: 只报影响用户的症状, 连续 5 分钟才算真, 每条必挂处置手册。
groups: - name: orders-golden rules: - alert: OrdersP99LatencyHigh expr: | histogram_quantile(0.99, sum by (le) ( rate(orders_request_duration_seconds_bucket[5m]))) > 0.35 for: 5m labels: { severity: page, team: trade } annotations: summary: "orders P99 > 350ms 持续 5 分钟" runbook: "https://wiki.ops/orders-p99"
只盯"可用性当前是多少"永远后知后觉。按 Google SRE 多窗口燃烧率方案: 烧得越快, 告警越急, 预算见底自动冻结发布。
# 快烧: 1h 窗口 burn_rate > 14.4 — 每小时烧 1.44% 预算, 约 2 天烧光全月 - alert: SLOBudgetFastBurn expr: | (1 - sum(rate(orders_requests_total{code!~"5.."}[1h])) / sum(rate(orders_requests_total[1h]))) > (14.4 * 0.001) for: 2m labels: { severity: page } # 慢烧: 6h 窗口 burn_rate > 6 — IM 通知即可, 周会复盘
换作以前要 SSH 十台机器拼时间戳。现在: Tempo 按耗时筛链路 → 打开最慢 trace → 沿 span 树钻到 mysql → 按 trace_id 捞日志取证。
# 1) Grafana Tempo TraceQL: 筛 orders 服务超过 900ms 的链路 {resource.service.name="orders" && duration>900ms} # 2) 打开最慢 trace: payments 段 900ms, mysql span 带错误 # span event: Error 1205 (HY000): Lock wait timeout exceeded # 3) Loki 按 trace_id 取证: {service="payments"} |= "4bf92f3577b34da6a3ce929d0e0e4736" # → 12 行日志锁定热点行 # 4) 修复: 热点行 UPDATE 改走队列串行化, P99 900ms → 210ms
用户报障只说"10 点下单失败"。给所有服务统一 RequestID 中间件: 入口取/生成, 出口回响应头, 日志强制携带。
func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rid := r.Header.Get("X-Request-ID") if rid == "" { rid = uuid.NewString() } ctx := context.WithValue(r.Context(), ridKey{}, rid) w.Header().Set("X-Request-ID", rid) // 回传给调用方, 报障有抓手 next.ServeHTTP(w, r.WithContext(ctx)) }) } // 日志字段统一带 request_id — 从工单一键捞全链路日志
头部采样 10% 让故障时刻"查无此链"。改用 OpenTelemetry Collector 尾部采样: 出错全留、慢请求全留、正常请求采 1%, 存储降一个数量级。
processors: tail_sampling: decision_wait: 30s policies: - name: errors-always type: status_code status_code: { status_codes: [ERROR] } # 出错链路全留 - name: slow-always type: latency latency: { threshold_ms: 900 } # 超 900ms 全留 - name: normal-1pct type: probabilistic probabilistic: { sampling_percentage: 1 }
多行堆栈、随机字段的"文本日志"没法聚合。统一 slog JSONHandler: 固定字段 svc/ver, 错误必带 trace_id 与耗时。
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})).With("svc", "orders", "ver", version)
logger.ErrorContext(ctx, "sql timeout",
"trace_id", trace.SpanContextFromContext(ctx).TraceID().String(),
"elapsed_ms", 640, "table", "balance")
// → 单行 JSON: {"level":"ERROR","svc":"orders","trace_id":"4bf92f...",...}
所有告警挤一个群, 电话和刷屏不分轻重。按 severity 路由: page 走电话值班, ticket 走工单; 大促窗口批量静默非核心告警。
route: receiver: im-low routes: - matchers: [severity="page"] # P0: 电话+短信, 24×7 值班 receiver: phone-oncall - matchers: [severity="ticket"] # P2: 工单, 工作时间处理 receiver: ticket-queue receivers: - name: phone-oncall webhook_configs: [{ url: "http://alert-bridge/phone" }] # 大促静默: amtool silence add alertname=~"CronJob.*" --duration=12h
服务 RED 大盘正常, 机器却悄悄扛满。给每类资源(CPU/内存/磁盘/网络)过一遍 USE 三问: 利用率、饱和度、错误。
# 利用率 Utilization — CPU 忙不忙 1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) # 饱和度 Saturation — 排队了吗: 5 分钟负载 / 核数 node_load5 / count by (instance) (node_cpu_seconds_total{mode="idle"}) # 错误 Errors — 硬错误信号: 主缺页 / 网卡丢包 rate(node_vmstat_pgmajfault[5m]) # 关键: CPU/内存/磁盘/网络 每类资源都过一遍 USE 三问
traceparent 全链路透传。 // 错: 服务间裸 HTTP 调用, 不带上下文 — 链路从网关就断了 http.Post(url, "application/json", body) // → 新 trace, 与上游毫无关联 // 对: 用携带上下文的请求, otelhttp 自动注入 traceparent req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
for 与分级。 # 错: CPU>80% 每天报 3 次, 群里 @none, 逐渐无人理睬 - alert: CpuHigh expr: cpu_usage > 0.8 # 对: 阈值收紧 + 持续 30 分钟才报 expr: cpu_usage > 0.9 for: 30m
user_id 当 label, 百万用户 = 百万时间序列. 正解: 指标只按业务维度(渠道/端)聚合, 个体进日志。 // 错: user_id 当 label — 序列数爆炸 prometheus.NewCounterVec(opts, []string{"user_id"}) // → OOM / "too many series" // 对: 渠道、平台等低基数维度; user_id 留在日志里查 prometheus.NewCounterVec(opts, []string{"channel", "platform"})
// 错: 打日志不带 ID — 无法关联到具体请求 log.Printf("order failed") // → 十台机器里捞不到那一行 // 对: 统一结构化日志, 每行自带 trace_id/request_id slog.InfoContext(ctx, "order failed") // → {"trace_id":"4bf92f...",...}
# 错: 头部采样 10% — 出事时链路几乎全没采到 head_sampling: { sampling_percentage: 10 } # 对: 尾部采样按结果留证 tail_sampling: { policies: [{type: status_code, status_code: {status_codes: [ERROR]}}] }
# 错: 合同 99.99%(月停机 4.3 分钟), 内部没有任何度量 sla: 99.99% # → 出了事故拿不出数据, 赔偿全凭对方算 # 对: 先立内部 SLO 并持续测量 slo: { sli: availability, target: 99.95%, window: 28d }
r.Context() 派生。 // 错: 每个服务都新起 root — 12 个服务 12 条"孤链" ctx, span := tracer.Start(context.Background(), "handler") // 对: 从请求 ctx 派生 — 子 span 自动挂到父链 ctx, span := tracer.Start(r.Context(), "handler")
子系统_对象_单位, 时间一律秒。 # 错: 同一延迟三种写法 — 有人除 1000 有人不除 resp_time_ms / latencyMs / duration_s # 对: 遵循 OTel 语义约定: 子系统_对象_单位 http_request_duration_seconds
# 错: 40 个面板的大屏, 第一屏是营收 — 排障没人用 dashboard: revenue-leadership # 对: 服务大盘: SLO 达成率 → RED 三行 → 依赖容量 dashboard: orders-red # → 30 秒判断要不要拉人
# 错: 半夜 3 点响, 不知道该干啥 annotations: {} # 对: 处置手册 + 一句话症状 annotations: { runbook: "https://wiki/ops/orders-p99", summary: "P99>350ms" }
emptyDir), 生命周期与 Pod 同生共死. 正解: 应用只写 stdout, DaemonSet 采到 Loki/ELK 中心存储。 # 错: 日志只写容器 emptyDir — Pod 一重建, 证据蒸发 volumes: [{ name: logs, emptyDir: {} }] # 对: stdout → DaemonSet 采集 → Loki 中心化存储 # kubectl logs 只留 1 天, Loki 可查 30 天
// 错: 同步打远程日志 — 日志端 P99 3s, 业务接口也 3s logClient.Send(entry) // 对: 异步 + 有界队列, 满了丢弃并计数 select { case ch <- entry: default: } // → 队列满丢弃, 计数告警
# 错: 默认桶(0.005~10s 稀疏) — 350ms 附近没桶, P99 算出来 500ms histogram: { buckets: default } # 对: SLO 350ms 附近加密桶 buckets: [.1, .2, .3, .35, .4, .5, 1]
inhibit_rules 抑制子告警。 # 错: 每个实例都报 — 无法分辨根因 - alert: InstanceDown # 对: 根因报警抑制症状报警 inhibit_rules: [{ source_matchers: [alertname=DatacenterDown], target_matchers: [alertname=InstanceDown] }]
traceparent, 网关原样转发。 # 错: Go -> Python 走自研 RPC, 未透传上下文 — 12 个服务断成 5 条链 # 对: 全语言统一 W3C traceparent, 网关/代理必须原样转发 traceparent: 00-4bf9...4736-00f067...02b7-01 # 一个都不能丢
// 错: 只打一行消息 — 无从定位 logger.Error("pay failed", "err", err) // 对: 关键错误带堆栈 (pkg/errors 的 %+v 输出调用栈) logger.Error("pay failed", "err", fmt.Sprintf("%+v", err))
scrape_interval 太长, 30 秒的尖刺落在两次抓取之间. 正解: 关键服务 15s 抓取 + 短窗口速率。 # 错: 抓取间隔过长 — 尖刺被平均掉 scrape_interval: 5m # → 复盘时"没有异常", 用户却在骂 # 对: 关键服务 15s + 短窗口计算 scrape_interval: 15s
# 错: 规则悄悄写错, 永不触发 — 出事才知道 expr: rate(errors_total[5m]) > 0.01 and on() up == 1 # 对: 定期演练, 用 promtool 检查规则 promtool check rules alerts.yml # → 上线前先过语法与单元测试
// 错: 整个请求体直接打印 — 身份证/卡号全进日志 logger.Info("req", "body", string(raw)) // → 泄露 PII, 审计亮红灯 // 对: 白名单字段 + 脱敏 logger.Info("req", "uid", uid, "phone", mask(phone)) // → 138****5678
Unset 被当成功. 正解: 出错必须 RecordError + SetStatus。 // 错: 失败了 span 还是 OK — 告警和采样全失效 span.End() // → status = Unset, 尾部采样把这条错误链路丢了 // 对: 记录错误事件 + 置错误状态 span.RecordError(err) span.SetStatus(codes.Error, "pay timeout")