《互联网产品的服务端架构·产品、流量与基础能力》11.2 日志、指标与追踪的最小集

日志、指标与追踪都接上了,故障时仍然查不出原因,往往是因为采集没有边界。文章说明三种信号各自回答什么问题,用高基数标签打爆时序库的案例解释成本从哪来,并给出可测试的采集审计规则与去重排序实现,帮助团队用最小集覆盖排障所需信息,避免工具越多越慢。

凌晨两点,告警面板上没有答案

「回声」的订单服务在凌晨两点开始超时。值班同学打开可观测性平台,看到三类信号都很齐全:日志在滚动,指标有曲线,追踪也有采样。但他花了四十分钟才定位到问题,因为每一类信号都“有”,却没有一类能直接回答他当下的问题。

他想知道“这次超时影响了哪些用户”,指标里只有整体 p95;他想知道“某一个失败请求经过了哪些服务”,日志里没有请求 ID,追踪采样率又低到恰好没有覆盖失败请求;他想知道“错误是从哪一天开始变多的”,日志保留七天,指标保留三十天,两边时间范围对不上。

问题不在工具数量,而在采集没有边界。团队过去半年的做法是“能采就采”:日志打印完整请求体,指标加上所有能拿到的标签,追踪开启全量采样。存储成本涨了三倍,查询却越来越慢,真正需要的信息反而被淹没。

可观测性的最小集不是“什么都采”,而是让每一类信号只回答它最擅长的问题,并且为另外两类留下关联键。

三种信号各回答一个问题

日志回答“这一次发生了什么”,指标回答“整体趋势如何”,追踪回答“一次请求经过了谁”。三者不是互相替代,而是从不同粒度描述同一个系统。

信号主要问题数据形态成本驱动典型保留期
日志单次事件的具体上下文离散文本或结构化事件条数、字段大小、索引7 到 30 天
指标一段时间内的整体趋势时间序列序列数(标签组合)30 到 400 天
追踪一次请求的调用链与耗时带父子关系的 span采样率、span 数、属性数3 到 14 天

表中的保留期是案例假设值,不是行业标准。真正的约束来自查询需求:如果事故复盘需要看三个月前的趋势,指标就要留三个月;如果日志只用于当天排障,保留七天可能就够。

日志的最小字段包括时间、级别、服务名、请求 ID、事件名和结果。请求体、响应体和 SQL 语句按需开启,默认不打印;需要排查时再通过采样开关临时打开。这样做不是为了省钱,而是为了避免日志系统变成第二个业务数据库,既要存储又要承担隐私风险。

指标的最小字段包括名称、有限的标签组合、时间戳和值。标签只保留能用于聚合的维度,例如接口、状态码、地区、客户端类型;任何接近唯一的字段都不应该进入标签。指标的价值在于趋势和对比,不在于还原单次请求。

追踪的最小字段包括 trace ID、span ID、父 span ID、服务名、操作名、开始时间、耗时和状态。属性可以按需增加,但不要把完整请求体塞进 span。追踪的核心是调用关系,属性只是辅助。

三类信号之间必须能关联。最小关联键是两个:request_id 让日志和追踪对上,trace_id 让追踪内部串起来。没有这两个键,三类信号就是三个孤岛,工具越多,排查越慢。

一个可落地的最小集可以写成清单:

  • 每个入口请求一条结构化日志,包含请求 ID、路由、状态码和耗时。
  • 每个关键依赖调用一条日志或 span,包含目标服务、结果和耗时。
  • 每个接口一组指标,标签只到接口、状态码和少量业务维度。
  • 每个失败请求保留完整追踪,正常请求按比例采样。
  • 每个告警附带请求 ID 样例、最近变更和受影响接口。
  • 每个采集配置在发布前跑一次审计,拦住高基数和缺失关联键。

这份清单不追求覆盖所有问题,它追求的是:常见故障发生时,值班同学能在三类信号之间跳转,而不是在三个互不相干的系统里重新开始。清单之外的信号可以按需增加,但每增加一项,都要回答它替代了哪一项、成本由谁承担。

采集决策可以用一张简单的表来收敛,表中的判断都是案例经验,不是硬性标准。

问题倾向采集倾向不采集
能直接回答排障问题结构化日志或 span只有原始文本
维度有限且稳定指标标签用户 ID、请求 ID
失败请求全量保留追踪按低比例采样
正常请求按比例采样全量保留
含敏感字段脱敏后摘要原样打印
成本无法预估先采样再评估直接全量上线

指标的设计要特别克制。指标的维度是标签,每个标签组合都是一条独立的时间序列。给指标加一个 user_id,序列数就从“接口数 × 状态码数”变成“接口数 × 状态码数 × 用户数”。用户数可以是百万级,存储和查询成本不是线性增长,而是直接换了一个数量级。

日志同样需要边界。完整请求体、响应体、SQL 语句和第三方返回都打出来,单条日志可能达到几十 KB;高峰期每秒几千条,一天就是几百 GB。更危险的是,这些字段里经常包含手机号、地址和令牌,日志系统往往比业务库更容易被忽视。

追踪的关键参数是采样率。全量采样能覆盖所有失败请求,但成本最高;低采样率便宜,却可能恰好错过那次事故。折中做法是尾部采样:先保留全部 span,请求结束后根据状态码和耗时决定是否落盘。这样失败和慢请求一定被保留,正常请求按比例丢弃。

失败场景:给指标加了 user_id,时序库三天被打爆

那次事故的直接原因,是团队为了“按用户排查问题”,给订单指标加上了 user_id 标签。上线时没人觉得有问题:指标名字没变,代码只多了一个标签,测试环境的数据量又很小。

生产环境的规模是案例假设值:80 万日活,订单接口每天约 300 万次调用,其中约 120 万次来自不同用户。加上 user_id 后,订单指标每天新增约 120 万条时间序列。时序数据库按天分片,每條序列都要保留索引和压缩块,三天后内存和磁盘同时告警。

第一个症状是写入变慢。指标上报从异步批量写入退化成频繁超时,业务代码里的上报调用开始阻塞请求。第二个症状是查询失败:值班同学想按接口聚合,查询引擎要先扫描海量序列,面板加载超过两分钟。第三个症状更隐蔽:为了保住核心指标,平台自动丢弃了“低优先级”序列,而判定优先级用的正是标签数量,结果部分机器的错误率指标被丢了。

第四个症状出现在账单上。存储团队按序列数收费,三天的增量让月度预算直接翻倍;为了控制成本,团队又给日志加上了更短的保留期,结果下一次排障时连当天早上的日志都查不到。成本压力和排障需求互相挤压,最后两边都没有做好。

事故的根因不是时序数据库不够强,而是把“排查用的维度”放进了“聚合用的指标”。按用户排查应该查日志或追踪,指标只保留有限维度的聚合,例如接口、状态码、地区、客户端类型。用户 ID、请求 ID、会话 ID 这类近乎唯一的标签,应该出现在日志和追踪里,不应该出现在指标里。

还有一个容易被忽略的边界:高基数不只来自显式标签,也可能来自错误信息。把完整错误文本作为指标标签,等于把每一种错误消息都变成一条序列;错误文本里常带订单号、路径和参数,基数同样会失控。正确做法是给错误分类,用有限的错误码做标签,原始文本留在日志里。

修复分三步。第一,移除指标上的 user_id 标签,改为在日志里输出用户 ID,并用请求 ID 关联。第二,给指标采集加一道审计:高基数标签、缺失关联键、空名称都在上线前报出来。第三,给平台加序列数配额和告警,超过阈值时拒绝新增序列,而不是等到磁盘写满。

这道审计不需要复杂的规则引擎,一组明确的约定就够。下面的实现把约定写成代码,采集侧可以在 CI 或启动时调用。

用 Audit 把采集约定变成检查

Record 描述一条信号在采集侧的属性,Audit 返回违规描述。规则只有四条:metric 不得高基数,log 必须带 request ID,trace 必须带 trace ID,名称不能为空。

// file: internal/obs/signals.go
// tier: L1
// verify: go test ./internal/obs/

package obs

import (
	"fmt"
	"sort"
)

// Kind 是可观测信号的三种基本类型。
type Kind string

const (
	KindLog    Kind = "log"
	KindMetric Kind = "metric"
	KindTrace  Kind = "trace"
)

// Record 描述一条信号在采集侧的属性。
// HighCardinality 表示该指标使用了用户 ID、请求 ID 这类近乎唯一的标签。
type Record struct {
	Kind            Kind
	Name            string
	HasRequestID    bool
	HasTraceID      bool
	HighCardinality bool
}

// Audit 检查一批信号是否满足最小采集约定,返回按字典序升序排列的违规描述。
// 约定只有四条:metric 不得高基数,log 必须带 request ID,
// trace 必须带 trace ID,名称不能为空。同一条违规只报告一次。
func Audit(records []Record) []string {
	seen := make(map[string]bool)
	add := func(format string, args ...any) {
		seen[fmt.Sprintf(format, args...)] = true
	}

	for _, r := range records {
		if r.Name == "" {
			add("record with kind %q has empty name", r.Kind)
		}
		switch r.Kind {
		case KindMetric:
			if r.HighCardinality {
				add("metric %q has high-cardinality labels", r.Name)
			}
		case KindLog:
			if !r.HasRequestID {
				add("log %q missing request id", r.Name)
			}
		case KindTrace:
			if !r.HasTraceID {
				add("trace %q missing trace id", r.Name)
			}
		default:
			add("record %q has unknown kind %q", r.Name, r.Kind)
		}
	}

	out := make([]string, 0, len(seen))
	for msg := range seen {
		out = append(out, msg)
	}
	sort.Strings(out)
	return out
}

HighCardinality 是一个显式标记,而不是让 Audit 去猜标签值。采集侧最清楚哪个字段接近唯一值,把这个判断放在声明处,比在审计函数里用启发式规则可靠。比如 user_id、request_id、session_id、email 都应该标记为高基数。

HasRequestID 和 HasTraceID 是布尔值,不是具体 ID。审计关心的是“有没有关联键”,不关心键的值;具体值由日志和追踪系统保存。这样 Audit 不接触用户数据,也不会把敏感字段带进检查结果。

空名称单独报错,因为名称是信号的主键。一个没有名字的指标无法聚合,一个没有名字的日志无法检索,一个没有名字的 trace 无法在调用链里定位。空名称通常来自模板变量为空或拼接错误,越早报出来越好。

未知类型也报错,而不是忽略。采集侧新增一种信号类型时,审计应该提醒维护者补充规则,而不是默默放行。忽略未知类型的后果是:新信号永远不受约束,等发现问题时已经积累了大量数据。

违规描述按字典序升序返回,并且同一条只出现一次。排序让不同机器、不同批次的输出可以直接比较;去重让同一批重复配置只产生一条告警。这两点看起来是输出格式,实际决定了审计能不能接进 CI:输出不稳定,CI 就会随机失败。

Audit 不修改入参,也不依赖记录顺序。它只做集合判断,因此可以安全地在采集启动时对配置跑一遍,也可以在测试里对样例跑一遍。

违规描述直接面向人阅读,没有引入错误码。这个选择适合当前规模:规则只有四条,消息本身已经说明了问题和对象。如果规则增长到几十条,再考虑加稳定的错误码,避免下游按文本匹配。无论哪种形式,输出顺序和去重都必须稳定。

审计的粒度是“声明”,不是“运行时数据”。它检查的是采集配置里有没有声明高基数标签、有没有关联键字段,而不是逐条扫描真实日志。这样成本固定、结果可复现,也不会把用户数据带进检查流程。运行时的高基数问题交给序列数监控,两者分工明确。

审计结果应该阻断发布吗?取决于团队阶段。新接入时可以先告警,观察一周后再改成阻断;已经稳定的采集配置应该直接阻断,因为此时新增高基数标签几乎总是错误。关键是让规则有明确的执行点,而不是停留在文档里。

测试:去重、排序与非法类型

测试要固定四个行为:多条违规按序输出、同一条违规去重、干净配置零输出、未知类型被拦住。

// file: internal/obs/signals_test.go
// tier: L1
// verify: go test ./internal/obs/

package obs

import (
	"reflect"
	"testing"
)

func TestAuditReportsViolationsInOrder(t *testing.T) {
	records := []Record{
		{Kind: KindMetric, Name: "http_requests", HighCardinality: true},
		{Kind: KindLog, Name: "login", HasRequestID: false},
		{Kind: KindTrace, Name: "checkout", HasTraceID: false},
		{Kind: KindLog, Name: "", HasRequestID: true},
	}

	got := Audit(records)
	want := []string{
		`log "login" missing request id`,
		`metric "http_requests" has high-cardinality labels`,
		`record with kind "log" has empty name`,
		`trace "checkout" missing trace id`,
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("audit = %v, want %v", got, want)
	}
}

func TestAuditDeduplicatesSameViolation(t *testing.T) {
	records := []Record{
		{Kind: KindMetric, Name: "session_active", HighCardinality: true},
		{Kind: KindMetric, Name: "session_active", HighCardinality: true},
	}

	got := Audit(records)
	if len(got) != 1 {
		t.Fatalf("audit = %v, want one deduplicated item", got)
	}
}

func TestAuditAcceptsCleanRecords(t *testing.T) {
	records := []Record{
		{Kind: KindLog, Name: "login", HasRequestID: true},
		{Kind: KindMetric, Name: "http_requests"},
		{Kind: KindTrace, Name: "checkout", HasTraceID: true},
	}

	if got := Audit(records); len(got) != 0 {
		t.Fatalf("audit = %v, want no violations", got)
	}
}

func TestAuditRejectsUnknownKind(t *testing.T) {
	got := Audit([]Record{{Kind: Kind("event"), Name: "order_paid"}})
	want := []string{`record "order_paid" has unknown kind "event"`}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("audit = %v, want %v", got, want)
	}
}

TestAuditReportsViolationsInOrder 一次构造四类违规,期望输出按字典序排列。注意 metric 排在 record 前面,因为比较的是完整字符串,不是违规类别。这个测试同时覆盖了空名称和小写消息格式。

TestAuditDeduplicatesSameViolation 用两条完全相同的记录,确认只返回一条。真实配置里同一个指标可能在多个采集点声明,如果不去重,一条问题会变成几十条告警,值班同学很快就会学会忽略它们。

TestAuditAcceptsCleanRecords 覆盖合法组合:带 request ID 的日志、不带高基数标记的指标、带 trace ID 的追踪。它防止审计过严,把正常配置也拦下来。

TestAuditRejectsUnknownKind 确认未知类型会报错。新增信号类型时,这个测试会提醒维护者:要么补规则,要么明确接受它。忽略未知类型是最容易发生的退化,因为代码不会报错,问题只会在几个月后出现。

这些测试都在内存里构造记录,不需要启动时序数据库或日志系统。采集约定本质上是配置约束,用纯函数验证比搭一套端到端环境更快,也更容易在 CI 里稳定运行。真正的存储行为由集成测试覆盖,不要在单元测试里重复。

还有一个边界值得单独测试:一条记录同时违反多条规则时,应该返回多条违规,而不是只返回第一条。当前实现会分别检查名称和类型,因此空名称的日志既会报空名称,也会报缺少 request ID。这不是重复告警,而是两个独立的配置问题,应该一起修。

运行命令与输出

在 lab 目录执行本章的测试命令:

cd .local/checks/webapp-lab
go test ./internal/obs/ -run 'TestAudit' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAuditReportsViolationsInOrder
--- PASS: TestAuditReportsViolationsInOrder (0.00s)
=== RUN   TestAuditDeduplicatesSameViolation
--- PASS: TestAuditDeduplicatesSameViolation (0.00s)
=== RUN   TestAuditAcceptsCleanRecords
--- PASS: TestAuditAcceptsCleanRecords (0.00s)
=== RUN   TestAuditRejectsUnknownKind
--- PASS: TestAuditRejectsUnknownKind (0.00s)
PASS
ok  	webapp/internal/obs	0.216s

输出中的四条 PASS 对应四个测试函数。实际接入时,可以把 Audit 的结果打印到启动日志,也可以把它作为 CI 检查的一步:有输出就失败,让配置问题在发布前暴露。

小结

最小集不是数量最少,而是每一类信号都有明确职责。日志回答单次事件,指标回答整体趋势,追踪回答调用链;三者用 request ID 和 trace ID 关联。

高基数标签是指标的成本与故障源。用户 ID、请求 ID、会话 ID 应该放在日志和追踪里,指标只保留有限维度。序列数增长不是线性成本,而是索引、内存和查询时间的共同膨胀。

采集约定要写成可执行的检查,而不是文档里的建议。Audit 的四条规则覆盖了最常见的配置错误,排序与去重让输出可以接进 CI。

最小集也需要定期复核。业务新增依赖、客户端新增版本、故障类型发生变化,都会让原来的信号组合出现盲区。复核不必追求大而全,先回答一个问题:上一次事故里,哪一类信号没有提供有效信息?如果没有,就删掉它;如果缺少,就补上并写进清单。

最后要接受一个事实:没有任何一组信号能覆盖所有故障。最小集的目标不是消灭盲区,而是让盲区可被发现、可被补上。每一次事故复盘都应该产出一条关于信号的结论,而不是只产出一条关于代码的结论。

阅读导航:上一节:11.1 用户视角的 SLI · 下一节:11.3 一次线上问题定位复盘 。

常见错误与边界

  • 给指标加 user_id、request_id 等高基数标签:序列数按用户数膨胀,存储和查询一起失控。
  • 日志不带 request ID:单条日志无法与追踪、业务事件关联,排障只能靠时间猜。
  • 追踪不带 trace ID:span 之间失去父子关系,调用链退化成散落的耗时记录。
  • 全量采样或极低采样二选一:前者成本高,后者容易错过失败请求,应该考虑尾部采样。
  • 日志打印完整请求体和响应体:成本高,且容易把手机号、地址、令牌写进日志系统。
  • 指标、日志、追踪保留期互不匹配:事故复盘时只有一类数据还在,时间线拼不起来。
  • 审计忽略未知信号类型:新类型永远不受约束,问题在几个月后才暴露。
  • 把审计结果只写进文档:没有 CI 或启动检查,约定会在下一次迭代中被绕过。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查