线上服务出问题,值班的人打开终端 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. 自测
- 只有指标没有追踪时,你能回答和不能回答的问题各是什么?
- 为什么日志里缺
trace_id会让另外两件套的价值同时打折? - 日增量估算需要哪四个输入?哪一个最容易被低估?
- 直方图分桶设错时,P99 会怎样失真?
- 头部采样为什么容易丢掉全部错误样本?
- 生产环境开 pprof,真正的风险是开销还是暴露面?