Go service如何搭建自己的可观测性?
那天凌晨两点,支付服务又开始报超时了。我打开 Kibana,熟练地输入 "error",回车。三千条结果。再输入 "timeout",一千二。然后我开始了那场熟悉的“猜谜游戏”:翻日志、对时间戳、开另一个窗口看监控、切回来继续翻、怀疑是数据库、怀疑是网络、怀疑是缓存、怀疑人生。
三个小时后,我终于找到了根因——一个上游依赖的慢查询。但其实前半个小时,所有的信息都已经躺在日志里了。我只是没法把碎片拼起来。
那一刻我不得不面对一个尴尬的事实:我们的服务有日志、有监控、甚至还有追踪,但遇到问题时,这些东西并没有让我更快地找到答案。
我们把“可观测性”当成了一堆工具的集合,而不是一种回答问题的能力。
第一步:别打“叙事日志”了,打“结构化日志”
把 Go 的日志从自由文本换成结构化日志,是性价比最高的升级之一。
像这样的日志:
log.Printf("failed to fetch order: %v", err)
人看着还行,但排查问题的时候基本没用。换成这样就好多了:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request completed",
"service", "order-api",
"route", "/v1/orders/{id}",
"status_code", 200,
"duration_ms", 34,
"request_id", "req_123",
)
这一个改动会改变后续所有事情:你现在可以按 request_id 过滤、按 route 分组、按 status_code 搜索,跨服务用统一的属性把日志串起来。
我的原则很简单:每行生产日志,首先得能让机器查,其次才是让人读。
第二步:请求关联——日志孤岛就是事故现场的噩梦
日志在事故期间感觉没用,最大的原因是它们彼此孤立。你有了完美的结构化日志,但如果不能把它们和请求、追踪、上游调用关联起来,依然是在浪费时间。
所以我现在会标准化一组关联字段:
trace_idspan_idrequest_idserviceroute- 需要的话再加
user_id
在 Go 里,就是在请求边界把 trace context 拽进日志:
func LoggerWithTrace(ctx context.Context, logger *slog.Logger) *slog.Logger {
span := trace.SpanFromContext(ctx)
sc := span.SpanContext()
if !sc.IsValid() {
return logger
}
return logger.With(
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
)
}
一旦你稳定地这么做,调试就从“搜报错信息”变成了“跟着请求走”。后者要舒服太多了。
第三步:追踪要能解释“时间去哪了”,不只是“有没有”
很多团队说自己“有追踪”,其实就是一个请求一个根 span,下面啥也没有。这不够。
对于后端系统,最有价值的 span 通常是:
- 入站 HTTP 请求
- 数据库查询
- 出站 HTTP/RPC 调用
- 消息队列发布/消费
- 耗时的内部计算
比如追踪一个支付调用:
func callPaymentService(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
ctx, span := tracer.Start(ctx, "payment_client.charge")
defer span.End()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
span.SetAttributes(attribute.String("http.url", url))
resp, err := client.Do(req)
if err != nil {
span.RecordError(err)
return nil, err
}
span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
return resp, nil
}
关键不是“追踪一切”,而是“追踪能解释延迟和失败的那部分”。
如果事故期间我打开一个 trace,我希望很快看到一个明确的答案:延迟是在我们的 handler、数据库,还是支付服务? 如果 trace 回答不了这个问题,它就是装饰品。
第四步:指标要反映“服务行为”,而不是“实现细节”
这地方最容易搞臃肿。团队加了几十个 counter 和 gauge,因为“加上又不费事”,然后发现没有一个能回答有意义的运维问题。
我一般从一小套稳定的指标开始:
- 请求总数
- 请求耗时分布(histogram)
- 正在处理的请求数(in-flight)
- 错误数/错误率
- 依赖延迟和依赖错误
- 相关的时候再加队列深度或 worker 饱和度
用 Prometheus 的话大概是这样:
var (
httpRequests = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total"},
[]string{"route", "method", "status_code"},
)
httpDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"route", "method"},
)
inFlight = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "http_requests_in_flight"},
)
)
一个实用建议:保持 labels 低基数。用 /orders/{id} 这样的路由模板,别用原始用户 ID。这个习惯能让你的 Prometheus 指标保持有用,而不是“贵得离谱”。
第五步:仪表盘应该在一屏之内回答事故问题
我现在想要的 Go 服务仪表盘,其实挺小的:
- 请求速率
- 错误率
- p50 / p95 / p99 延迟
- 正在处理的请求数
- 报错最多的路由
- 报错最多的依赖
- 最近部署标记
- 从 trace 跳转到日志、从日志跳转到 trace 的链接
就这些。
如果我在事故头五分钟还需要另外十个图表才能看懂,那这个仪表盘就没帮上什么忙。
重新设计之后发生了什么变化?
最先改善的不是监控覆盖率,是调试速度。
事故变短了,因为遥测数据开始指向原因,而不是制造噪音。结构化日志让搜索变得可行,追踪让依赖延迟变得可见,指标说明了问题是本地的、上游的还是系统性的。关联字段把孤立事件串成了一个完整的请求故事。
第二个改善是文化上的。
开发者不再为了“安心”而乱加日志,开始问更好的问题:如果这个接口在 p95 时挂了,我需要知道什么?哪个 span 能解释那个延迟?哪个指标能在用户抱怨之前告警?哪些日志属性能让我们在几秒内隔离出一条坏请求?
当这些问题开始出现,可观测性就不再是一个“平台打勾项”了——它成了软件设计的一部分。
最后说几句
在 Go 里做有效的可观测性,不是收集更多信号。而是确保每个信号都有自己的职责:
- 结构化日志提供详细上下文
- 追踪解释请求流向和依赖耗时
- 指标反映服务健康状况和告警依据
Go 已经给了你很好的基础组件:slog 做结构化日志,OpenTelemetry 做追踪,Prometheus client 做指标。真正的提升来自于有目的地组合它们。
一旦日志带上关联 ID、追踪能展示依赖成本、指标能反映真实服务行为——可观测性就不再是摆设了。它开始真正帮上忙。
- 点赞
- 收藏
- 关注作者
评论(0)