2888 字
约 9 分钟
18
S09-A07 · Go 服务可观测性:日志、指标与追踪的成本

线上服务出问题,值班的人打开终端 kubectl logs -f,翻十分钟日志猜哪次请求出了问题——这是最常见的起点。它说明一件事:当时没有可查的东西,只有可猜的东西。可观测性不是装三个组件,而是让"当时的请求"在事后仍然可重建。

对 Go 服务而言,三件套的接入门槛低到危险:slog 几行就有结构化日志,promhttp 一个 handler 就有指标,otel 一次初始化就有 span。于是团队往往三件都上:日志量涨十倍、span 压垮后端存储、直方图分桶设错让 P99 完全失真——成本涨了,判断力没涨。这里只算这笔账。


1. 三件套的分工,以及只补一件的后果

工具 回答的问题 粒度 只补它的后果
指标 有没有问题、多严重、从何时开始 聚合,分钟级 知道坏了,不知道坏在哪
追踪 慢在哪一跳、哪个下游、哪段代码 单请求,微秒级 有慢样本,无基线,判断不了"是否普遍"
日志 为什么、上下文是什么、参数取值 单事件,全字段 数据最全,检索成本最高,事后重建极慢
// 指标发现异常 -> 追踪定位到 span -> 日志看该 span 的上下文:
// 三者的接缝就是 traceID,缺了它,三次检索对不上同一次请求
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    span := trace.SpanFromContext(ctx)
    h.log.InfoContext(ctx, "order.create.begin",
        "trace_id", span.SpanContext().TraceID().String(),
        "user_id", userIDFrom(ctx))
}

判断标准是排障时你从哪一屏开始:看板 → 指标是主干;用户投诉 → 追踪是主干;错误码 → 日志是主干。主干那件先做扎实,其余按它的需要补。


2. 日志:字段设计决定事后能不能查

字段 为什么必须有 缺失时的排障代价
trace_id 串起三件套的唯一接缝 只能按时间戳猜,跨服务对不上
user_id / 租户 复现单用户问题 无从过滤,只能全量翻
错误码(稳定枚举) 可聚合、可告警 靠匹配错误文案,改文案即失效
关键业务键(订单号等) 定位单条记录 只能反查数据库
耗时/结果 判断是慢还是错 只能看有没有异常栈
// 字段名一旦定下就是契约,改一次要同步改看板、告警、查询脚本
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == slog.TimeKey { a.Key = "ts" }   // 缩短键名也是省钱手段
        return a
    },
}))

最常见的错误是把日志当自由文本:logger.Info("create order failed: " + err.Error())。开发机上好读,生产上不可查——文案里带变量就难以按错误类型聚合。


3. 日志量的成本换算

变量 典型量级(按实际压测核对) 说明
单条结构化日志 200–400 字节 字段越多越大,trace_id 占 32 字节
每请求日志条数 3–10 条 入口、出口、关键分支、错误
峰值 QPS 由容量规划确定 乘以条数就是写入速率
保留天数 7–30 天 成本与天数线性相关
索引比例 全索引 vs 只索引关键字段 差距可达数倍
日增量 ≈ 单条字节 × 每请求条数 × 日均请求数
代入假设值(300B × 5 × 200 QPS × 86400s)≈ 26 GB/天,未压缩
压缩后约 1/5–1/10;索引字段全开再乘 1.5–3 倍

算清这个数再看"要不要打这条日志"。最有效的三个动作:降级为 Debug(Info 只留入口/出口/错误)、分层采样(错误与慢请求直通,成功请求按比例)、去掉重复字段(同一请求每条都带 user_id 不必)。


4. 指标三类与直方图分桶

类型 用途 典型误用
Counter 只增计数(请求数、错误数) 用它记"当前连接数",重启即归零
Histogram 分布(延迟、大小) 分桶设错,P99 变成桶边界
Gauge 瞬时值(goroutine 数、队列长度) 用它算速率,取值时机不稳
// 分桶必须覆盖真实分布:上限太低会把所有慢请求塞进 +Inf,P99 失去意义
var latency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
    Name:    "http_request_duration_seconds",
    Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"route", "method", "status"})

http.Handle("/metrics", promhttp.Handler())   // 只绑内网/独立端口,别挂业务路由

标签基数是隐性成本:route 用原始 path 会让序列数爆炸,status 用完整状态码可接受,user_id 做标签是事故。判据是单指标序列数千级以内,且每个标签的取值集合必须是有限枚举。


5. 采样:头部与尾部的取舍

策略 决策时机 成本 完整性
头部采样 请求入口按比例决定 低,链路完整 会丢掉所有异常样本(若比例低)
尾部采样 整条链路结束后按结果决定 高(要先收集全部) 异常/慢请求可全留
分层采样 按路由/错误码定不同比例 中 需集中配置
// 头部采样的最小实现:错误与慢请求永远留,其余按比例
sampler := sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))
// 更好的做法在 Collector 侧按 status_code 做尾部采样,应用侧固定 100%

只做头部采样的团队常遇到"监控里看不到错误 span"——不是没有错误,是错误落在被丢的那 90% 里。补救是入口 100%、出口丢弃,或至少让错误路径绕过采样。


6. context 传播的纪律

场景 正确做法 断链后果
跨 goroutine 从父 ctx context.WithValue 派生,别用 context.Background() 子任务日志无 trace_id
跨 HTTP 注入 traceparent 头,下游继续用 链路在服务边界断开
跨 MQ trace 上下文塞进消息头/属性 消息消费侧难以归因
跨定时任务 为每批生成新 trace,批次内复用 一次任务的所有日志混在一起
跨 SDK 第一时间 ctx = r.Context(),别 Background() 追踪从 handler 内部才开始
// 反例:goroutine 里用 Background,日志与追踪同时断链
go func() { process(context.Background(), job) }()
// 正例:派生并显式携带取消语义
go func(ctx context.Context) { process(ctx, job) }(ctx)

context.Background() 在业务代码里出现的每一处,都是一个可观测性缺口。用 lint 规则(contextcheck 类)把它挡在评审前,比事后查"为什么这条日志没有 trace_id"便宜得多。


7. pprof:三种 profile 与生产启用的风险

profile 回答 采集开销 生产可用性
heap 谁占了内存、分配热点 需 GC 配合,短暂停顿 可用,按需触发
cpu 谁在消耗 CPU 采样 100Hz,约 5% 开销 短时可用(30 秒)
goroutine 阻塞在哪、协程是否泄漏 极低(抓栈) 常开代价很小
block/mutex 锁竞争 需显式开启,开销可观 排障期短开
// 独立端口 + 只绑回环,不要挂在业务端口上
go func() {
    mux := http.NewServeMux()
    mux.HandleFunc("/debug/pprof/", pprof.Index)
    _ = http.ListenAndServe("127.0.0.1:6060", mux)   // 需要时走 SSH 隧道
}()

pprof 的真实风险不是开销,而是暴露面:/debug/pprof 能触发 CPU 采样、导出堆,公网可达就是一个免费的 DoS 开关。规范是独立端口 + 仅回环 + 按需隧道。


8. 告警、最小集与埋点的维护成本

判据 症状告警(优先) 原因告警(慎用)
触发条件 错误率、P99、饱和度 CPU、GC 次数、队列长度
噪音 低 高(指标动了但用户没感觉)
行动 明确(扩容/回滚/降级) 模糊("去看看")
# 症状告警 + 持续时长抑制抖动:不要用 for: 0s
- alert: OrderApiHighErrorRate
  expr: |
    sum(rate(http_requests_total{route="/api/orders",status=~"5.."}[5m]))
      / sum(rate(http_requests_total{route="/api/orders"}[5m])) > 0.02
  for: 10m

最小集与冗余集的分界:入口 RED 指标 + 依赖的可用性 + 每条错误日志带 trace_id + 一个能抓 goroutine 的 pprof 端口,这四样构成能排障的最小集。其余(每个函数的 span、每个分支的日志、每张表的查询耗时)属于冗余集,只有在具体问题反复出现后才值得加。

埋点的维护成本与数量近似线性:一处埋点要跟着改代码、看板、告警、文档。每加一个标签就欠一次复审,否则两年后没人敢删。


9. 排错:五种典型现象

现象 常见原因 方向
日志有、追踪断 context.Background() 或未注入 traceparent 查跨 goroutine 与出口注入
指标 P99 明显偏离实际 分桶上限过低,全落 +Inf 按真实分布重设 buckets
看板序列数暴涨 高基数标签(user_id、原始 path) 收敛标签取值集合
采样后错误样本缺失 纯头部采样 改尾部采样或错误路径直通
告警天天响但无影响 原因告警 + 无 for 抑制 换症状告警并加持续时长

10. 边界:这些钱不必花

场景 判断 理由
一次性脚本 / 内部工具 只用 slog 到 stderr 没有 SLO,也没有"事后重建"的需求
QPS 个位数服务 指标 + 日志足够,追踪可省 样本太少,追踪的统计意义不存在
调试期原型 不接埋点 接口还在变,埋点会随重构全部作废
瓶颈已明确在外部依赖 先治依赖 本地加埋点只会更清楚地证明"不是我的问题"
团队无人看板 先建值班流程 无人消费的观测数据是纯成本

一句话:可观测性的成本不在接入,在保留。接入是几行代码,保留是每天增长的存储、需要复审的标签、需要维护的看板。


11. 自测

  1. 只有指标没有追踪时,你能回答和不能回答的问题各是什么?
  2. 为什么日志里缺 trace_id 会让另外两件套的价值同时打折?
  3. 日增量估算需要哪四个输入?哪一个最容易被低估?
  4. 直方图分桶设错时,P99 会怎样失真?
  5. 头部采样为什么容易丢掉全部错误样本?
  6. 生产环境开 pprof,真正的风险是开销还是暴露面?
S09-A07 · Go 服务可观测性:日志、指标与追踪的成本
https://www.clxhxhhr.top/posts/6182/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。