可观测性指从系统的外部输出推断内部状态的能力。聚合监控只能回答服务整体是否正常,无法定位单个请求变慢的位置与原因;后者依赖三类信号——日志、指标、追踪,外加按需的 pprof/连续性能剖析。可观测性与业务代码同属交付物:一项功能若未声明指标、未埋设 span、未输出结构化日志,不应视为完成。
Go 1.21 起,log/slog 成为日志的标准方案:生产服务必须输出结构化 JSON,printf 式自由文本应逐步淘汰;仍使用 zap、logrus、zerolog 的项目应制定迁移计划。指标的事实标准是 Prometheus client,追踪是 OpenTelemetry。
三类信号的分工
| 信号 | 回答的问题 | 工具 | 典型用途 |
|---|---|---|---|
| 日志 | 发生了什么? | log/slog | 离散事件、错误现场、审计记录 |
| 指标 | 多少?多快? | Prometheus client | 聚合度量、趋势、告警与 SLO |
| 追踪 | 时间花在了哪? | OpenTelemetry | 跨服务请求路径、延迟分解 |
埋点之前应先明确每个信号回答的问题:日志是离散事件流,回答"服务多快"需人工聚合海量记录;指标是聚合度量,缺少单请求细节。三类信号的价值通过相互引用实现:日志携带 trace_id 关联追踪,指标经 exemplar 关联 trace_id。
日志:结构化是底线
Datadog、Grafana Loki 等平台只对键值对做索引、过滤与聚合,自由文本日志离开进程后无法被有效检索:
// ❌ 自由文本:无法按 user_id 过滤
log.Printf("ERROR: failed to create user %s: %v", userID, err)
// ✅ 结构化键值对:机器可解析
slog.Error("user creation failed", "user_id", userID, "error", err)
handler 选型:生产用 slog.NewJSONHandler,开发用 slog.NewTextHandler 配 LevelDebug,再 slog.SetDefault。一个常见错误是多行堆栈:自由文本中的堆栈会被采集器按行拆散;JSON handler 转义换行、每条记录单行,不受影响。
日志级别划分为:Debug 开发调试、Info 常态运行、Warn 降级但成功、Error 操作失败。生产默认级别应设为 slog.LevelInfo:繁忙服务的 Debug 日志会使日志量增长 10~100 倍,使 CPU、磁盘与存储成本成倍增加。
热路径上不应预格式化字符串,应直接传入结构化键值:fmt.Sprintf 的拼接在该条日志是否输出尚不确定时已完成;键值对把渲染延迟到 handler,开销大的对象应实现 slog.LogValuer 惰性求值,仅在输出时计算一次。
日志应统一使用 Context 变体(如 slog.ErrorContext(ctx, "query failed", "error", err))。配合 OpenTelemetry bridge(如 otelslog),trace_id 与 span_id 自动注入每条日志。
logger 应由中间件注入,各层不应手工拼接字段:入口处用 slog.With() 派生携带请求属性的子 logger 存入 context,下游日志自动携带同一份上下文:
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := slog.With(
"request_id", r.Header.Get("X-Request-ID"),
"method", r.Method,
"path", r.URL.Path,
)
ctx := WithLogger(r.Context(), logger) // 子 logger 存入 context
next.ServeHTTP(w, r.WithContext(ctx))
})
}
错误应要么记录、要么返回,不应同时执行,否则同一条错误会沿调用链被记录多次。按错误处理规范的单次处理原则,底层包装错误向上返回,顶层记录一次:
// ❌ 记录 + 返回:上游必然再记一次
if err != nil {
slog.Error("query failed", "error", err)
return fmt.Errorf("query: %w", err)
}
// ✅ 携带上下文返回,顶层统一记录
if err != nil {
return fmt.Errorf("querying users: %w", err)
}
密码、token、邮箱、身份证号等敏感数据不得写入日志:slog.Info("user logged in", "email", user.Email, "token", tok) 即构成泄露,应改为只记录 user.ID 等标识符。系统化的脱敏方案见安全实践规范。
存量项目迁移 slog 分三步:先以 slog-zap、slog-logrus、slog-zerolog 等 bridge 将 slog 输出路由进现有 logger;再把调用点替换为 slog.Info(...);迁移完成后移除 bridge 与旧依赖。Go 1.26+ 的简单多路输出可直接用标准库 slog.NewMultiHandler。
日志管线:采样、脱敏、路由
需求超出"输出到 stdout"时,samber 系列提供了一整套实现标准 slog.Handler 接口的组合件。顺序错误会浪费计算资源:采样最先、格式化次之、路由最后。
record → [Sampling] → [Pipe: 脱敏/trace] → [Router] → [Sinks]
采样应置于最外层:格式化之后再采样,是为注定丢弃的记录先付格式化成本,管线负载越高浪费越大。
slogmulti.Router 按谓词路由,必须保留一个无条件的 catch-all handler 作为最终防线,否则不匹配任何谓词的记录会被静默丢弃且无报错,典型现象是日志量无故减半:
// 错误进 Sentry,其余进 Loki,stdout 作为最终防线
logger := slog.New(
slogmulti.Router().
Add(sentryHandler, slogmulti.LevelIs(slog.LevelError)).
Add(lokiHandler).
Add(slog.NewJSONHandler(os.Stdout, nil)). // catch-all:无谓词
Handler(),
)
采样策略只应削减噪声,Error 记录应保持全量。UniformSamplingOption 对所有级别按同一概率随机丢弃,只适用于开发环境;生产应使用 ThresholdSamplingOption(每类消息先保留 N 条再按比例采样)或 AbsoluteSamplingOption(全局速率上限),再用 FirstMatch 让 Error 绕过采样:
// 每类消息 5 秒内前 10 条全量,之后 10% 采样
sampling := slogsampling.ThresholdSamplingOption{
Tick: 5 * time.Second, Threshold: 10, Rate: 0.1,
}.NewMiddleware()
脱敏应实现为 Pipe 中间件,在记录离开进程前完成:slog-formatter 的 PIIFormatter、IPAddressFormatter 就地遮蔽敏感字段,保证下游 sink 得到干净数据:
logger := slog.New(
slogmulti.Pipe(slogformatter.NewFormatterMiddleware(
slogformatter.PIIFormatter("user"), // 遮蔽 user 相关字段
slogformatter.IPAddressFormatter("client_ip"),
)).Handler(jsonHandler),
)
HTTP 访问日志应使用对应框架的中间件:slog-gin、slog-echo、slog-fiber、slog-chi、slog-http 共享同一套 Config,2xx 记 Info、4xx 记 Warn、5xx 记 Error,并携带 trace_id,用 Filters 过滤健康检查等高频路径:
router.Use(sloggin.NewWithConfig(logger, sloggin.Config{
ClientErrorLevel: slog.LevelWarn,
ServerErrorLevel: slog.LevelError,
WithTraceID: true,
Filters: []sloggin.Filter{
sloggin.IgnorePath("/health", "/metrics"), // 过滤健康检查等高频路径
},
}))
批量后端在进程退出时必须 flush:slog-datadog、slog-loki、slog-kafka 均在进程内缓冲日志,shutdown 时遗漏 defer handler.Stop(ctx) 会丢失缓冲记录。
日志管线不应成为性能瓶颈:Fanout 的延迟是所有 handler 之和,等效后端应改用 Pool() 分摊;Pipe 链保持 2~4 层;上线前用 go test -bench 压测整条管线。
指标的类型、命名和基数
四种类型各有分工:Counter 计事件,Gauge 记当前状态,Histogram 记分布,Summary 基本不用。类型选错会使后续 PromQL 全部失效。
延迟类指标一律使用 Histogram,不使用 Summary。 Histogram 的 bucket 原始计数存于 Prometheus 服务端,histogram_quantile() 可跨实例聚合出全局 P99;Summary 的分位数在客户端预计算,十个副本的 P99 无法合并:
// ❌ Summary:分位数预计算,无法跨实例聚合
prometheus.NewSummary(prometheus.SummaryOpts{
Name: "http_request_duration_seconds",
Objectives: map[float64]float64{0.99: 0.001},
})
// ✅ Histogram:服务端聚合,支持 histogram_quantile
prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Buckets: prometheus.DefBuckets,
})
命名遵循 <namespace>_<subsystem>_<name>_<unit>,计数器加 _total 后缀;时间用 _seconds 不用 _ms,大小用 _bytes 不用 _kb,比率用 0~1 的 _ratio;httpDuration 这类 camelCase 不应通过代码评审。取值不应编码进指标名——应写 http_requests_total{method="GET"},不写 http_get_requests_total。
每个指标声明上方应用注释写明 Dashboard 与 Alert 的 PromQL,查询随指标一起评审:label 改名时旧查询会随之暴露。
// Dashboard: rate(myapp_http_requests_total[5m])
// Alert: sum(rate(myapp_http_requests_total{status=~"5.."}[5m]))
// / sum(rate(myapp_http_requests_total[5m])) > 0.01
var httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "myapp",
Subsystem: "http",
Name: "requests_total",
Help: "Total number of HTTP requests.",
},
[]string{"method", "path", "status"},
)
标签(label)基数是 Prometheus 的关键约束:user_id、request_id、session、完整 URL 绝不能作为 label。 每个取值组合生成一条独立时间序列,无界 label 会耗尽 Prometheus 内存并拖慢查询;经验阈值为单一 label 取值约 100 种,超过即应重新设计:
// ❌ 无界基数:百万用户就是百万条序列
httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, userID)
// ✅ 有界取值:路由模板、HTTP 方法、状态桶
httpRequestsTotal.WithLabelValues(r.Method, routePattern, statusBucket)
降低基数有两种惯用做法:用路由模板 /users/:id 代替真实路径 /users/alice,用状态桶 2xx/4xx/5xx 代替精确状态码。单请求粒度的信息(某个 trace、某个用户)应记入 span 属性与日志,不进指标 label。分位数查询:histogram_quantile(0.99, sum(rate(myapp_http_request_duration_seconds_bucket[5m])) by (le))。
追踪
微服务中一条请求跨过 N 个服务,日志分散在各地,"这条请求为什么慢"只有追踪能回答——trace 将各服务的处理串成带耗时的完整调用路径。
TracerProvider 应在程序启动时最先配置,resource 标明 service.name 与版本;缺少它,后续所有 span 无法导出。新项目应从初始阶段就接入 OTLP exporter、resource、batcher,再逐层补充 span。
每个有意义的操作都应创建 span:服务方法、DB 查询、外部 API 调用、消息队列收发。span 应挂载调试所需的结构化属性;出错时必须同时 RecordError 与 SetStatus,否则 trace 会显示为一切正常:
ctx, span := tracer.Start(ctx, "OrderService.Create")
defer span.End()
span.SetAttributes(attribute.String("order.payment_method", req.PaymentMethod))
order, err := s.repo.Insert(ctx, req.ToOrder())
if err != nil {
span.RecordError(err) // 记录错误事件
span.SetStatus(codes.Error, err.Error()) // 并标记 span 状态
return nil, fmt.Errorf("inserting order: %w", err)
}
ctx 是 trace 的载体,所有下游调用必须透传:db.Query 应换成 db.QueryContext,出站 HTTP 用 otelhttp.NewTransport 包装 client——context 传播中断即 trace 中断。context 的取消、超时与值传递细则见 context 使用规范。出入站传播不必手写:mux.Handle("/orders", otelhttp.NewHandler(orderHandler, "CreateOrder")) 即可完成入站 span,出站交给 otelhttp Transport。
追踪必须采样,生产以 10% 为起点:一万 QPS 乘以每请求 5 个 span 即每秒五万条 span,全量采集通常成为可观测性成本的最大项。sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)) 起步,再包一层 ParentBased() 使上下游采样决策一致、trace 不残缺。大 payload 不应写入 span 属性——记入日志,靠 trace_id 关联。
告警:只针对症状
告警的对象是用户能感知的症状,不是疑似原因。 Google SRE 的四大黄金信号——延迟、流量、错误、饱和度——即症状类告警清单:P99 超过 2 秒、错误率超过 1%、订单归零、连接池饱和超九成。CPU 100% 但服务正常属于原因层面的指标,是排障线索而非告警来源,放入 dashboard 供诊断即可。
所有告警必须带 for: 时长,阈值查询用 rate() 不用 irate()。 缺少 for: 时,条件在单个评估周期满足即触发,单次异常 scrape 造成的波动也会引发误报;irate() 仅基于最近两个采样点计算,单次采样点波动即触发告警。critical 持续 25 分钟,warning 持续 1030 分钟,非二元告警禁止 for: 0m:
# ❌ 单点敏感:单次采样点波动即触发告警
- alert: HighErrorRate
expr: irate(http_requests_total{status=~"5.."}[5m]) > 0.01
# ✅ rate 平滑 + for 确认持续性
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.01
for: 5m
业务指标往往比系统指标更早暴露事故:rate(myapp_orders_created_total[30m]) == 0 意味着业务异常,而此时 CPU 可能毫无异常。关键服务应叠加多窗口 burn-rate 告警:短窗口(5m+1h)捕捉事故快速发展并触发告警,长窗口(2h+24h)确认慢性消耗并转 ticket;两个窗口同时超阈值才触发,可过滤瞬时波动。
Go 运行时指标由 Prometheus Go client 自动暴露:goroutine 数、GC 停顿、堆占比。go_goroutines > 1000 持续 10 分钟基本可判定 goroutine 泄漏(配合 /debug/pprof/goroutine 查看栈),go_gc_duration_seconds{quantile="1"} > 0.1 用于监测 GC 对尾延迟的压力。awesome-prometheus-alerts 收录了约 500 条按技术分类的现成规则,按自身 SLO 调整阈值即可。
速查清单
- 信号分工:日志回答"发生了什么",指标回答"多少/多快",追踪回答"时间花在哪";三类信号齐备,功能方视为完成。
- 日志:生产 JSONHandler、开发 TextHandler;输出键值对,不拼接字符串;热路径不预格式化,大对象实现 LogValuer;级别 Debug/Info/Warn/Error,生产默认 Info;统一使用 *Context(ctx) 变体。
- 错误:要么记录要么返回,不同时执行;底层以 %w 包装,顶层记录一次。
- 敏感信息:密码、token、PII 不写入日志,slog-formatter 在出进程前脱敏。
- 管线:顺序为采样 → 脱敏/trace → 路由,不可颠倒;Router 必须保留 catch-all;Error 经 FirstMatch 绕过采样;批量后端退出时必须 flush(Stop/Close)。
- HTTP 日志:slog-gin/echo/fiber/chi/http 共用一套 Config,Filters 过滤 /health、/metrics。
- 指标:延迟一律 Histogram,不使用 Summary;命名
<ns>_<sub>_<name>_<unit>,计数器加 _total;user_id、完整 URL 不得进入 label,单一 label 取值超过约 100 应重新设计;声明上方注释写明 Dashboard/Alert 的 PromQL。 - 追踪:TracerProvider 启动时配置;DB/外部调用/MQ 均创建 span;出错时 RecordError + SetStatus;ctx 全程透传,HTTP 使用 otelhttp;生产采样 10% 起步并包 ParentBased,大 payload 不写入 span 属性。
- 告警:针对症状而非原因;使用 rate() 不用 irate(),必须带 for:(critical 2-5m,warning 10-30m);业务计数归零也属告警;goroutine 数、GC 停顿、堆占比由 Go client 自动提供;现成规则见 awesome-prometheus-alerts。