Skip to content

可观测性三大支柱:从“服务变慢了”找到下一条证据 ​

服务的 P99 延迟突然升高,先搜错误日志不一定有结果。请求可能全都成功,只是在连接池前排了队。反过来,日志里出现一百条错误,也不能直接判断影响有多大:如果总请求量是一亿次,和总请求量是一百次,含义完全不同。

日志、指标和链路追踪分别保存事件细节、数量分布和调用关系。掌握它们的目标,是面对一个问题知道先看什么、怎样把下一条证据找出来,同时知道哪些信息已经在聚合或采样中丢掉。所谓“三大支柱”是常用的组织方式,不是“装齐三套系统就能解释一切”的定理;CPU 剖析、部署记录和用户反馈仍可能不可缺少。

本文的 Go 代码在 Go 1.27.2、Linux/amd64 实测。日志使用标准库;直方图与分位数代码是明确标注的数学演示,不冒充 Prometheus 客户端或查询引擎。

先按问题选证据 ​

现在想知道什么先看什么能得到什么单靠它还缺什么
错误是否变多,哪些接口最受影响指标时间趋势、错误比例、延迟分布单次失败的细节
某次订单校验为什么失败日志错误类别、业务阶段、允许记录的上下文未记录的步骤,以及总体发生比例
一次请求的时间花在哪个服务TraceSpan 的时间和父子关系没埋点、被丢弃或没有传播过去的部分
CPU 到底在执行什么Profile被采样到的调用栈单次用户请求的完整经过
为什么恰好这时开始异常上述信号加变更记录时间、版本、流量的对应关系因果关系仍需进一步检验

三种信号应有共同的服务名、环境、版本和时间口径。Trace ID 可以连接某次请求的日志与 Trace,但必须真的写入日志、在采集时保留,再配置检索或跳转;装了追踪 SDK 并不会替任意日志补字段。[1][2]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    R[一次请求] --> L[结构化日志<br/>保留事件字段]
    R --> M[Counter / Histogram<br/>累计到服务和路由维度]
    R --> T[Span<br/>保留调用关系与耗时]
    M --> A[发现错误率或延迟异常]
    A --> Q[限定服务、路由、时间窗口]
    Q --> L
    Q --> T
    L <-->|显式写入并保留 trace_id| T
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    R[一次请求] --> L[结构化日志<br/>保留事件字段]
    R --> M[Counter / Histogram<br/>累计到服务和路由维度]
    R --> T[Span<br/>保留调用关系与耗时]
    M --> A[发现错误率或延迟异常]
    A --> Q[限定服务、路由、时间窗口]
    Q --> L
    Q --> T
    L <-->|显式写入并保留 trace_id| T

图中没有让每个请求 ID 都进入指标标签。指标负责压缩大量请求;把请求 ID 加回标签,会让压缩效果消失。

日志:先把事件写清楚,再讨论检索 ​

结构化日志是有明确字段的事件记录。"status":503 比一句“调用失败,返回503”更适合稳定筛选,因为程序不必依赖自然语言的位置。常用字段包括服务、环境、版本、路由模板、事件类别、错误码;具体字段必须跟要解决的问题对应。

Go 的 slog.NewJSONHandler 输出 JSON,Logger.With 可附上固定属性,LogAttrs 用有类型的属性避免键值错位。下面的 trace_id 是明确写入的演示标识,并非真实追踪系统产生的 Trace ID。[1:1]

go
l.LogAttrs(context.Background(), slog.LevelError, "dependency failed",
    slog.String("service", "checkout"),
    slog.String("route", "/orders/{id}"),
    slog.Int("status", 503),
    slog.String("trace_id", "demo-trace"))

完整程序去掉不固定的时间字段,实际输出:

text
{"level":"ERROR","msg":"dependency failed","service":"checkout","route":"/orders/{id}","status":503,"trace_id":"demo-trace"}
service=checkout AND status=503: true

第二行来自程序把 JSON 解码后按两个字段筛选,说明结构化字段可被精确读取。它没有验证 Loki、Elasticsearch 或某个日志平台的查询语法。

写出日志只是第一步。一个常见链路是进程写标准输出,节点上的采集器读取,解析后送入日志存储,查询端再按字段和时间检索。也可以由应用直接发送;这减少中间文件,却把网络连接、缓冲和退出时刷新带进应用。选择时要检查以下故障点,而不只是“页面上能看见一条日志”:

环节应当核对的行为典型失误与代价
产生字段类型、级别、单位是否稳定同一字段一会数字一会文本,查询和映射变得不可靠
采集多行异常、轮转、重启位置、重复读取栈被拆散;轮转时漏读或重复读
缓冲队列上限、背压或丢弃、失败计数日志后端故障拖慢请求,或静默丢失证据
存储索引字段、保留期、访问权限全字段索引增加成本;保留过短无法复盘
检索时区、事件时间与接收时间、关联字段时钟偏差或延迟到达导致“查不到”

这些是部署检查项,不是所有采集器的统一保证。OTel Collector 也可能因队列满、重试期限结束或进程退出而丢数据;持久队列能覆盖一部分故障,但不能消除无限积压。[3]

隐私最省事的处理发生在写日志之前:不要写认证头、Cookie、完整请求体和未脱敏的个人信息。错误对象本身也可能携带 URL 查询参数或数据库值。字段脱敏必须测试嵌套字段和异常路径;“后端再删”意味着数据已经经过了传输、缓存甚至备份。

指标:类型决定能对它做什么运算 ​

类型保存什么Go 服务中的例子常用动作容易写错的地方
Counter一个生命周期内只增不减的累计量,可重置完成请求数、失败次数、发送字节数Inc / 非负 Add;查询增长速率把累计值当每秒请求数,或忽略重启
Gauge当前量,可升可降正在处理的请求数、队列长度、可用连接数Set / Inc / Dec对当前温度之类的量使用 Counter;遗漏对应的减一
Histogram观测值的分布摘要请求时长、响应大小每次完成后 Observe / Record桶没覆盖关注阈值;把桶计数当互斥区间

表中的命名是跨库说明:Prometheus 和 OTel 的类型体系及方法名并非完全相同。Prometheus classic histogram 展开为多个带 le 标签的累计桶,以及 _sum、_count;OTel 的数据模型还区分增量/累计时间性,导出时要核对转换。[4][5]

Counter 重置:先对每条序列求速率,再聚合 ​

一个进程的 Counter 从 100 增长到 130 后重启,下一次抓取是 5。5 - 130 得到 -125,但业务没有“负的请求”。Prometheus 的 rate(counter[5m]) 会处理观测到的 Counter 重置并做窗口外推,不等于简单的首尾相减。没有被抓到的重启和增量,不能靠查询完全恢复。[6]

对多个实例,常见查询形状是:

promql
sum by (service) (rate(http_requests_total[5m]))

先求 rate,每个实例的重置仍可辨识;先合并累计值可能把一个实例的下降和另一个实例的增长混在一起。这段是依照官方语义给出的查询示例,本文没有启动 Prometheus 执行它。

Histogram:累计桶可以相加,分位数通常不能直接平均 ​

假设桶边界为 0.1、0.3、1 秒,另有 +Inf 桶。A 实例观测到 0.05、0.2、0.8 秒;B 观测到 0.1、0.4、1.2 秒。一个 0.05 秒的请求会进入所有四个累计桶,不是只进第一个。[4:1]

go
a, _ := cumulativeBuckets(bounds, []float64{.05, .2, .8})
b, _ := cumulativeBuckets(bounds, []float64{.1, .4, 1.2})
total, _ := merge(a, b)

实际输出:

text
bounds=[0.1 0.3 1 +Inf] A=[1 2 3 3] B=[1 1 2 3] total=[2 3 5 6]
fraction_le_0.3=0.500

相同单位、相同桶边界、相同时间口径的桶计数可以相加,因此总共六个请求中三个不超过 0.3 秒,比例就是 50%。这适合直接计算“300ms 内完成”的 SLI。为了演示加法,辅助函数只检查数组长度;实际系统还必须确认边界本身相同。

P99 表示把观测值按大小排列后约 99% 所在的位置。经典桶只知道某一区间里有多少个观测,不知道每个精确值;histogram_quantile 会根据桶做插值,误差受桶分辨率影响。想在 300ms 附近判断得更清楚,就应围绕这个边界设计桶,不能指望很粗的桶精确恢复尾部。[4:2][6:1]

更隐蔽的错误是平均各实例的 P99、P50。实验用最近秩定义计算 P50:A 有五个值 1,B 有一个值 100。输出是:

text
mean_local_p50=50.5 global_p50=1.0

两个实例的 P50 平均为 50.5,但合并六个观测后中位数为 1。各实例请求量不同,分布也不同,只保留分位数无法重新组合全局分布。需要全局分位数时,先聚合桶,再算分位数;不要平均分位数。对于 classic histogram,还必须保留 le:

promql
histogram_quantile(0.99,
  sum by (service, le) (rate(http_request_duration_seconds_bucket[5m]))
)

Prometheus 也支持 native histogram,其数据表示和聚合查询不等同于上面的 classic 桶。选择前应核对客户端、抓取、远程写入和后端的版本支持,不能把 classic 的序列数量公式直接套过去。[7]

标签的成本来自组合数量 ​

“路由、状态码、实例”各自不多,组合起来也会增长。用三个有限边界的 classic histogram,包含 +Inf、sum、count 共六条序列。若有 20 个路由、5 种状态、8 个实例,且每种组合都实际出现,模型给出:

text
classic_series=4800 with_10000_users=48000000

第二项是在前面再乘一万个用户。它是组合上界算术,不是对具体后端内存的测量;有的组合不会出现,某些客户端还可能产生额外序列。即使如此,也足以说明为什么 user_id、订单 ID、原始 URL、Trace ID 不适合默认进入指标标签。使用 /orders/{id} 这种路由模板,单次详情留在经过权限控制的日志或 Trace 中。[8]

RED 看请求,USE 看资源 ​

方法维度问题示例
REDRate单位时间来了多少请求服务每秒请求数
REDErrors有多少失败失败请求速率和失败比例
REDDuration请求耗时分布怎样直方图、满足延迟目标的比例
USEUtilization资源在多大程度上被使用CPU 忙碌时间比例、连接池占用比例
USESaturation超过可立即处理能力的工作有多少等待连接数、运行队列、排队时长
USEErrors资源层发生什么错误磁盘错误、连接获取失败

RED 由服务入口向里看,USE 从资源向外查,二者可配合:先从 RED 发现订单接口变慢,再查连接池是否饱和。RED 的方法说明来自提出者的介绍,USE 来自 Brendan Gregg 的原始方法文档。[9][10]

利用率和饱和度不能混为一谈。连接池有 10 个连接,9 个被使用,是 90% 利用率;此时可以没有人等待。10 个都忙而且还有 50 个请求排队,才出现了明确的饱和证据。对于 CPU,还要看各核、运行队列、容器限额和节流;“整机平均 CPU 只有 30%”排除不了单线程瓶颈。利用率高可以是资源被有效使用,是否需要干预应结合等待和用户体验。

Trace:靠传播连接起来的 Span ​

Trace 是一次分布式操作相关 Span 的集合。Span 表示一个有开始和结束的操作,拥有 Span ID、Trace ID,通常有一个父 Span,还可以有跨关系的 Links。Span 名称宜是稳定的操作,如 inventory.check;具体订单号不适合写进每个 Span 名称。[11]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 订单服务
    participant H as HTTP Header
    participant B as 库存服务
    A->>A: 创建 client Span
    A->>H: Inject 当前 SpanContext
    H->>B: traceparent 随请求传输
    B->>B: Extract 远端父上下文
    B->>B: 创建 server Span,结束时导出
    B-->>A: 响应
    A->>A: 结束 client Span
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 订单服务
    participant H as HTTP Header
    participant B as 库存服务
    A->>A: 创建 client Span
    A->>H: Inject 当前 SpanContext
    H->>B: traceparent 随请求传输
    B->>B: Extract 远端父上下文
    B->>B: 创建 server Span,结束时导出
    B-->>A: 响应
    A->>A: 结束 client Span

埋点和传播是两件事。创建 Span 是埋点;把上下文编码进 Header,再从 Header 恢复,是传播。进程里的 context.Context 不能直接穿过网络。W3C Trace Context 规定了 traceparent、tracestate 的格式;traceparent 包含 Trace ID、父 Span ID 和标志,但不是授权凭证。[12]

Go 函数继续工作时,应传入包含当前 Span 的 ctx。HTTP 包装器通常能处理协议边界,仍需核对客户端与服务端两端都接入。异步任务要同时决定“任务能活多久”和“追踪如何关联”:请求取消可能结束请求 Context 的生命,而 Span 的 End 是另一件事。真正独立的后台任务可以用独立截止时间和 Link 记录触发关系;跟随请求完成的并发子任务则通常继续用请求 Context。不能为了保留追踪一律改成没有截止时间的 Background。[11:1][13]

采样省下存储,也会删掉证据 ​

策略决策时机适合什么丢掉什么或付出什么
Head sampling开始创建 Span 时控制产生与传输成本尚不知道最终是否出错或变慢
Parent-based根据父级是否采样决定子级尽量保持一次调用的决策一致上游未采样时,下游通常也不记录
Tail sampling收集一段时间后依据 Trace 内容偏向保留错误、慢请求等先接收、缓冲和关联数据;等待有上限,晚到 Span 可能不完整

Head 与 parent-based 可以组合,并非互斥选项。Go SDK 的 ParentBased(TraceIDRatioBased(p)) 是“根 Span 按比例判断,已有父级按配置跟随”的常见写法。它不意味着每个子服务都独立掷一次骰子。[14]

Tail sampling 也不能恢复上游根本没产生的 Span。如果先做 1% head sampling,后面的尾部采样最多从到达的那部分中挑选;它不能承诺“保留全部错误请求”。要让分布式尾部决策尽量完整,同一 Trace 的 Span 必须进入能共享决策的处理路径,还要规划缓存、超时和故障时的数据损失。[15]

按错误或延迟偏向保留的 Trace 样本,不代表总体请求分布。通常用覆盖口径明确的指标计算错误率和 SLO,再用样本解释具体失败;从抽样 Trace 直接推断全量比例,需要知道采样概率、缺失路径与估计方法。

把接入验收写成可以回答的问题 ​

先挑一个重要请求路径:正常请求能否在指标里计数?一个合成错误能否通过同一 Trace ID 找到对应日志?客户端和服务端 Span 是否连起来?关闭接收端后应用会阻塞还是丢遥测,丢弃是否有计数?重启后 Counter 查询是否仍合理?

这些检查能缩小未知范围,却不能保证“分钟级定位所有故障”。本文实际验证了 JSON 字段读取、经典累计桶的加法、分位数平均的反例及标签组合算术;没有部署采集器、Prometheus、日志后端,也没有测量生产开销。真实 SDK 的上下文、采样和日志关联实验见独立的《OpenTelemetry》,本篇不把那套工具的行为伪装成这里的模型行为。

依据与版本 ​

以下网页于 2026-10-13 核对;动态文档描述概念,具体实现以固定版本源码为准。

完整可复现代码 ​

将下面三个文件放进同一目录。命令为 go run .、go test ./...、go test -race ./...、go vet ./...。本次全部通过。负例在测试中断言拒绝,不需要启动网络服务;没有外部依赖。

go.mod ​

go.mod
module example.com/observability/pillars

go 1.27.0

main.go ​

代码块 · 94 行 · 点击收起 / 展开
go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"math"
	"slices"
)

// cumulativeBuckets is a teaching model for classic histogram bucket counts.
// It is not a concurrent metric implementation or a PromQL evaluator.
func cumulativeBuckets(bounds, observations []float64) ([]int, error) {
	if !slices.IsSorted(bounds) {
		return nil, errors.New("unordered bounds")
	}
	for i, b := range bounds {
		if math.IsNaN(b) || math.IsInf(b, 0) || (i > 0 && b == bounds[i-1]) {
			return nil, errors.New("invalid bounds")
		}
	}
	counts := make([]int, len(bounds)+1)
	for _, x := range observations {
		if x < 0 || math.IsNaN(x) || math.IsInf(x, 0) {
			return nil, errors.New("invalid duration")
		}
		for i, b := range bounds {
			if x <= b {
				counts[i]++
			}
		}
		counts[len(bounds)]++ // +Inf bucket
	}
	return counts, nil
}
func merge(a, b []int) ([]int, error) {
	if len(a) != len(b) {
		return nil, errors.New("different bucket counts")
	}
	out := make([]int, len(a))
	for i := range a {
		out[i] = a[i] + b[i]
	}
	return out, nil // caller must ensure identical boundaries and units
}
func quantile(xs []float64, q float64) float64 {
	ys := slices.Clone(xs)
	slices.Sort(ys)
	return ys[int(math.Ceil(q*float64(len(ys))))-1] // nearest-rank, inputs fixed here
}
func logs() (string, bool, error) {
	var b bytes.Buffer
	l := slog.New(slog.NewJSONHandler(&b, &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
		if a.Key == slog.TimeKey {
			return slog.Attr{}
		}
		return a
	}}))
	l.LogAttrs(context.Background(), slog.LevelError, "dependency failed",
		slog.String("service", "checkout"), slog.String("route", "/orders/{id}"),
		slog.Int("status", 503), slog.String("trace_id", "demo-trace"))
	var m map[string]any
	err := json.Unmarshal(b.Bytes(), &m)
	return b.String(), m["service"] == "checkout" && m["status"] == float64(503), err
}
func main() {
	line, found, err := logs()
	if err != nil {
		panic(err)
	}
	fmt.Print(line)
	fmt.Println("service=checkout AND status=503:", found)
	bounds := []float64{.1, .3, 1}
	a, err := cumulativeBuckets(bounds, []float64{.05, .2, .8})
	if err != nil {
		panic(err)
	}
	b, err := cumulativeBuckets(bounds, []float64{.1, .4, 1.2})
	if err != nil {
		panic(err)
	}
	total, err := merge(a, b)
	if err != nil {
		panic(err)
	}
	fmt.Printf("bounds=[0.1 0.3 1 +Inf] A=%v B=%v total=%v\n", a, b, total)
	fmt.Printf("fraction_le_0.3=%.3f\n", float64(total[1])/float64(total[3]))
	x, y := []float64{1, 1, 1, 1, 1}, []float64{100}
	fmt.Printf("mean_local_p50=%.1f global_p50=%.1f\n", (quantile(x, .5)+quantile(y, .5))/2, quantile(append(slices.Clone(x), y...), .5))
	fmt.Printf("classic_series=%d with_10000_users=%d\n", 20*5*8*(3+3), 20*5*8*(3+3)*10000)
}

main_test.go ​

go
package main

import (
	"math"
	"reflect"
	"testing"
)

func TestBuckets(t *testing.T) {
	got, err := cumulativeBuckets([]float64{.1, .3, 1}, []float64{.05, .1, .2, .4, .8, 1.2})
	if err != nil || !reflect.DeepEqual(got, []int{2, 3, 5, 6}) {
		t.Fatalf("%v %v", got, err)
	}
	for _, bad := range [][]float64{{-.1}, {math.NaN()}, {math.Inf(1)}} {
		if _, err := cumulativeBuckets([]float64{.1}, bad); err == nil {
			t.Fatal("accepted invalid duration")
		}
	}
	if _, err := cumulativeBuckets([]float64{.3, .1}, nil); err == nil {
		t.Fatal("accepted unordered buckets")
	}
	if _, err := merge([]int{1}, []int{1, 2}); err == nil {
		t.Fatal("accepted mismatched counts")
	}
}
func TestCounterexample(t *testing.T) {
	if quantile([]float64{1, 1, 1, 1, 1, 100}, .5) != 1 {
		t.Fatal("wrong global median")
	}
	_, found, err := logs()
	if err != nil || !found {
		t.Fatalf("%v %v", found, err)
	}
}

  1. Go 1.27.2 log/slog:JSONHandler、属性与 Context;本机 go doc 同时核对。 ↩︎ ↩︎

  2. OTel:非 OTLP 日志中的 Trace Context:Trace ID、Span ID 的显式字段约定。 ↩︎

  3. Collector resilience:队列、重试及数据丢失边界。 ↩︎

  4. Prometheus histograms and summaries:累计桶、分位数与聚合。 ↩︎ ↩︎ ↩︎

  5. Prometheus metric types;OTel metrics data model:类型及时间性。 ↩︎

  6. Prometheus query functions:rate、histogram_quantile 与计算顺序。 ↩︎ ↩︎

  7. Prometheus native histogram specification:原生直方图格式和支持限制。 ↩︎

  8. Prometheus instrumentation practices:标签基数成本。 ↩︎

  9. Tom Wilkie:RED method:请求率、错误、时长。 ↩︎

  10. Brendan Gregg:USE method:利用率、饱和度和错误的定义。 ↩︎

  11. OTel Trace API:Span、SpanContext、Links、End。 ↩︎ ↩︎

  12. W3C Trace Context 1,2021-11-23 Recommendation:Header 格式与安全考虑。 ↩︎

  13. Go 1.27.2 context:截止时间、取消与 WithoutCancel。 ↩︎

  14. OTel Go v1.44.0 sampling.go:父级与比例采样实现。 ↩︎

  15. OTel sampling concepts:head/tail 的能力与成本。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准