当断路器跳开了、降载器拒绝了请求、限流器拦住了调用方——你如何知道这些事正在发生?更进一步,当系统一切正常时,你如何验证一切正常?这就是可观测性的领域。它不直接参与业务逻辑,但它回答运维中最基本的问题。
- “刚才发生了什么?” → 日志(Logging)
- “这个请求经过了哪些服务?每个环节花了多久?” → 链路追踪(Tracing)
- “系统整体表现如何?QPS、延迟、错误率分别是多少?” → 指标(Metrics)
三者的分工互补,各有侧重:
| 支柱 | 回答的问题 | 数据特征 | 典型消费者 |
|---|---|---|---|
| 日志 | “发生了什么?” | 离散事件,非结构化或半结构化 | 开发者、运维排查 |
| 追踪 | “请求经过了哪里?” | 跨服务关联,带层级结构 | 开发者、SRE 定位瓶颈 |
| 指标 | “系统表现如何?” | 聚合数值,可做趋势分析 | 告警、容量规划、Dashboard |
go-zero 的可观测性体系不仅仅是为这三个维度各自提供了实现,更重要的是让它们互相关联——同一条请求,在日志里能看到 trace ID 和 span ID,在 trace 里能看到每个 span 的耗时和属性,在 metrics 里能看到同一维度的 QPS 和分位数。接下来,我们从日志讲起,然后看追踪和指标是如何与日志关联的,最后介绍围绕运行时诊断的几个辅助工具。
日志系统:三层架构与结构化输出
go-zero 的日志模块位于 core/logx/,是整个可观测性体系的底座。trace 的错误通过它输出,metrics 的统计报告通过它持久化,Prometheus 的启动日志也经过它——它几乎是所有其他观测手段的"最后一步"。
但日志模块自身的结构并不简单。它采用了一个三层架构,从顶层的业务 API 到底层的文件系统,每一层都有明确的分工。
三层架构总览
1 | Tier 1: 公共 API 层 |
先从最顶层看起——如果你在业务代码中调用 logx.Info("order created"),这个调用的完整旅程会穿过这三层。
公共 API:两种使用方式
go-zero 的日志模块提供了两种使用方式:包级函数(快速使用)和 Logger 接口(需要上下文或结构化字段时)。
包级函数直接可用,不需要预先创建任何对象:
1 | // 基本用法 |
每种日志级别(Debug、Info、Error、Slow、Severe)都有五种变体:
- 基础形式:
Info(v ...any) - 格式化:
Infof(format string, v ...any) - 惰性求值:
Infofn(fn func() any)——当函数调用很昂贵时,只有在级别启用时才求值 - 结构化值:
Infov(v any)——序列化为 JSON - 结构化字段:
Infow(msg string, fields ...LogField)——带键值对的日志
Logger 接口则提供了更丰富的上下文能力:
1 | // core/logx/logger.go |
WithContext 是最关键的方法。当你把 ctx 传给 logger 后,logger 会自动从 context 中提取 trace ID 和 span ID(通过 internal/trace 包),并将它们注入到日志字段中。这意味着同一条请求链路上的所有日志都会自动带上相同的 trace ID——你不需要在每处日志调用中手动传递。
richLogger:门面层的关键职责
richLogger 是 Logger 接口的唯一实现(core/logx/richlogger.go)。它是一个值类型 builder,每次调用 With* 方法时都返回一个新实例,复制现有状态并扩展新字段——原实例保持不变。
它最核心的方法是 buildFields:
1 | // core/logx/richlogger.go |
字段的合并顺序是有意的,体现了优先级:
- Logger 自身的 fields(通过
WithFields添加) - 调用方信息(
caller字段——哪个文件的哪一行调用了日志) - 全局 fields(通过
AddGlobalFields添加——例如所有日志都需要带的服务名称) - Trace/Span ID(从 context 中自动提取)
- Context fields(通过
ContextWithFields(ctx, ...)注入的自定义字段)
这个顺序意味着:context 中的 trace 信息总是会出现在日志中,且用户通过 context 传入的字段拥有最后的发言权。正是这个机制,让日志和 trace 系统自然地融为一体——trace 中间件在创建 span 后将 trace context 注入到请求的 context.Context 中,后续所有使用了 WithContext(ctx) 的日志调用都会自动带上 trace 信息。
Writer 层:输出分流与格式化
当 richLogger 完成了字段组装后,日志内容被传递到 Writer 接口——日志的输出抽象层:
1 | // core/logx/writer.go |
Writer 接口的七个方法,对应七种不同的日志流向。concreteWriter 是标准实现——它内部持有 六个不同的 io.WriteCloser,将不同级别的日志写入不同的文件:
1 | // core/logx/writer.go |
以 newFileWriter 为例,它创建五个独立的 RotateLogger 文件:
access.log:Debug 和 Info 级别——记录一切正常的请求error.log:Error 级别——记录需要关注的错误severe.log:Severe 级别——记录需要立即响应的严重事件slow.log:Slow 级别——记录超过阈值的慢请求stat.log:Stat 级别——记录每分钟的 QPS 和延迟统计
这种按级别分流的设计有一个直接的好处:当你排查问题时,不需要在海量的 access 日志中grep 错误——直接看 error.log 即可。同样,性能分析直接看 slow.log。每个文件的关注者不同,运维策略也不同(比如 error.log 可能需要告警,access.log 只需要归档)。
stackLog 特殊一些——它被包了一层 lessWriter,限频机制在 100ms 内同一条错误栈最多输出一次,避免在故障大爆发时栈日志把磁盘打满。
输出管道:从字段到 JSON
所有级别的日志最终都汇聚到 output 函数——日志模块的"最后一道门":
1 | // core/logx/writer.go |
在编码之前,有两个值得注意的处理步骤:
内容截断。 如果配置了 MaxContentLength,string 类型的日志内容会被截断到指定长度,并追加一个 truncated: true 字段。这不是为了防止日志炸磁盘(磁盘空间一般充足),而是因为像 http.ResponseWriter 包装后的日志可能会把整个 response body 打印出来,在响应体很大的时候污染日志,影响后续 grep 的效率。
敏感数据脱敏。 go-zero 提供了一个 Sensitive 接口:
1 | type Sensitive interface { |
任何实现了这个接口的类型,在日志输出时会自动调用 MaskSensitive(),用脱敏后的值代替原始值。这发生在两个层面:日志消息体本身(val)和每个字段的值(field.Value)。
字段值的类型感知编码。 processFieldValue 对常见类型做了定制处理:
error→ 通过encodeError安全地调用.Error(),防止 nil pointer panictime.Duration→ 转为字符串(如"1.5s")json.Marshaler→ 保留 JSON 编码能力fmt.Stringer→ 安全地调用.String()
JSON 模式下,最终输出形如 {"@timestamp":"2026-08-12T10:30:00.000+08:00","level":"info","content":"order created","caller":"logic/orderlogic.go:42","trace":"abc123...","span":"def456..."}。注意 @timestamp 字段使用了 @ 前缀——这是为了在 ELK 等日志平台中让它被识别为时间字段。
RotateLogger:异步写入与文件轮转
大多数日志库的写入是同步的——每次调用 Write 都是一次系统调用。go-zero 的 RotateLogger 采用了异步写入模式,核心是一个带缓冲的 channel:
1 | // core/logx/rotatelogger.go |
日志数据通过 channel 发送给一个专门的 goroutine(startWorker),由它负责实际的 Write 系统调用和文件轮转。channel 大小为 100,意味着在高并发写日志时,最多可以缓冲 100 条日志消息而不阻塞业务 goroutine。如果 channel 满了?Write 方法会立即返回 false,但调用方(output 函数)会退回到标准库的 log.Println 做一个保底。这是一个明确的取舍:宁可丢一条日志,也不阻塞业务处理。
轮转规则通过 RotateRule 接口定义,有两种实现:
DailyRotateRule:每天轮转一次。备份文件名为access-2026-08-12.log。通过MarkRotated()记录上次轮转日期,通过ShallRotate()比较当前日期与记录日期。SizeLimitRotateRule:文件达到指定大小后轮转。继承自DailyRotateRule,增加了maxSize和maxBackups限制。ShallRotate()检查currentSize >= maxSize。
轮转时还支持 gzip 压缩——轮转完成后,一个后台 goroutine 将旧文件压缩为 .gz,并清理超过保留天数或超过最大备份数的过期文件。
限频机制:防止日志爆炸
在故障场景下,日志输出本身就是一种负担。一个下游超时的错误,在 1000 个并发请求下每秒会产生 1000 条错误日志。go-zero 用了 limitedExecutor 来防止这种日志爆炸:
1 | // core/logx/limitedexecutor.go |
在 threshold 时间内(比如 100ms),只有第一次调用会执行,后续调用只增加丢弃计数。当 threshold 过去后,下一次调用先报告"这期间丢弃了多少条",再执行。这使得日志输出从"每个错误一条"退化到"每 100ms 一条 + 丢弃计数"——信息量没有丢失(你知道丢失了多少条),但日志量降低了两个数量级。
limitedExecutor 的两大消费者是:
lessWriter:限频 stack trace 的写入(用在concreteWriter.stackLog)LessLogger:面向用户的限频 Logger,用户可以在业务代码中使用logx.NewLessLogger(500)来限制某个特定日志的频率
链路追踪:OpenTelemetry 的集成与传播
日志告诉你"发生了什么",但当一个请求跨越多个服务时,"发生了什么"散落在各个服务的日志中。你需要一个办法把它们串起来——这就是链路追踪的角色。
全局设定:一次初始化,处处生效
go-zero 的追踪模块通过 sync.Once 保证全局 TracerProvider 只初始化一次。入口是 trace.StartAgent(c Config):
1 | // core/trace/agent.go |
startAgent 做了三件关键的事:
第一,创建 Exporter。 根据 Batcher 配置选择不同的后端:
"zipkin":Zipkin 兼容后端"otlpgrpc":OTLP over gRPC(默认),支持自定义 headers(如 Uptrace DSN)"otlphttp":OTLP over HTTP,支持自定义路径和 TLS"file":本地文件(开发和调试用)
对于 OTLP gRPC,特别注意它使用了 WithInsecure() 和非阻塞连接模式——这意味着如果 exporter 不可达,不会阻塞服务启动。
第二,设置全局 TracerProvider。 通过 otel.SetTracerProvider(tp) 将 TracerProvider 注册为全局单例。同时,错误通过自定义的 ErrorHandler 路由到 go-zero 的 logx.Errorf——OTEL SDK 的内部错误不会无声丢失。
第三,设置采样策略。 使用 ParentBased(TraceIDRatioBased(sampler))——基于 Trace ID Ratio 的采样 + 基于父 Span 的决策继承。如果上游已经决定要采样这个 trace,下游强制采样;否则按比例随机采样。
W3C Trace-Context 传播
追踪的关键不是"创建 span",而是"把 span 连成一条链"。go-zero 在包初始化时就确定了传播协议:
1 | // core/trace/propagation.go |
TraceContext 是 W3C 标准的 trace 传播协议,通过 traceparent 和 tracestate HTTP header 传递 trace 信息。Baggage 允许在 trace 上下文之外携带用户自定义的键值对。两者组成一个 composite propagator——一次注入或提取,两套信息同时传播。
REST 侧的 Trace 中间件
TraceHandler 是 HTTP 请求的追踪入口:
1 | // rest/handler/tracehandler.go |
注意第四步——r.WithContext(spanCtx)。这一步是关键桥接:它把 trace context 注入到 HTTP 请求的 context 中。后续的 handler、logic 以及所有使用 logx.WithContext(r.Context()) 的日志调用,都会自动获得 trace ID 和 span ID 字段。日志和 trace 就是这样关联起来的。
gRPC 侧的双向传播
gRPC 的追踪比 HTTP 多了一个环节——gRPC 使用 metadata 传递上下文,而不是 HTTP header。go-zero 提供了 Inject 和 Extract 两个适配函数:
1 | // core/trace/tracer.go |
metadataSupplier 将 gRPC 的 metadata.MD 适配为 OpenTelemetry 的 TextMapCarrier 接口。在服务端,UnaryTracingInterceptor 通过 Extract 从 incoming metadata 中提取上游的 trace context;在客户端,UnaryTracingInterceptor 通过 Inject 将 trace context 注入 outgoing metadata。
服务端 interceptor 的核心流程:
1 | // zrpc/internal/serverinterceptors/tracinginterceptor.go |
每个 span 不光有方法名(/package.Service/Method)、服务名、对端地址这些属性,还记录了 message 事件——每次发送和接收的 proto 消息大小、消息 ID,这些信息在排查"哪个请求/响应的消息体特别大"时非常有用。
REST 到 RPC 的完整 Trace 链路
当 REST 服务调用 RPC 服务时,一条 trace 需要跨两种协议。REST 服务从 HTTP header 提取 trace 上下文,当它通过 gRPC client 调用下游时,客户端的 UnaryTracingInterceptor 会自动将 trace context 注入到 gRPC metadata 中。下游的 UnaryTracingInterceptor 再从 metadata 中提取——整条 trace 就这样跨越了协议边界。
中间件的顺序也保证了 trace 总是最先执行。在 REST 中:
1 | Trace → Log → Prometheus → MaxConns → Breaker → Shedding → Timeout → ... |
Trace 在最外层意味着:即使后续的 Breaker 或 Shedding 拒绝了请求,拒绝行为本身也会在一个 span 中被记录下来——通过 span 的属性(HTTP 503)和事件(为什么被拒绝),你可以区分"是断路器跳了"还是"降载器丢掉了"。
统计指标:CPU 感知的延迟分位数
日志是离散事件,trace 是跨服务链路,Metrics 则回答了另一个维度的问题:“过去一分钟,这个接口的平均响应时间是多少?99% 的请求在多少毫秒内完成?”
go-zero 的 core/stat/ 模块实现了一套独立的聚合统计系统,它不从 Prometheus 获取数据,而是直接从请求链路中收集原始数据,自行计算分位数。
Task 收集与定时聚合
一切从 Task 开始:
1 | // core/stat/task.go |
Task 是最小的统计单元。每当一个请求完成(或被拒绝),调用方创建一个 Task 并调用 Metrics.Add(task)。Metrics 内部使用 executors.PeriodicalExecutor,每隔一分钟执行一次聚合:
1 | // core/stat/metrics.go |
ReqsPerSecond 的计算很简单:size / 60s,就是一分钟内的平均 QPS。Drops 是被拒绝(breaker/shedding/limit)的请求数——这是一个有用的信号:drop 数突然上升意味着某个保护机制触发了。
分层 topK:用最小堆计算分位数
计算分位数的直观做法是排序——但对一分钟内上万个请求排序,时间开销不小。go-zero 使用了一种 分层最小堆 策略:
1 | // core/stat/metrics.go |
分层的思路是:先从全部请求中取前 50%(得到 p50),再从 p50 中取前 1/5(得到 p90),再从 p90 中取前 1/10(得到 p99)……每一层的搜索范围缩小一个数量级,最小堆的 k 值也随之缩小。这样总计算复杂度从 O(N log N)(全排序)降到 O(N log k₁ + N/2 log k₂ + ...),其中 k 值逐层缩小。
topK 使用 Go 标准库的 container/heap:
1 | // core/stat/topk.go |
标准的最小堆取前 K 大——当堆未满时直接放入,满了后如果新元素比堆顶大则替换堆顶。最终堆中保留的就是前 K 个最大元素,堆顶是第 K 大的值,即分位数值。
输出双通道:Stat 日志与 RemoteWriter
计算完成后,StatReport 通过两个通道输出:
- Stat 日志:
logx.Statf("(%s) - qps: %.1f/s, drops: %d, avg time: %.1fms, med: %.1fms, 90th: %.1fms, 99th: %.1fms, 99.9th: %.1fms", ...)——输出到stat.log文件,每行一条,适合 grep 归档。 - RemoteWriter:如果设置了
stat.SetReportWriter(stat.NewRemoteWriter("http://...")),则通过 HTTP POST 将 JSON 格式的StatReport发送到外部监控系统。
CPU 监控:EMA 平滑与 cgroup 感知
除了请求级别的统计,stat 包还运行了一个后台 CPU 监控 goroutine:
1 | // core/stat/usage.go |
CPU 数据每 250ms 刷新一次,通过指数移动平均(EMA, β=0.95)平滑——等价于约 5 秒的滑动窗口。这在上一篇的降载器中被使用——降载器通过 stat.CpuUsage() 判断 CPU 是否过载。
CPU 的采集是 cgroup 感知的(Linux 下)。core/stat/internal/ 中的代码会读取 cgroup v1 或 v2 的 CPU quota 和 CPU usage,在容器化部署中能拿到容器级别的 CPU 使用率,而不是宿主机级别的。这个设计保证了降载器在容器环境中仍然准确。
Prometheus 指标:类型包装与开关控制
与 stat 的自建聚合不同,Prometheus 模块是标准的指标暴露层。go-zero 的 core/metric/ 封装了 prometheus/client_golang,提供 Counter、Gauge、Histogram、Summary 四种向量类型,但同时增加了一个关键的"开关"机制。
全局开关:不启用就不开销
所有指标操作的入口是一个 update 函数:
1 | // core/metric/metric.go |
每个指标的每个操作(Inc、Add、Set、Observe)都包裹在 update 中。如果 Prometheus 没有被启用,所有操作都是空调用。这不是多余的——prometheus.Enabled() 只是一个 atomic.Bool 的读取,几乎零开销。而如果不做这个检查,即使没有启动 Prometheus agent,CounterVec.Inc() 仍然会执行完整的 label 查找和计数更新——这些都是有实质开销的。
enabled 这个开关被设置在 prometheus.StartAgent(c Config) 中:
1 | // core/prometheus/agent.go |
只有当用户显式配置了 Prometheus 的 Host 和 Port,agent 才会启动并且设置 enabled = true。这个设计对开发体验很友好:本地开发时不用配 Prometheus,不会有额外的指标开销;部署到生产时配上了 host,指标自动生效。
四类向量的统一模式
core/metric/ 提供了四种向量类型,每种都遵循同样的模式:
- 公共接口(如
CounterVec)定义方法 - 私有实现(如
promCounterVec)封装*prom.CounterVec - 构造函数注册到 Prometheus 并注册 shutdown 清理
以 Counter 为例:
1 | // core/metric/counter.go |
shutdown 清理器会调用 prom.Unregister 取消注册——这不是可有可无的,因为在重启时如果不清理旧的注册,MustRegister 会对重复注册的指标 panic。
运行时诊断工具:健康检查、Dev Server 与持续 Profiling
日志、trace 和 metrics 构成了可观测性的三大支柱,但还有一些工具不在三者之中,却一样重要——它们帮你回答"服务还活着吗"、"现在 CPU 在干什么"这些问题。
健康检查:组合探针模式
internal/health/ 提供了一个简洁的多探针健康检查框架:
1 | // internal/health/health.go |
单个 Probe 是一个命名组件——比如数据库连接池、Redis 客户端、消息队列消费者——各自用 MarkReady() 标记自己就绪。多个 Probe 通过 comboHealthManager 组合在一起:
1 | func (p *comboHealthManager) IsReady() bool { |
组合语义是逻辑与——所有探针就绪才算就绪。这考虑了"数据库还没连上但 HTTP 端口已经开了"的场景——Kubernetes 的 readiness probe 此时应返回 503,而不是 200。
CreateHttpHandler 将一个组合探针对外暴露为 HTTP 端点:
- 全部就绪 → 200 +
healthResponse(默认"OK") - 任一未就绪 → 503 + 逐探针状态列表(
"mysql is not ready\nredis is ready\n")
Dev Server:一个端口提供三种能力
internal/devserver/ 将三个内部服务聚合到了一个 HTTP 端口(默认 6060)上:
1 | // internal/devserver/server.go |
访问 :6060 的根路径能看到所有已注册路由的 JSON 列表——这在排查"哪个端点有没有被正确注册"时很方便。
这里需要注意两套 Prometheus 的区别:
core/prometheus/是独立的 agent,启动在用户配置的端口上(默认 9101)- Dev Server 的
/metrics端点(默认在 6060 上)在启动时会调用prometheus.Enable(),激活全局指标开关,但数据源是同一个——都是promhttp.Handler()暴露的默认 Registry
两套端口的分工是:9101 给 Prometheus scrap(生产用途),6060 给人直接访问(调试用途)。
持续 Profiling:CPU 门控的 Pyroscope 集成
internal/profiling/ 不是普通的 pprof 端点(那些在 Dev Server 中已经有了),而是持续的、自动触发的 Pyroscope profiling。它的独特之处在于:profiling 不是一直开着的,而是根据 CPU 使用率自动启停:
1 | // internal/profiling/profiling.go |
这个 CPU 门控设计非常务实:profiling 本身有开销(特别是 memory profiling),一直开着会消耗约 5-10% 的 CPU。在 CPU 空闲时采样没有意义(系统表现良好),只有在 CPU 过载时才需要看火焰图——“CPU 时间花在哪里了”。默认每 10 秒检查一次,一旦 CPU 超过 700 millicpu(约 70%),自动启动 profiler;持续 2 分钟后自动停止。
Profiling 类型覆盖:
- CPU(默认开):CPU profiling
- Goroutines(默认开):goroutine 状态快照
- Memory(默认开):
AllocObjects、AllocSpace、InuseObjects、InuseSpace - Mutex(默认关):mutex 竞争 profiling——需要
runtime.SetMutexProfileFraction(10) - Block(默认关):阻塞 profiling——需要
runtime.SetBlockProfileRate(1000000)
Mutex 和 Block 默认关闭是因为它们的运行时开销更大——采样会拦截每次 mutex 锁和 channel 阻塞操作——只在确定需要排查锁竞争时才打开。
三个 Pillar 的串联:日志、Trace、Metrics 的协同
到此,go-zero 的可观测性体系各个模块都已就位。但它们不是孤立的——它们在一份请求的生命周期中协同工作。让我们以一次 REST → RPC 调用为例,追踪完整的可观测性数据流:
1 | HTTP 请求到达 |
日志是如何和 Trace 关联的? 通过 context。Trace 中间件将 trace context 注入到 HTTP 请求的 context 中,logx.WithContext(ctx) 自动从 context 中提取 trace ID 和 span ID。这意味着任何带 WithContext 的日志调用——不管是在中间件、logic 还是更深的调用栈——都会自动带上 trace 信息。
Metrics 是如何和 Trace 关联的? 它们不直接关联——Metrics 是聚合数据,不携带 trace ID。但它们通过相同的维度(接口名、方法名)可以间接对照:trace 中的一个慢请求的 span 耗时,在 metrics 的 p99 分位数中体现为其统计数据的一部分。
三者各自的出口不同,但数据源是同一份请求:
| 支柱 | 数据源 | 出口 |
|---|---|---|
| Logs | 中间件、Logic 中的 logx 调用 | 本地文件(access/error/slow/stat.log)、可扩展 Writer |
| Trace | Trace 中间件/拦截器自动创建 Span | OTLP/Zipkin/File Exporter → Jaeger/Grafana/Uptrace |
| Metrics | Metrics.Add(task), Prometheus metrics | stat.log + RemoteWriter + Prometheus /metrics 端点 |
总结
本文从"保护机制生效后怎么知道发生了什么"这个问题出发,梳理了 go-zero 的可观测性体系的各个组件。
日志系统(logx) 是整个体系的底座。三层架构——公共 API、richLogger 门面和 Writer 输出层——将"业务调用日志"和"日志如何存储"解耦。WithContext(ctx) 自动注入 trace 信息的设计,是日志与 trace 关联的最关键机制。异步写入、文件轮转、内容截断、敏感数据脱敏和限频写入是它作为生产级日志的核心竞争力。
链路追踪(trace) 通过 OpenTelemetry 实现了跨服务、跨协议的 trace 传播。W3C Trace-Context 协议在 HTTP 和 gRPC 之间无缝切换,metadataSupplier 是 gRPC metadata 和 OTEL TextMapCarrier 之间的适配器。Trace 中间件在最外层执行,保证所有后续中间件(包括 breaker 的拒绝和 shedding 的丢弃)都能被 span 记录。
统计指标(stat) 用分层最小堆算法高效计算分位数,同时提供 CPU 监控(cgroup 感知 + EMA 平滑),为降载器提供决策依据。Prometheus 指标(metric/prometheus) 则以"不启用就不开销"的全局开关为特色,零配置的开发体验和丰富的生产数据互不冲突。
辅助工具——健康检查、Dev Server 和 Pyroscope 持续 profiling——填补了可观测性的剩余空白。CPU 门控的自动 profiling 尤其务实:不开着浪费 CPU,但在 CPU 飙高时自动采样,既省资源又不丢失关键时刻的诊断数据。
下一篇中,我们将从可观测性转向 go-zero 的并发工具箱——看 RollingWindow、TimingWheel、SingleFlight 等基础组件是如何支撑起上层的熔断器、降载器和限流器的。