50.2 日志(结构化 / trace_id / 分级 / 采样)

一句话

日志的价值不在「记得多」,而在「每条都能按字段检索、都能被 trace_id 串到链路上、并且只在需要它的那几天占着最贵的存储」;一条日志的成本 = 字节数 × 保留天数,所以第一个决定必须发生在写入端。

一条日志该有哪些字段

字段 例子 缺了会怎样
ts 2026-10-25T14:02:11.382+08:00 跨时区/跨主机对不齐时间轴
level ERROR / INFO 无法按级别采样与告警
msg order created(常量文案) 变量拼进 msg → 无法聚合、无法按值检索
service.name / version / env order / v3.2.1 / prod 分不清哪个服务、哪个版本、哪个环境
trace_id / span_id 4bf92f35…4736 日志与链路断开,只能靠时间戳猜
host.name / k8s.pod.name ip-10-0-3-12 / order-7d9f-abc 定位不到实例,无法和指标下钻对齐
请求维度 http.method、route=/orders/{id}、status=500、duration_ms=118 无法算错误率与耗时分位
业务维度 order_id、sku_id、tenant_id 无法按业务对象检索、无法做定向排查
错误维度 error.type、error.code、error.stack(单行 JSON) 只能肉眼读堆栈,无法按类型聚合

两条硬规则:

  • msg 是常量,变量全部进字段。 这是「能聚合」与「只能人读」的分界线。
  • route 用模板,不用原始 URL。 /orders/{id} 而不是 /orders/8812——和指标放 user_id 是同一个病(50.4 度量)。

结构化:拼字符串的代价

package main

import (
	"context"
	"log/slog"
	"os"
)

type ctxKey struct{}

// traceHandler:不改业务代码,把 context 里的 trace_id/span_id 自动塞进每条日志
type traceHandler struct{ slog.Handler }

func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
	if sc, ok := ctx.Value(ctxKey{}).(span); ok {
		r.AddAttrs(slog.String("trace_id", sc.traceID), slog.String("span_id", sc.spanID))
	}
	return h.Handler.Handle(ctx, r)
}
func (h traceHandler) WithAttrs(as []slog.Attr) slog.Handler {
	return traceHandler{h.Handler.WithAttrs(as)}
}
func (h traceHandler) WithGroup(n string) slog.Handler {
	return traceHandler{h.Handler.WithGroup(n)}
}

type span struct{ traceID, spanID string }

func main() {
	// 固定字段在启动时绑定一次,而不是每条日志手写
	base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
	log := slog.New(traceHandler{base}).With(
		slog.String("service", "order"), slog.String("env", "prod"),
	)

	ctx := context.WithValue(context.Background(), ctxKey{}, span{
		traceID: "4bf92f3577b34da6a3ce929d0e0e4736", spanID: "00f067aa0ba902b7",
	})

	// 好:msg 是常量,变量全部进字段(可聚合、可检索)
	log.InfoContext(ctx, "order created",
		slog.String("order_id", "o_8812"), slog.Int("amount_cents", 12900),
		slog.String("channel", "app"))

	// 坏:变量拼进 msg —— 每条的 msg 都不同,既无法按文案聚合,也无法按订单号检索
	log.WarnContext(ctx, "order o_8812 created by user 42 from channel app, amount 129")
}

输出(本机实测):

{"time":"2026-10-25T22:38:47.208513+08:00","level":"INFO","msg":"order created","service":"order","env":"prod","order_id":"o_8812","amount_cents":12900,"channel":"app","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
{"time":"2026-10-25T22:38:47.209137+08:00","level":"WARN","msg":"order o_8812 created by user 42 from channel app, amount 129","service":"order","env":"prod","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
记法 按订单号检索 统计「创建订单失败率」 改文案不影响检索 字段缺失能发现
拼进 msg 只能全文模糊匹配 只能正则,几乎不可靠 不能,文案即 schema 不能
字段 精确 按 status/error.type 分组 能 能(schema 校验/告警)

级别:级别不是「严重程度」,是「谁会行动」

级别 判据 生产默认 谁看 动作
ERROR 有用户受影响或即将受影响,需要人介入 开 on-call 可能触发告警
WARN 系统自己恢复了,但值得计数(重试成功、降级命中、限流触发) 开 值班/看板 只看趋势,不告警
INFO 状态变更与关键事件(启动、配置变更、订单状态迁移) 开 排障时看 无
DEBUG 只对特定请求/租户临时开 关 开发者 动态调级
TRACE 逐步骤细节 关 几乎不用(这是链路的活) 无

两个高频错误:

  • 把「业务拒绝」打成 ERROR:库存不足、余额不足、参数校验失败。判据很直接——如果这件事每天发生 1 万次且属于正常业务,它就不是 ERROR。否则错误率虚高,告警全部失真,最后没人看错误率。
  • 把「需要人知道」打成 DEBUG:出事时没有任何上下文可看。级别是给「将来的自己」留的检索入口。

🔑 级别只有三个用途:过滤成本(关掉高分贝级别)、路由(ERROR 进告警通道)、采样依据(ERROR 全留、INFO 按比例)。任何与这三件事无关的级别讨论都是审美。

从应用到查询:六段流水线

段 做什么 典型组件 这一段挂了的后果 补救
应用输出 结构化写 stdout / 文件 slog、zap、logback 没数据 无,必须最先做对
采集 读文件/收容器 stdout,补 host/pod 元数据 Filebeat、Fluent Bit、Vector 丢一段时间的日志 采集端本地磁盘队列 + 断点续传
缓冲 削峰、解耦采集与处理 Kafka、Redis 峰值期日志被丢弃 采集端队列兜底
加工 解析(grok/JSON)、脱敏、补字段、路由 Logstash、Vector、OTel Collector 字段缺失、索引 mapping 冲突 保留一份 raw 原文兜底
索引存储 倒排索引 + 按时间分区 Elasticsearch、Loki、ClickHouse 查询不可用 降级到冷存扫描 / 临时只查指标
查询展示 检索、聚合、看板、跳转链路 Kibana、Grafana 只能用命令行 无

两个常被问到的问题:

① 解析放应用端还是采集端?

你的情况 选什么 原因
新服务、语言支持结构化 logger 应用端直出 JSON 字段语义准确、无正则开销、采样在源头可控
遗留系统、几十个老服务、改不动 采集端 grok 正则单核每秒只能处理几万行,复杂 grok 更低;但能在不改代码的前提下补齐
合规要求字段必须脱敏后才能出机器 应用端脱敏 + 采集端二次校验 敏感字段不能落到采集缓冲里

② 缓冲(Kafka)到底保证什么?

它保证「已经进了 Kafka 的数据不会因为下游 Logstash/ES 挂掉而丢」,不保证「采集器挂掉期间新产生的日志」不丢——那要靠采集端的磁盘队列。原文那句「即便它自己宕机也不会影响数据的完整性」,准确说是:缓冲把「下游故障」变成「延迟」,但把「上游故障」留给了采集端。

聚合:即时查询 vs 固定报表

  • 即时查询用存储端聚合(ES aggregation、ClickHouse SQL):问题临时提,维度临时选。
  • 固定报表用加工端预聚合(Logstash/Vector 里先按分钟聚一次):每天跑的看板不该每次扫全量原文。

判据:同一张图每天被看、且时间范围 > 1 天 → 预聚合;临时排障 → 即时查询。

trace_id:把日志挂到链路上

  • 必须是字段,且建索引(ES 里 keyword 类型;Loki 里做 label 或 structured metadata,取决于查询模式)。
  • 只有一个注入点:HTTP/gRPC/消费入口的 middleware。不要在业务函数里手写 trace_id,否则漏一处就断一处。
  • 采样要跟链路一致:日志的保留决策应跟随 trace 的采样决策,否则会出现「网关有日志、下游没有」的半条链。

⚠️ 常见断链:日志里有 trace_id,但链路里没开采样 → 日志能串起来,但点不进 trace。反过来(trace 有、日志没带 id)更常见。两侧都要有,关联才成立。

采样:五种策略与它们的证据损失

策略 怎么定 错误保留 成本 适用
全量 全写 100% 最高 资金、审计、合规(且必须脱敏)
按级别 ERROR 全留,INFO 按比例 100% 低 默认组合
跟随 trace 用 trace 的采样决策 取决于 trace 采样 低 已有链路追踪
按 key 指定 tenant_id/order_id 全留 定向 100% 可控 排障期临时开、大客户
均匀比例 hash(request_id) % 1000 < 10 按比例 最低 只看大流量趋势

算一下均匀采样的代价:100 万请求、错误率 0.1%(1000 条错误),均匀采样 1% 只留下 10 条错误。以这 10 条去判断「错误率有没有从 0.1% 涨到 0.2%」,置信区间比信号本身还大。

而「ERROR 全留 + INFO 1%」留下 1000 条错误 + 9900 条正常,日志量只多了 9%,错误证据一条不少。

🔑 日志采样的原则:按「级别 / trace 决策 / 业务 key」采,绝不按请求均匀采。均匀采样会把最稀缺的异常样本和普通样本一起稀释。

存储与保留:热、温、冷

层 保留 介质 查询延迟 用途
热 3–7 天 SSD / 本地盘 秒级 正在发生的排障
温 30–90 天 普通云盘 / 对象存储+索引 秒–十秒 复盘、趋势
冷 180 天–数年 对象存储(压缩归档) 分钟级 合规、取证

保留期由三件事决定,缺一不可:排障窗口(事故平均多久被发现)、合规底线(审计/资金类可能有最低保留期)、复盘需求(季度复盘要不要看三个月前的日志)。

成本量级:1000 QPS、每请求 1 KB,原始 86.4 GB/天,压缩 5× 加 2 副本后 34.6 GB/天,30 天约 1 TB(算法见 50.1 三支柱与成本模型)。分层单价差异见 39.3 存储分级。

日志不是大数据

从日志里分析用户行为、做漏斗和画像,属于大数据/数仓范畴(HBase + Spark、或直接进湖仓),不该压在可观测性集群上:可观测性只回答「这一次请求发生了什么」,它的查询模式是「按 id/时间/少量维度精确定位」,不是「全量扫描做聚合」。

判据:查询里有 GROUP BY user 且时间跨度以月计 → 那是分析,不是观测。

反模式

反模式 后果
打敏感信息(密码、手机号、身份证、完整卡号) 合规事故;日志系统的访问权限通常比数据库松得多(见 38.5 数据分级与脱敏)
打「引用内容」(要跨库/跨服务/重计算才能拿到的数据) 拖慢业务主路径,日志量失控
打调试/追踪数据(本该是链路的事) 成本翻倍,且暴露内部结构
埋假日志、占位日志 排查时被误导,比没有日志更糟
msg 里拼变量 无法聚合、无法按值检索、改文案即破坏查询
多行堆栈每行一条 采集端拆成 N 条独立日志,堆栈断链(必须单行 JSON 或配 multiline)
用原始 URL 当字段 基数爆炸,索引膨胀(同指标的 user_id 病)
级别乱用(业务拒绝打 ERROR) 错误率失真,告警失效
没有 trace_id 排障靠时间戳猜,跨服务只能对日志文件
每条日志都打全量请求体 单条几 KB,成本直接 ×5,且极易带出 PII

自检

Q1 为什么「库存不足」不该打 ERROR?

▸ 答案:ERROR 的定义是「有用户受影响且需要人介入」,而库存不足是正常业务流程,每天成千上万次。打成 ERROR 会让错误率虚高一个数量级,基于错误率的告警和 SLO 全部失真。正确做法:INFO(或 WARN,如果要统计拒绝率),用 reason=out_of_stock 这类字段区分,看板单独统计「业务拒绝率」——它和「系统错误率」是两个指标。

Q2 日志采样 1%,错误率 0.1%,为什么不能靠日志发现错误率涨到 0.2%?

▸ 答案:100 万请求只有 1000 条错误,1% 采样后剩 10 条。10 条样本的相对误差在 ±30% 以上,0.1% 与 0.2% 的差异完全淹没在采样噪声里。正确做法:错误率用指标算(全量计数,不采样),日志用「ERROR 全留 + INFO 按比例」保住证据;两者分工明确。

Q3 采集端已经用了 Filebeat,为什么中间还要加 Kafka?

▸ 答案:Filebeat 解决「把文件读出来并补元数据」,Kafka 解决「削峰 + 解耦」。流量高峰或 ES 写入变慢时,Logstash/ES 处理不过来,Kafka 把积压吸收掉,采集端不阻塞、不丢已入队的数据;同时下游可以多消费方(ES 检索 + 对象存储归档 + 实时规则)。但 Kafka 不保证「采集器宕机期间的新日志」——那要靠采集端自己的磁盘队列。

Q4 多行 Java 堆栈直接写进日志文件,会发生什么?

▸ 答案:采集端按行读取,一个 40 行的异常会变成 40 条互不相关的日志,且每条都不带 trace_id、order_id,堆栈彻底断链。解决方式二选一:① 应用输出单行 JSON(error.stack 里放换行转义后的全文);② 采集端配置 multiline 规则(按时间戳/日志头识别新记录),但规则一旦猜错就会吞日志,优先选 ①。

Q5 日志保留 30 天砍到 7 天,除了省钱还有什么影响?

▸ 答案:① 复盘窗口从 30 天缩到 7 天,跨周的问题(低频、慢燃)查不到;② 合规风险,审计/资金类日志可能有更长最低期;③ 与指标/链路的时间范围不一致——看板能看 30 天趋势,点进去却没有日志。替代做法是热 7 天 + 冷归档 180 天:热数据保证排障速度,冷数据保证合规与复盘,总存储成本约为「全热 30 天」的 1/4 量级。

关联