凌晨两点,告警面板上没有答案
「回声」的订单服务在凌晨两点开始超时。值班同学打开可观测性平台,看到三类信号都很齐全:日志在滚动,指标有曲线,追踪也有采样。但他花了四十分钟才定位到问题,因为每一类信号都“有”,却没有一类能直接回答他当下的问题。
他想知道“这次超时影响了哪些用户”,指标里只有整体 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 或启动检查,约定会在下一次迭代中被绕过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。