系统架构 · 可观测性

回答"系统现在怎么样": 指标发现症状, 追踪还原旅程, 日志定位细节, SLO 定义什么叫健康 — 三支柱靠同一个 trace_id 缝成一条证据链

可观测性三支柱 × 一次请求的 Span 旅程 × SLO 错误预算燃尽对比 指标发现症状 → 追踪定位段落 → 日志还原细节 → SLO 定义健康 一次下单请求的分布式追踪 · 同一 trace 串起 4 个 span W3C traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 0 200 400 600 800 1000 ms gateway GET /checkout · 1100ms · root span orders-service POST /orders · 1040ms payments-service POST /pay · 900ms (P99 350ms) · 重试 3 次 · error=true mysql(balances) UPDATE balance · 640ms · 热点行锁等待 Error 1205 (HY000): Lock wait timeout exceeded; try restarting transaction 读法: 同一 trace_id 的 span 按父子嵌套 · 子段完全落在父段时间窗内 · rose = 慢段/错误段, 沿树往下钻就是根因 哪段慢? 哪一行? 三支柱接力 · 告警发现 → 追踪定位 → 日志取证 (靠 trace_id 缝合) ① Metrics · 发现症状 聚合视角最便宜, 可长期保存 P99 延迟告警在这里先响 ② Tracing · 定位段落 一次请求的完整旅程 span 树钻取: 慢在 payments ③ Logging · 还原细节 SQL 文本 / 参数 / 堆栈 trace_id 一跳直达日志行 接力线索: 告警里带 trace_id → 一键跳 Tempo; span 里带 error → 一键跳当日日志 SLO 当裁判: 预算烧太快 → 收紧发布节奏与告警级别 ✗ 事故月 · 错误预算 3 天烧完 SLO 99.9% / 28d → 全月预算 40.3 分钟; 连环故障烧光 → 冻结发布 100% 0 ✗ 预算耗尽 → 冻结新发布, 只许修不许加 day 0 day 3 day 28 ALERTS{alertname="SLOBudgetFastBurn", severity="page"} = firing burn_rate 14.4× — 每小时烧 1.44% 预算, 3 天烧完全月 40.3 分钟 ✓ 健康月 · 预算匀速消耗, 月底还剩 62% 错误预算 = 0.1% × 窗口 · 拿预算换发布速度, 见底才踩刹车 100% 0 剩 62% → 放心发布 day 0 day 28 sli: availability = 1 - (5xx + timeout) / total_requests slo: 99.9% / 28d → 预算 40.3min · 预算见底 → 只修不加新功能 cyan 入口/gateway · emerald 计算/健康 · violet 存储/日志 · amber 边界 · rose 事故/慢段 · orange 告警消息 · 箭头 = 排障接力

机制视角 — 三支柱分工

  • • Metrics: 聚合数字, 便宜可长期存, 负责报警"有事"
  • • Tracing: 一次请求的 span 树, 负责定位"慢在哪段"
  • • Logging: 离散事件全文, 最贵, 负责还原"当时细节"
  • • 缝合线是 trace_id / request_id: 告警 → 链路 → 日志一跳直达

行为视角 — 传播与失真

  • • 上下文传播是命门: W3C traceparent 断一跳, 链路就断成孤岛
  • • 采样决定"出事时有没有证据": 头部采样会恰好丢掉故障链路
  • • 高基数 label(user_id) 打进指标 = Prometheus 慢性死亡
  • • histogram 桶设计决定分位数: 桶粗了 P99 就是假的

生产价值 — 从吵到准

  • • SLO 错误预算把"要不要报警/发版"变成数字决策, 不再拍脑袋
  • • RED 看服务, USE 看资源, 黄金四信号兜底 — 大盘 30 秒看懂
  • • 慢接口定位从"SSH 十台机器 grep 4 小时"到"trace 3 分钟"
  • • 告警分级 + runbook + 静默: 半夜电话只为中心事故而响

💡 一句话理解

把可观测性想成医院的诊断体系: 指标(Metrics)是体温计和心电图, 便宜、每秒都在量, 负责喊"病人不对劲"; 追踪(Tracing)是全身增强 CT, 把一次"血液循环"(请求)从头到尾拍下来, 看清堵在哪一段血管; 日志(Logging)是病历本, 记下当时的每一句话, 还原全部细节。

三支柱靠同一个 trace_id 缝在一起: 告警发现症状 → 追踪定位段落 → 日志取证细节, 把"SSH 十台机器 grep 四小时"变成"三分钟一条链路"。而 SLO 是"什么叫健康"的定义: 错误预算烧得快, 就收紧发布、提高告警级别。它解决的是"吵闹但无用的监控"; 它警惕的新问题是——采样会把恰好出事的那条链路丢掉, 高基数标签会把监控系统本身压垮。

🧠 必知必会 必考 & 必会

Logging 日志
离散事件的全文记录, 排障时信息量最大、存储也最贵。底线是结构化(单行 JSON)+ 统一字段 + 带上 trace_id, 否则只是"很多文本"。
{"ts":"2026-09-26T03:12:44.312Z","level":"error","svc":"payments",
 "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","msg":"sql timeout","elapsed_ms":640}
# 关键: 单行 JSON + trace_id — 才能从日志一跳反查回追踪链
Metrics 指标
聚合的数字时间序列, 三种基本类型: Counter(只涨)、Gauge(瞬时值)、Histogram(分布)。最便宜、最适合报警, 但看不到个体请求。
rate(http_requests_total{job="orders"}[5m])
# → 342.7 (请求/秒)
# 关键: Counter 只会涨, 算速率必须用 rate() 包一层
Histogram 直方图
把观测值落进一排桶(bucket)记次数, 客户端端就能近似算出 P99——不用存每个样本。桶的疏密直接决定分位数的精度。
histogram_quantile(0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# → 0.987 (单位: 秒 — bucket 定义的单位)
# 关键: 必须 sum by (le) 聚合所有实例, 否则分位数失真
Trace/Span 追踪
一次请求 = 一棵 span 树: 每个 span 有开始时间、耗时、状态; 子 span 嵌在父 span 的时间窗内。慢在哪一段, 树上一目了然。
ctx, span := tracer.Start(ctx, "POST /pay")
span.SetAttributes(attribute.Int("retry.count", 3))
defer span.End()
// 关键: ctx 一路透传 — 子 span 自动挂在父 span 之下
OpenTelemetry
CNCF 的可观测性统一标准: SDK 产生 span, OTLP 协议上报, 跨服务靠 W3C traceparent 头传播——换语言、换厂商都认这一张票。
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
#            版本-trace_id(32hex)-parent_span_id(16hex)-标志
# 关键: W3C 标准头 — 跨语言/跨公司都能续上同一条链
Correlation ID
请求关联 ID: 入口生成(或沿用上游), 日志、响应头、下游调用全程携带。用户报障只要贴一个 ID, 就能捞齐全部相关日志。
rid := r.Header.Get("X-Request-ID")
if rid == "" { rid = uuid.NewString() }
w.Header().Set("X-Request-ID", rid)
// 关键: 出口回写响应头 — 用户报障贴 ID 即可全链路捞日志
黄金四信号
Google SRE 的体检单: 延迟 / 流量 / 错误 / 饱和度。任何服务Healthy与否, 四个数字先说话; 延迟必须同时看均值和尾部分位。
Latency    — P99 慢请求        // → 890ms (阈值 300ms)
Traffic    — QPS / 连接数      // → 3.2k req/s
Errors     — 错误率            // → 1.7%
Saturation — 队列 / 连接池水位 // → 连接池 92%
// 关键: 延迟必须看分位数, 平均值会吃掉尾部
RED / USE 方法
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/Error Budget
SLI 是测量指标, SLO 是内部目标, SLA 是对外合同。错误预算 = 允许失败的额度: 没烧完就大胆发版, 烧完就冻结——把"稳不稳"变成数字决策。
slo: 可用性 99.9% / 28 天滚动窗口
预算 = 40320 分钟 × 0.001 = 40.3 分钟   # → 全月只允许坏 40 分钟
# 关键: SLA(合同) > SLO(内部) > SLI(测量) — 别倒着用
Alerting 告警
好的告警三要素: 阈值 + 持续时长(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 — 没有处置手册的告警不许上线
Sampling 采样
全量 trace 存不起, 于是采样: 头部采样在入口掷骰子, 简单但会整条丢链; 尾部采样等链路结束再决定, 能保证"错误的、慢的"100% 留下。
头部采样: 入口掷骰子 — 10% 通过 → 90% 请求根本没有链路
尾部采样: 全量采集, 结束后按规则留 — 错误 100% 留, 慢 100% 留, 正常采 1%
# 关键: 故障排查要的是"出错的链路", 只有尾部采样保得住
Dashboard 大盘
大盘是"30 秒内判断有没有事"的工具, 不是电视墙装饰。服务大盘自上而下: SLO 达成率 → RED 三行 → 依赖与容量; 每个面板都要能回答一个问题。
topk(5, sum by (service) (rate(errors_total[5m])))
# → errors 排名前 5 的服务 (错误率/秒)
# 关键: 大盘第一屏只回答"现在有没有事", 30 秒看懂

🏭 生产实战 real world

场景 1 · Go 订单服务接入 OTel 全链路追踪 —— tracer + W3C 传播

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 自动串成一棵树

场景 2 · 给下单接口建 RED 三件套 —— Counter + Histogram 埋点

接口没指标, 容量规划和报警全靠猜。按 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()

场景 3 · P99 告警规则 —— 有 for、有分级、有 runbook

以前"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"

场景 4 · SLO 错误预算落地 —— 多窗口燃烧率报警

只盯"可用性当前是多少"永远后知后觉。按 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 通知即可, 周会复盘

场景 5 · 用户投诉"下单慢", 30 分钟定位到一行 SQL —— trace 排查链

换作以前要 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

场景 6 · correlation id 贯穿 12 个服务 —— middleware 注入 + 响应头回传

用户报障只说"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 — 从工单一键捞全链路日志

场景 7 · 尾部采样配置 —— 错误和慢请求 100% 保留

头部采样 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 }

场景 8 · 结构化 JSON 日志规范 —— slog 单行输出

多行堆栈、随机字段的"文本日志"没法聚合。统一 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...",...}

场景 9 · 告警分级 / 静默 / 值班 —— Alertmanager 路由

所有告警挤一个群, 电话和刷屏不分轻重。按 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

场景 10 · USE 方法资源大盘 —— node_exporter 关键查询

服务 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 三问

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 排查慢请求靠 SSH 十台机器 grep — 症状: 一次慢查询要拼十台机器的时间戳, 一查 4 小时. 原因: 服务间裸调用不传 trace 上下文, 链路从第一跳就断. 正解: OTel + traceparent 全链路透传。
// 错: 服务间裸 HTTP 调用, 不带上下文 — 链路从网关就断了
http.Post(url, "application/json", body)  // → 新 trace, 与上游毫无关联
// 对: 用携带上下文的请求, otelhttp 自动注入 traceparent
req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
坑 2 · 告警群里 200 条 warning, 没人再看 — 症状: 告警被批量已读忽略, 真事故被淹没. 原因: 阈值随手定、无 for 时长、无分级, 天天狼来了. 正解: 只留"可执行"的告警, 加 for 与分级。
# 错: CPU>80% 每天报 3 次, 群里 @none, 逐渐无人理睬
- alert: CpuHigh
  expr: cpu_usage > 0.8
# 对: 阈值收紧 + 持续 30 分钟才报
  expr: cpu_usage > 0.9
  for: 30m
坑 3 · Prometheus 被 user_id label 打爆 — 症状: 接入用户维度指标后, Prometheus 内存暴涨、查询超时. 原因: user_id 当 label, 百万用户 = 百万时间序列. 正解: 指标只按业务维度(渠道/端)聚合, 个体进日志。
// 错: user_id 当 label — 序列数爆炸
prometheus.NewCounterVec(opts, []string{"user_id"})
// → OOM / "too many series"
// 对: 渠道、平台等低基数维度; user_id 留在日志里查
prometheus.NewCounterVec(opts, []string{"channel", "platform"})
坑 4 · 用户报障, 日志里捞不到那一行 — 症状: 只知道"10 点左右失败", 无法关联具体请求. 原因: 日志不带 request_id/trace_id. 正解: 日志中间件强制注入关联 ID。
// 错: 打日志不带 ID — 无法关联到具体请求
log.Printf("order failed")            // → 十台机器里捞不到那一行
// 对: 统一结构化日志, 每行自带 trace_id/request_id
slog.InfoContext(ctx, "order failed") // → {"trace_id":"4bf92f...",...}
坑 5 · 出事那几秒的 trace 恰好没被采 — 症状: 故障时刻 Tempo 里"查无此链". 原因: 头部采样按比例丢, 故障请求恰好全被丢掉. 正解: 尾部采样, 错误与慢请求 100% 保留。
# 错: 头部采样 10% — 出事时链路几乎全没采到
head_sampling: { sampling_percentage: 10 }
# 对: 尾部采样按结果留证
tail_sampling: { policies: [{type: status_code, status_code: {status_codes: [ERROR]}}] }
坑 6 · SLA 承诺 4 个 9, 出事拿不出数据 — 症状: 合同写了 99.99%, 事故后赔偿金额全凭对方计算. 原因: 对外有 SLA, 内部却没有 SLI/SLO 度量. 正解: 先立 SLI/SLO, 再谈 SLA。
# 错: 合同 99.99%(月停机 4.3 分钟), 内部没有任何度量
sla: 99.99%     # → 出了事故拿不出数据, 赔偿全凭对方算
# 对: 先立内部 SLO 并持续测量
slo: { sli: availability, target: 99.95%, window: 28d }
坑 7 · 12 个服务 12 条孤链, 串不起来 — 症状: 链路系统里全是独立 root span. 原因: 各服务都从空 context 起span, 不从请求 context 派生. 正解: 永远从 r.Context() 派生。
// 错: 每个服务都新起 root — 12 个服务 12 条"孤链"
ctx, span := tracer.Start(context.Background(), "handler")
// 对: 从请求 ctx 派生 — 子 span 自动挂到父链
ctx, span := tracer.Start(r.Context(), "handler")
坑 8 · 同一个延迟指标三种名字三种单位 — 症状: 两个大盘同一接口延迟曲线差 1000 倍. 原因: resp_time/latencyMs/duration_s 各写各的, 单位不统一. 正解: 统一命名约定 子系统_对象_单位, 时间一律秒。
# 错: 同一延迟三种写法 — 有人除 1000 有人不除
resp_time_ms / latencyMs / duration_s
# 对: 遵循 OTel 语义约定: 子系统_对象_单位
http_request_duration_seconds
坑 9 · 40 面板大屏没人用, 出事找不到入口 — 症状: 大屏只在中控室放着, 排障没人打开. 原因: 大盘做给老板看, 第一屏是营收柱状图. 正解: 每服务一张 RED 大盘, 第一屏 30 秒回答"有没有事"。
# 错: 40 个面板的大屏, 第一屏是营收 — 排障没人用
dashboard: revenue-leadership
# 对: 服务大盘: SLO 达成率 → RED 三行 → 依赖容量
dashboard: orders-red   # → 30 秒判断要不要拉人
坑 10 · 半夜告警响了, 不知道该干什么 — 症状: 值班同学盯着告警重启大法. 原因: 告警没有 runbook, 处置靠考古. 正解: 每条告警必须挂处置手册, 没有就不许上线。
# 错: 半夜 3 点响, 不知道该干啥
annotations: {}
# 对: 处置手册 + 一句话症状
annotations: { runbook: "https://wiki/ops/orders-p99", summary: "P99>350ms" }
坑 11 · Pod 重建, 排查证据全蒸发 — 症状: 事故后想查日志, Pod 已经重建了三轮. 原因: 日志只写容器本地盘(emptyDir), 生命周期与 Pod 同生共死. 正解: 应用只写 stdout, DaemonSet 采到 Loki/ELK 中心存储。
# 错: 日志只写容器 emptyDir — Pod 一重建, 证据蒸发
volumes: [{ name: logs, emptyDir: {} }]
# 对: stdout → DaemonSet 采集 → Loki 中心化存储
#     kubectl logs 只留 1 天, Loki 可查 30 天
坑 12 · 日志系统一抖, 业务接口跟着卡 — 症状: 日志平台故障时, 核心接口 P99 同步飙升. 原因: 请求路径上同步写远程日志. 正解: 异步批量 + 有界队列, 写满先丢日志不丢请求。
// 错: 同步打远程日志 — 日志端 P99 3s, 业务接口也 3s
logClient.Send(entry)
// 对: 异步 + 有界队列, 满了丢弃并计数
select { case ch <- entry: default: }  // → 队列满丢弃, 计数告警
坑 13 · P99 曲线跳变, 告警忽上忽下 — 症状: 分位数在两个值之间反复横跳, 告警一起抖. 原因: histogram 用默认桶, SLO 附近的样本全挤进同一个粗桶. 正解: 桶按 SLO 布置, 分位附近多放几档。
# 错: 默认桶(0.005~10s 稀疏) — 350ms 附近没桶, P99 算出来 500ms
histogram: { buckets: default }
# 对: SLO 350ms 附近加密桶
buckets: [.1, .2, .3, .35, .4, .5, 1]
坑 14 · 机房断网, 200 条告警齐发 — 症状: 一个根因触发几百条实例 Down, 手机被打爆. 原因: 只按实例报警, 没有根因聚合与抑制. 正解: 机房级聚合报警 + inhibit_rules 抑制子告警。
# 错: 每个实例都报 — 无法分辨根因
- alert: InstanceDown
# 对: 根因报警抑制症状报警
inhibit_rules: [{ source_matchers: [alertname=DatacenterDown],
                  target_matchers: [alertname=InstanceDown] }]
坑 15 · 跨语言调用, 链路到边界就断 — 症状: Go 调 Python 的链路在边界断成两条. 原因: 自研 RPC 未透传 W3C 头, 各语言采样/格式不一致. 正解: 全语言统一 traceparent, 网关原样转发。
# 错: Go -> Python 走自研 RPC, 未透传上下文 — 12 个服务断成 5 条链
# 对: 全语言统一 W3C traceparent, 网关/代理必须原样转发
traceparent: 00-4bf9...4736-00f067...02b7-01   # 一个都不能丢
坑 16 · error 日志只有一行, 堆栈呢 — 症状: 只有"context deadline exceeded"一行, 排查全靠猜. 原因: 只打 err.Error(), 丢了调用栈. 正解: 关键错误带完整堆栈。
// 错: 只打一行消息 — 无从定位
logger.Error("pay failed", "err", err)
// 对: 关键错误带堆栈 (pkg/errors 的 %+v 输出调用栈)
logger.Error("pay failed", "err", fmt.Sprintf("%+v", err))
坑 17 · 5 分钟抓一次, 尖刺全错过 — 症状: 复盘时"监控上风平浪静", 用户却在骂. 原因: scrape_interval 太长, 30 秒的尖刺落在两次抓取之间. 正解: 关键服务 15s 抓取 + 短窗口速率。
# 错: 抓取间隔过长 — 尖刺被平均掉
scrape_interval: 5m    # → 复盘时"没有异常", 用户却在骂
# 对: 关键服务 15s + 短窗口计算
scrape_interval: 15s
坑 18 · 告警规则上线半年没触发过 — 症状: 真出事时告警没响, 一查规则早就写错了. 原因: 规则从没被真实验证过, 语法错误静默失效. 正解: 每季度故障演练, 验证"报得出、找得到、修得好"。
# 错: 规则悄悄写错, 永不触发 — 出事才知道
expr: rate(errors_total[5m]) > 0.01 and on() up == 1
# 对: 定期演练, 用 promtool 检查规则
promtool check rules alerts.yml   # → 上线前先过语法与单元测试
坑 19 · 日志里全是身份证卡号, 合规亮红灯 — 症状: 安全审计发现日志含 PII, 全量日志需删除. 原因: 整个请求体直接打印. 正解: 白名单字段 + 脱敏 mask。
// 错: 整个请求体直接打印 — 身份证/卡号全进日志
logger.Info("req", "body", string(raw))   // → 泄露 PII, 审计亮红灯
// 对: 白名单字段 + 脱敏
logger.Info("req", "uid", uid, "phone", mask(phone)) // → 138****5678
坑 20 · 失败的 span 还是绿的 — 症状: 链路图一片绿, 但用户在报错; 尾部采样也把错误链路丢了. 原因: span 出错没记录状态, 默认 Unset 被当成功. 正解: 出错必须 RecordError + SetStatus。
// 错: 失败了 span 还是 OK — 告警和采样全失效
span.End()  // → status = Unset, 尾部采样把这条错误链路丢了
// 对: 记录错误事件 + 置错误状态
span.RecordError(err)
span.SetStatus(codes.Error, "pay timeout")