一次线上问题排查,团队按 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. 自测
- 五个级别的语义中,哪一条是核心约定?这条约定被破坏后为什么难以恢复?
- 五类常被误记为 ERROR 的情况里,哪一类在流式响应中最常见?为什么它是正常行为?
- 为什么"每个 ERROR 都告警"最终会导致告警失效?产出量应当与什么匹配?
- INFO 应当记录哪四类内容?其中哪一类最省排障时间?
- 动态调试级别为什么是打破"不敢开 DEBUG"循环的唯一便宜办法?它必须带什么?
- 判断"是否需要严格分级"的那个问题是什么?