那天凌晨两点,支付服务又开始报超时了。我打开 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 拽进日志:
funcLoggerWithTrace(ctx context.Context,logger*slog.Logger)*slog.Logger{span:=trace.SpanFromContext(ctx)sc:=span.SpanContext()if!sc.IsValid(){returnlogger}returnlogger.With("trace_id",sc.TraceID().String(),"span_id",sc.SpanID().String(),)}一旦你稳定地这么做,调试就从“搜报错信息”变成了“跟着请求走”。后者要舒服太多了。
第三步:追踪要能解释“时间去哪了”,不只是“有没有”
很多团队说自己“有追踪”,其实就是一个请求一个根 span,下面啥也没有。这不够。
对于后端系统,最有价值的 span 通常是:
- 入站 HTTP 请求
- 数据库查询
- 出站 HTTP/RPC 调用
- 消息队列发布/消费
- 耗时的内部计算
比如追踪一个支付调用:
funccallPaymentService(ctx context.Context,client*http.Client,urlstring)(*http.Response,error){ctx,span:=tracer.Start(ctx,"payment_client.charge")deferspan.End()req,_:=http.NewRequestWithContext(ctx,http.MethodPost,url,nil)span.SetAttributes(attribute.String("http.url",url))resp,err:=client.Do(req)iferr!=nil{span.RecordError(err)returnnil,err}span.SetAttributes(attribute.Int("http.status_code",resp.StatusCode))returnresp,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、追踪能展示依赖成本、指标能反映真实服务行为——可观测性就不再是摆设了。它开始真正帮上忙。