主题
可观测性三大支柱:从“服务变慢了”找到下一条证据
服务的 P99 延迟突然升高,先搜错误日志不一定有结果。请求可能全都成功,只是在连接池前排了队。反过来,日志里出现一百条错误,也不能直接判断影响有多大:如果总请求量是一亿次,和总请求量是一百次,含义完全不同。
日志、指标和链路追踪分别保存事件细节、数量分布和调用关系。掌握它们的目标,是面对一个问题知道先看什么、怎样把下一条证据找出来,同时知道哪些信息已经在聚合或采样中丢掉。所谓“三大支柱”是常用的组织方式,不是“装齐三套系统就能解释一切”的定理;CPU 剖析、部署记录和用户反馈仍可能不可缺少。
本文的 Go 代码在 Go 1.27.2、Linux/amd64 实测。日志使用标准库;直方图与分位数代码是明确标注的数学演示,不冒充 Prometheus 客户端或查询引擎。
先按问题选证据
| 现在想知道什么 | 先看什么 | 能得到什么 | 单靠它还缺什么 |
|---|---|---|---|
| 错误是否变多,哪些接口最受影响 | 指标 | 时间趋势、错误比例、延迟分布 | 单次失败的细节 |
| 某次订单校验为什么失败 | 日志 | 错误类别、业务阶段、允许记录的上下文 | 未记录的步骤,以及总体发生比例 |
| 一次请求的时间花在哪个服务 | Trace | Span 的时间和父子关系 | 没埋点、被丢弃或没有传播过去的部分 |
| 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
图中没有让每个请求 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 看资源
| 方法 | 维度 | 问题 | 示例 |
|---|---|---|---|
| RED | Rate | 单位时间来了多少请求 | 服务每秒请求数 |
| RED | Errors | 有多少失败 | 失败请求速率和失败比例 |
| RED | Duration | 请求耗时分布怎样 | 直方图、满足延迟目标的比例 |
| USE | Utilization | 资源在多大程度上被使用 | CPU 忙碌时间比例、连接池占用比例 |
| USE | Saturation | 超过可立即处理能力的工作有多少 | 等待连接数、运行队列、排队时长 |
| USE | Errors | 资源层发生什么错误 | 磁盘错误、连接获取失败 |
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
埋点和传播是两件事。创建 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.0main.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)
}
}Go 1.27.2 log/slog:JSONHandler、属性与 Context;本机
go doc同时核对。 ↩︎ ↩︎OTel:非 OTLP 日志中的 Trace Context:Trace ID、Span ID 的显式字段约定。 ↩︎
Collector resilience:队列、重试及数据丢失边界。 ↩︎
Prometheus histograms and summaries:累计桶、分位数与聚合。 ↩︎ ↩︎ ↩︎
Prometheus query functions:
rate、histogram_quantile与计算顺序。 ↩︎ ↩︎Prometheus native histogram specification:原生直方图格式和支持限制。 ↩︎
Tom Wilkie:RED method:请求率、错误、时长。 ↩︎
Brendan Gregg:USE method:利用率、饱和度和错误的定义。 ↩︎
OTel Trace API:Span、SpanContext、Links、End。 ↩︎ ↩︎
W3C Trace Context 1,2021-11-23 Recommendation:Header 格式与安全考虑。 ↩︎
Go 1.27.2 context:截止时间、取消与
WithoutCancel。 ↩︎OTel Go v1.44.0 sampling.go:父级与比例采样实现。 ↩︎
OTel sampling concepts:head/tail 的能力与成本。 ↩︎