日志级别乱打,线上排查像大海捞针


日志级别乱打,线上排查像大海捞针

这是《Go工程踩坑实录》第2期。上一期聊了数据库连接池,本期说说另一个让我翻遍10G日志的坑。


线上服务报错,用户反馈下单失败。我打开日志平台,检索 level=error,结果跳出来 5000+ 条。

翻了 10 页,发现里面混着这些:

  • "error: user not found" —— 用户没注册,正常情况
  • "error: retry failed" —— 重试机制打的,最后成功了
  • "error: connection reset by peer" —— 网络抖动,自动恢复了

真正的根因呢?一条被埋在 Info 里的 "upstream circuit breaker tripped"。我花了 40 分钟才找到。

不是日志打少了,是噪音淹没了信号


01 40分钟,我翻完了10G日志

背景是一个支付回调服务,偶发订单状态不同步。

症状很典型:

  • 日志量 10G/天,Error 级别日志 5000+ 条
  • 告警群每天被 Error 刷屏,但 90% 是"假错误"
  • 真正的故障信号被淹没,告警疲劳导致大家习惯性忽略

排查链路是这样的:

先看 Error 日志。5000 多条,翻了几页发现全是"用户不存在"“重试失败"“网络抖动”——这些要么是业务正常分支,要么是自动恢复的场景。Error 级别被滥用成了垃圾桶。

再看 Warn。混合了"需要注意"和"可以忽略”,筛选成本极高。

最后翻 Info。翻了 20 多分钟,发现一条 "upstream circuit breaker tripped"(下游服务熔断)被当成 Info 打了——这才是根因。第三方回调服务熔断,我们自己的服务没挂,但所有通知都被静默丢弃了。这行致命的信号被夹在"请求接收"和"请求处理完成"的两千万行 Info 之间。

根因

  • 团队没有统一的日志级别规范
  • Error 被滥用:正常分支、预期异常、重试中间态全打 Error
  • Info 被当成垃圾桶:不知道放哪的就丢 Info
  • 没有结构化字段,纯文本检索效率极低

【配图建议:日志平台截图】Error 级别 5000+ 条,翻页翻到手酸,真正的报错混在中间


02 5个级别,不是5个垃圾桶

日志级别不是摆设,它直接决定了:

  1. 这条日志会不会触发告警
  2. 告警会不会把你从床上叫起来
  3. 排查时能不能快速过滤掉噪音

我把 5 个级别重新翻译一下——不是它们的技术定义,而是你在生产环境里的真实后果


Fatal —— 服务活不下去了

什么时候用:进程无法继续运行,必须立即退出。

典型场景:配置文件缺失、端口被占用且无法自动切换、数据库连接失败且没有降级方案。

致命陷阱

  • log.Fatal 会调用 os.Exit(1),defer 不执行,资源不释放
  • 库代码里打 Fatal → 调用方服务被直接杀死

原则业务代码尽量不用 Fatal,用 Error + 优雅降级。只有启动期初始化失败才用 Fatal。在容器化环境(K8s)中,启动期 Fatal 会触发 Pod 重启,有时能解决临时性网络抖动或配置同步问题;但运行期连不上 DB 应该打 Error 并触发降级/熔断,而不是让进程自杀。

// Bad: 运行期直接自杀
if err != nil {
    slog.Error("order process fatal", "order_id", id, "err", err)
    os.Exit(1) // 整个服务挂了,defer不执行
}

// Good:记录错误,返回失败,服务继续跑
if err != nil {
    slog.Error("order process failed", "order_id", id, "err", err)
    return fmt.Errorf("process order %s: %w", id, err)
}

Error —— 需要人来看的异常

什么时候用:发生了预期外的异常,需要运维或开发介入。

判断标准:如果这条日志触发告警,你会不会半夜爬起来处理?不会 → 别打 Error。

常见滥用

  • 用户输入校验失败 → 这是业务正常分支,打 Warn 或 Info
  • 第三方 API 返回 404 → 要看场景,如果是"资源不存在"是预期内,打 Warn;如果是"服务挂了"才是 Error
  • 重试失败的中间态 → 最后一次重试失败再打 Error,中间过程打 Warn
// 错:正常业务分支打 Error
if user == nil {
    slog.Error("user not found", "uid", uid) // 用户没注册很正常
    return ErrUserNotFound
}

// 对:预期内的分支,用Warn
if user == nil {
    slog.Warn("user not found, redirect to register", "uid", uid)
    return ErrUserNotFound
}

Warn —— 需要注意,但不用立即处理

什么时候用:发生了预期内的异常非关键路径失败,需要关注趋势但不紧急。

典型场景

  • 请求重试(第1次失败,还有2次机会)
  • 缓存未命中(降级查数据库,性能下降但功能正常)
  • 参数缺失使用默认值
  • 限流触发(部分请求被拒绝,服务整体健康)

价值:Warn 是"预警"。如果 Warn 量突然飙升,说明有恶化趋势,可以提前介入避免变成 Error。


Info —— 关键路径的里程碑

什么时候用:记录业务关键节点的状态变化,用于事后追溯流程。

典型场景

  • 请求接收(带 trace_id、user_id)
  • 状态机跳转(订单从 “待支付” → “已支付”)
  • 外部调用发起到完成
  • 定时任务开始和结束

原则

  • 不是每行代码都配 Info,只记录"如果事后复盘,这条日志能不能帮我还原流程"
  • 带上下文:谁、在什么时间、做了什么、结果是什么
// 好的 Info:能还原完整流程
slog.Info("order paid",
    "trace_id", traceID,
    "order_id", order.ID,
    "user_id", order.UserID,
    "amount", order.Amount,
    "gateway", "alipay",
    "duration_ms", time.Since(start).Milliseconds(),
)

// 差的 Info:不知道在说什么
slog.Info("process done") // 什么process?什么结果?

Debug —— 开发期显微镜

什么时候用:开发调试阶段,记录详细中间态。

原则

  • 线上默认关闭,通过开关动态开启(如按用户ID采样)
  • 可以打很细:函数入参、中间变量、循环迭代状态
  • 不考虑性能(因为线上不开)

级别速查表

级别 核心问题 告警 示例
Fatal 服务能活吗? 电话叫醒 数据库连不上且无法降级
Error 需要现在修吗? 立即告警 支付回调签名校验失败
Warn 趋势恶化吗? 趋势监控 缓存未命中、重试第1次失败
Info 能还原流程吗? 不告警 订单状态跳转、请求完成
Debug 开发调试用 不告警 函数入参、SQL语句

金句:下次写 slog.Error 前,停一秒问问自己:这条日志如果半夜吵醒我,我会不会觉得它值得?


03 别再打 "user %s login failed"

为什么结构化?

文本日志的问题:

  • "user alice login failed" → 想按用户统计失败次数?正则提取,慢且容易错
  • "login failed, user=alice, ip=10.0.0.1" → 格式不统一,有的用 = 有的用 :
  • "2024-01-15 login failed [alice] [10.0.0.1]" → 时间格式各异,解析成本高

结构化日志(JSON/Key-Value):

{
  "time": "2024-01-15T09:23:01Z",
  "level": "ERROR",
  "msg": "login failed",
  "user_id": "alice",
  "ip": "10.0.0.1",
  "reason": "password_mismatch",
  "trace_id": "abc123"
}

可以直接用日志平台(ELK/Loki)的字段过滤:user_id=alice AND reason=password_mismatch


Go 1.21+ 的 log/slog

Go 官方终于给了标准的结构化日志方案:

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

// 结构化字段,不是格式化字符串
logger.Info("order created",
    slog.String("order_id", order.ID),
    slog.Int("amount", order.Amount),
    slog.Duration("latency", latency),
)

输出:

{"time":"2024-01-15T09:23:01Z","level":"INFO","msg":"order created","order_id":"ORD-123","amount":19900,"latency":15000000}

对比 zap/logrus:

  • slog 是标准库,零依赖
  • 性能接近 zap(足够快)
  • 支持 Handler 自定义,可以对接日志平台

高性能提示:在高并发场景下,直接传 any 类型的 Key-Value 会有类型转换开销。推荐用强类型方法,或一次性传 slog.Attr 切片:

// 推荐:强类型方法,减少 any 转换
logger.Info("order created",
    slog.String("order_id", order.ID),
    slog.Int("amount", order.Amount),
    slog.Int64("duration_ms", latency.Milliseconds()),
)

// 更高性能:LogAttrs 减少内存分配
logger.LogAttrs(ctx, slog.LevelInfo, "order created",
    slog.String("order_id", order.ID),
    slog.Int("amount", order.Amount),
)

必须带上的字段

每条日志建议带:

  • time:ISO8601 格式,带时区
  • level:级别
  • msg:简短描述(不是长句子)
  • trace_id:分布式追踪ID,串联请求全链路
  • service:服务名
  • env:环境(prod/staging/dev)

业务日志按需加:

  • user_idorder_idrequest_id
  • duration_ms:耗时
  • status:结果状态(success/failure)

上下文传递的坑

很多新手会在每行日志里手动加 trace_id

// Bad:每一行都要写 trace_id,容易漏
slog.Info("order created", "trace_id", traceID, "order_id", order.ID)
slog.Info("payment started", "trace_id", traceID, "order_id", order.ID)

正确的做法是用 slog.With() 预绑定公共字段,或通过中间件把 logger 注入 context.Context

// Good:预绑定公共字段
reqLogger := slog.With("trace_id", traceID, "user_id", userID)
reqLogger.Info("order created", "order_id", order.ID)
reqLogger.Info("payment started", "gateway", "alipay")

// 更好:通过 context 传递(中间件注入)
// 注:slog 标准库并未直接内置 Context 存取 Logger 的方法,建议自行封装:
//   ctx = context.WithValue(ctx, slogKey, reqLogger)
//   logger := ctx.Value(slogKey).(*slog.Logger)
// 这样 slog.FromContext(ctx).Info(...) 自动带 trace_id 和 user_id

04 日志不是打得越多越好

1. 日志的性能代价

每次日志调用都有:格式化 → 锁竞争(部分库)→ IO(落盘/网络发送)。

高并发下,同步日志会成为瓶颈。原则:线上 Info 级别日志量应该与流量成正比,建议控制在单请求 1-3 条关键路径日志的密度。日订单百万级的系统,全量打日志不现实——该采样就采样,别用"全量日志"掩盖"不敢做取舍"。

2. 异步与缓冲

// 使用 Buffered Writer 减少系统调用
writer := bufio.NewWriterSize(os.Stdout, 256*1024)
handler := slog.NewJSONHandler(writer, nil)
logger := slog.New(handler)

或直接用支持异步的第三方库(如 zap 的 zapcore.NewCore + zapcore.BufferedWriteSyncer)。

3. 采样策略——Debug日志线上怎么开?

线上全开 Debug = 自杀。策略:

  • 按级别采样:Error 100% 保留,Info 100%,Debug 1% 或按用户ID白名单
  • 按用户采样:VIP 用户或问题用户的请求全量打 Debug
  • 按流量采样:只有 0.1% 的请求打详细日志
// 自定义 Handler:只对特定用户打 Debug
func (h *SampleHandler) Handle(ctx context.Context, r slog.Record) error {
    if r.Level == slog.LevelDebug {
        userID, ok := ctx.Value("user_id").(string)
        if !ok || !isVIP(userID) {
            return nil // 非VIP或类型不匹配,丢弃Debug
        }
    }
    return h.next.Handle(ctx, r)
}

4. 敏感信息脱敏

日志里绝对不能出现:

  • 密码、Token、信用卡号
  • 手机号、身份证号(或脱敏后打)
  • 个人隐私数据
// 错:直接把敏感信息打到日志
slog.Info("user login", "password", req.Password)

// 对:脱敏或干脆不打
slog.Info("user login", "user_id", userID, "masked_phone", maskPhone(phone))

05 怎么让全团队按规范打日志?

1. Code Review 清单

  • Error 级别日志是否真的是异常?
  • 是否带了 trace_id 和关键业务ID?
  • 是否有敏感信息泄露?
  • Info 日志是否能还原业务流程?
  • Debug 日志是否可能被误开到线上?

2. Lint 规则(可自动化)

// 禁止在库代码里使用 log.Fatal
// 禁止在业务逻辑里打印裸字符串(必须用结构化字段)
// 禁止日志里出现 password、token、secret 等关键字

3. 告警分级

日志级别 告警策略 响应时效
Fatal 电话/短信 立即
Error 钉钉/企业微信 5分钟内
Warn 邮件/日报 当天内关注趋势
Info 不告警 事后追溯

告警疲劳的解法:单次 Error 不一定叫醒人,但 1 分钟内出现 10 次同样的 Error 必须叫醒。这种"频率阈值告警"比"单条级别告警"更可靠——既能避免噪音,又不会漏掉批量故障。很多团队不敢把 Error 阈值设高,结果告警群变成垃圾堆,真正的问题反而没人看。


06 面试官爱问的2道题

Q1:线上服务日志量太大,怎么优化?

参考答案:

  1. 分级采样:Error 100% 保留,Info 按需采样,Debug 线上关闭或按用户白名单
  2. 异步写入:用 Buffered Writer 或专门的日志 agent(如 Fluentd),避免阻塞业务
  3. 结构化+字段过滤:用 JSON 格式,日志平台只索引关键字段,减少存储
  4. 日志聚合:相同错误合并,只保留计数和首次/末次时间
  5. 冷热分离:近期日志放 SSD,历史日志压缩后放对象存储

Q2:为什么 Go 1.21 引入 log/slog?和 zap 有什么区别?

参考答案:

  • slog 是标准库,解决 Go 生态长期没有官方结构化日志方案的问题
  • 设计目标:统一接口,降低库之间的日志依赖冲突(A库用 zap,B库用 logrus,项目里一堆日志库)
  • 性能:slog 接近 zap,比 logrus 快很多
  • 迁移路径:已有 zap 的项目可以用 zapslog 适配器逐步迁移
  • 核心优势:Handler 接口灵活,可以自定义输出格式、过滤、采样,对接各种日志平台

记住这张表

级别 核心问题 该不该告警
Fatal 服务能活吗? 电话叫醒
Error 需要现在修吗? 立即告警
Warn 趋势恶化吗? 监控趋势
Info 能还原流程吗? 不告警
Debug 开发调试用 线上关闭

日志是服务的"黑匣子"。打得好,10秒定位问题;打得乱,40分钟翻垃圾。

下次写 slog.Error 前,停一秒问问自己:这条日志如果半夜吵醒我,我会不会觉得它值得?

现在可以做的3件事

  1. 打开你项目的最新一条 Error 日志,判断它是否真的需要告警
  2. 检查是否有纯文本日志,规划迁移到结构化(log/slog)
  3. 在 Code Review 清单里加上"日志级别检查"项

你们项目有日志规范吗?Error 日志一天有多少条?评论区见。

下期预告:《接口设计——RESTful vs RPC,版本兼容性血案


本文完。如果对你有用,点个「在看」等于告诉微信"这货值得推给别人"。

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo