2211 字
约 7 分钟
23
S16-A28 · 日志级别的约定:什么该记、什么该丢
2024-12-31
2026-11-10

一次线上问题排查,团队按 ERROR 关键词搜日志,得到的结果是每条请求都有 ERROR——因为一个已知的下游抖动被记成了错误,一天几十万条。真正的问题发生在两小时前,它的日志是一条 WARN,被淹在里面没人看见。

日志级别的混乱不是格式问题,而是严重度语义没有约定。当 ERROR 表示"出错了但没关系",WARN 表示"也许有问题"时,这两个级别就都不再携带信息,而告警只能建立在其中一个上。


1. 五个级别各自的语义

级别 语义 谁来处理 是否告警
ERROR 需要人介入的失败 值班的人 是
WARN 异常但已自愈,或降级生效 定期复盘 通常否
INFO 关键业务事件(状态变化) 无(用于追溯) 否
DEBUG 排查所需的细节 排障时临时开 否
TRACE 逐步骤追踪 极少数场景 否

核心约定只有一条:ERROR 意味着"现在就该有人看"。一旦这条约定被破坏(比如把预期内的失败记成 ERROR),级别的语义就失效了,而恢复它比重建指标更难。

2. 最常被误记为 ERROR 的五类

情况 为什么不该记 ERROR
参数校验失败(用户输错) 预期内的正常路径
已重试成功的调用 最终成功了
下游抖动但降级生效 系统按设计工作
业务规则拒绝(余额不足) 是业务结果,不是故障
客户端主动断开 不是服务端问题

还有一个组织层面的成因:开发阶段为了让问题显形而把所有异常记成 ERROR,上线后没人回头调整。日志级别是少数"上线前后应当相反"的东西——开发期要吵,生产期要静。

第五项在文件下载、流式响应里很常见:客户端取消会让服务端抛异常,这类异常每次都记 ERROR,日志里就充满了"用户正常行为"。

// 反例:把所有异常都记成 ERROR
try {
    process(req);
} catch (Exception e) {
    log.error("处理失败", e);
}

// 正例:按语义分级,预期内的失败不升级为 ERROR
try {
    process(req);
} catch (ValidationException e) {
    log.info("参数不合法: {}", e.getMessage());          // 预期内
} catch (ClientDisconnectException e) {
    log.debug("客户端断开: {}", req.id());               // 正常行为
} catch (DownstreamException e) {
    log.warn("下游异常,已降级: {}", e.code());           // 已自愈
} catch (Exception e) {
    log.error("未预期失败, req={}", req.id(), e);         // 需要人看
}

3. 级别与告警的绑定关系

绑定方式 优点 风险
每个 ERROR 一条告警 不漏 噪音大,最终被静音
ERROR 计数超过阈值告警 抗噪 可能漏掉单次严重错误
按错误类型分别设阈值 精确 维护成本高
ERROR + 指纹去重 兼顾 需要指纹计算

第一项的结局几乎总是"被静音"。告警一旦被静音,它就不再是告警,而是一个没人看的数字。所以 ERROR 的产出量必须与"人能处理的量"匹配——这就是为什么把预期内失败降级为 INFO 是必要的。

// 告警规则:按错误指纹分组,避免同一错误刷屏
alert:
  source: log.level == "ERROR"
  group_by: ["error.fingerprint"]
  trigger:
    - count > 5 within 10m       # 同一指纹 10 分钟内 5 次
    - count > 0 and severity == "fatal"
  silence:
    - key: "known-issue-412"
      until: 2026-12-01          # 已知问题设到期,不设"永久静音"

4. INFO 的价值常被低估

INFO 应记录的内容 为什么
状态变化(订单从待付到已付) 事后追溯时间线
关键决策分支(走了 A 还是 B) 解释"为什么是这个结果"
外部交互的摘要(调了谁、结果类别) 归因外部依赖
配置生效值(启动时) 解释环境差异

级别与"采样"的关系也要明确:ERROR 不能采样,INFO 可以。如果采样策略把 ERROR 也按比例丢弃,那么在低概率故障上,日志会恰好在需要它的时候缺失。

第三项是排障时最省时间的:"这次请求依赖了谁、对方返回了什么类别",比完整的请求体更有用,也不涉敏。

// INFO 记事件与决策,不记详细数据
log.info("order.state {}", Map.of(
    "order", orderId, "from", "PENDING", "to", "PAID",
    "channel", channel, "amount", amount));     // 结构化字段,便于查询

5. DEBUG 的开关与成本

做法 成本
生产常开 DEBUG 日志量数倍,磁盘与检索成本上升
全局临时开 影响所有请求,噪音大
按请求/用户开(动态) 需要开关通道
按包/类开 折中,较常用

第三种是最实用的形态:遇到问题时只对某个请求开 DEBUG,其余保持 INFO。它需要一条"日志级别可动态调整"的通道,成本不高,收益很大。

// 动态开 DEBUG:按 trace 或用户临时打开,事后自动关闭
POST /admin/loglevel { "logger": "com.example.order", "level": "DEBUG",
                       "scope": { "trace_id": "9f2c..." }, "ttl": "30m" }
// 关键:必须带 ttl,否则会变成永久 DEBUG

6. 排错:五种"日志查不到想看的东西"

现象 成因 处理方向
全是 ERROR 找不到问题 预期内失败被记为 ERROR 降级为 INFO/WARN
关键决策没记录 只记了结果没记分支 补决策点日志
数量太多不敢开 DEBUG 无按请求开关 加动态级别
日志无法关联 缺 trace 字段 采集侧统一补齐
日志被截断 单条过长 记摘要 + 引用

排错时还有一个高效手法:统计各级别的日产量。如果 WARN 与 ERROR 的量级相当甚至更多,说明两者的语义已经混了;健康的分布通常是 ERROR 极少、WARN 少量、INFO 大量。

// 各级别日产量:判断语义是否已经混掉
zcat app-2026-11-10.log.gz | awk '{print $2}' | sort | uniq -c | sort -rn
// ERROR 与 WARN 数量接近 → 级别语义已混乱
// ERROR 为 0 且确有问题发生 → ERROR 被降级为 INFO,同样有问题

第三项常形成恶性循环:因为日志太多所以不敢开,因为不开所以只能靠猜,猜错又要重来。动态级别是打破这个循环唯一便宜的办法。

7. 边界:什么情况不必严格分级

场景 为什么 做法
一次性脚本 无告警消费方 随意
调试期代码 短期存在 用 print 也行
内部工具(无值班) 无人看告警 只保留 INFO
已有完整追踪系统 事件由追踪承载 日志只记异常

最后一条与成本有关:日志量是有预算的。当月产量超过预算时,最先该做的不是加磁盘,而是回头检查"哪些级别记了不该记的东西"——大多数日志膨胀来自 INFO/DEBUG 的记录范围失控,而不是真的发生了更多事件。

判断口径一句话:这个级别的日志有没有人消费。有告警或看板消费 ERROR,它就必须严格;没人消费的级别,规定得再细也只是形式。


8. 自测

  1. 五个级别的语义中,哪一条是核心约定?这条约定被破坏后为什么难以恢复?
  2. 五类常被误记为 ERROR 的情况里,哪一类在流式响应中最常见?为什么它是正常行为?
  3. 为什么"每个 ERROR 都告警"最终会导致告警失效?产出量应当与什么匹配?
  4. INFO 应当记录哪四类内容?其中哪一类最省排障时间?
  5. 动态调试级别为什么是打破"不敢开 DEBUG"循环的唯一便宜办法?它必须带什么?
  6. 判断"是否需要严格分级"的那个问题是什么?
S16-A28 · 日志级别的约定:什么该记、什么该丢
https://www.clxhxhhr.top/posts/6431/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。