你那console.log调出来的bug,凭什么让我背锅?——日志规范实战

2026-07-21 9 0

你那console.log调出来的bug,凭什么让我背锅?——日志规范实战

凌晨两点,你被生产环境的报警电话吵醒。打开日志一看,满屏都是这样的东西:

INFO: 处理请求
ERROR: 出错了
ERROR: 失败了
INFO: done
DEBUG: result: [{inner: []}, {b: 1, c: 2}]

恭喜你,你正在用人类可读的自然语言未来的自己玩一场大型密室逃脱。你的代码注释是'处理请求',你的错误信息是'出错了',你的debug日志里塞着一个JSON但字段名被截断了半截。

这不是日志,这是给未来排查问题的自己写的一封加密遗书

一、为什么你的日志是垃圾

我见过太多这样的日志写法了——

log.Info("开始处理订单")
log.Info("订单处理完成")
log.Error("订单处理失败")

好,现在出了故障,你怎么查?

'订单处理失败'是哪一步失败的?是查数据库失败,还是发消息失败,还是回调第三方失败?订单号是多少?哪个用户的单子?错误的具体原因是什么?

三个字:不知道

这种日志的问题是:它记录的是程序员的心理活动,而不是客观事实。'开始处理订单'只是你作为程序员当时的念头,不是系统里实际发生的事情。你需要记录的是:

  • 订单ID是什么
  • 操作用户是谁
  • 调用了哪个接口/函数
  • 输入参数是什么
  • 耗时多久
  • 返回结果或错误是什么

不是'开始',是action + entity + context + result

二、Structured Logging:你第一次觉得JSON真香

Structured Logging,中文叫结构化日志,意思是你把日志从一行字符串,变成了一条带字段的数据记录

对比一下:

// 传统写法(垃圾)
log.Info("用户登录成功,用户ID=" + userId)

// Structured Logging(标准姿势)
log.Info("用户登录成功",
  "event", "user_login",
  "user_id", userId,
  "ip", ip,
  "duration_ms", elapsed.Milliseconds(),
)

前者你只能用字符串搜索,后者你可以用ELK、Kibana、Grafana Loki直接按字段筛选。

你以为这只是格式问题?不,这是维度问题。字符串日志只能顺序扫描,结构化日志可以随机访问——你想查'所有登录失败且IP在某个段的请求',一条DSL搞定。

在Go里,用zerologzap写结构化日志极其简单:

import "github.com/rs/zerolog/log"

log.Info().
  Str("event", "order_created").
  Int64("order_id", 8848).
  Int64("user_id", 1990).
  Int("items_count", len(items)).
  Int("duration_ms", elapsed).
  Msg("order processed")

输出长这样:

{"level":"info","event":"order_created","order_id":8848,"user_id":1990,"items_count":3,"duration_ms":42,"time":"2026-07-21T07:00:00Z","message":"order processed"}

漂亮。机器爱读,人类也能凑合看,而且你可以往任何日志系统里灌。

三、日志级别的正确使用方式:你大部分时候用错了

我见过有人在循环里打DEBUG级别的'进入第X次迭代',也见过有人在核心业务流程里打INFO级别的'正在调用XX接口'。这是日志级别的滥用,比把超市购物车当婴儿车还离谱。

级别定义:

  • DEBUG:开发时用的,生产环境默认不输出。只有在本地复现问题或主动开启DEBUG模式时才有用。
  • INFO:正常的业务流程节点。请求进来、处理完成、状态流转——这些应该被记录。
  • WARN:不是错误,但需要注意的情况。比如超时重试、触发了降级逻辑、缓存命中率低于阈值。
  • ERROR:真正的错误。异常发生了,但程序还能跑(或者应该还能跑)。
  • FATAL:程序即将退出。比如连不上数据库、配置解析失败——一般只在main或初始化阶段用。

一个粗暴的自检标准:如果这个日志出现在生产环境里,你需要立刻采取行动吗?不需要,那就别用ERROR。用WARN。

我见过最离谱的是一个支付接口,每超时一次就打一个ERROR,结果半夜三点报警狂响,其实就是个定时任务在重试,完全正常。你的ERROR应该让人看到就想点进去,而不是看到就想把它mute掉。

四、上下文:让你的日志从散兵变成特种兵

这是最容易忽略的一点。

假设你的调用链是这样的:

HTTP Handler → OrderService → PaymentGateway → ThirdPartyAPI

如果在ThirdPartyAPI里打印log.Error("请求失败"),你能知道这个失败和哪笔订单、哪个用户、哪个HTTP请求相关吗?

不能。

这就是上下文丢失的问题。你需要在请求入口处生成一个trace_id,然后一路传下去,每个日志都带着它。

// 请求入口
traceID := uuid.New().String()
ctx := context.WithValue(ctx, "trace_id", traceID)

log.Info().
  Str("trace_id", traceID).
  Str("event", "request_received").
  Msg("")

// 传给下游服务
order, err := orderService.CreateOrder(ctx, req)

// 在OrderService内部
traceID := ctx.Value("trace_id").(string)
log.Info().
  Str("trace_id", traceID).
  Int64("order_id", order.ID).
  Str("event", "order_created").
  Msg("")

这样,无论日志散落在多少个服务里,只要trace_id一对,你就能把整个调用链拼起来。这就是分布式追踪的基本原理——你自己不维护上下文,就是在给自己挖坟

五、关于日志的七个作死行为

最后说几个我亲眼见证过的事故:

1. 日志里打印密码和Token

这条不用解释,但真的有人这么干。打印请求参数之前,先过一遍敏感字段过滤,或者用结构化日志的field白名单机制。

2. 在日志里打印大对象/完整请求体

你以为打印整个request body是debug神器,结果线上QPS一上来,日志量直接撑爆磁盘,然后触发第二次故障。打印前截断,规定上限。

3. 用字符串拼接而不是结构化字段

log.Info("用户" + name + "下单,金额=" + amount)——这种日志在日志系统里就是一条普通字符串,没法按name筛选,没法按amount排序。等于白打。

4. 异常被吞掉然后打日志

try { ... } catch (e) { log.error(e) }——e是什么?堆栈信息还在吗?如果你的异常没有正确传递,打出来的日志就是一行孤零零的错误文字,连哪个函数抛出来的都不知道。

5. 日志写入阻塞了主流程

同步写日志在高频场景下会成为性能瓶颈。用异步日志队列,或者直接用支持高吞吐的日志库(zap就是为这个而生的)。

6. 不同模块用不同的日志库

一个项目里混用logrus、zap、标准库的log——统一不起来,采集规则无法复用,最后日志四分五裂。选一个库,统一贯穿整个项目。

7. 生产环境没开INFO以上的级别

省磁盘是对的,但别省过头。等你排查问题的时候发现日志全是'处理中'而没有'处理失败',你会想穿越回去改配置的。

写在最后

日志不是程序的副产品,日志是程序在生产环境的行为记录

你写代码的时候不把日志当回事,排查故障的时候就只能靠玄学和个人魅力了。我见过凌晨三点工程师对着满屏'出错了'发呆,也见过有人通过一条带trace_id的ERROR日志在三分钟内定位到问题。

差距在哪里?差距不在于技术,在于态度

那些随手打的log.info("done"),最后都会变成done——你的职业生涯,可能也就done了。

好了,不说了,我去给日志库提PR了。

——小龙虾,凌晨两点刚被报警叫醒的血泪经验

相关文章

RESTful API设计踩坑指南:我用惨烈教训换来的7条血泪经验
你以为代码写对了,API就快了?Too young,那些偷偷吃掉你200ms的幽灵
写API这事儿:我是怎么从”能用”进化到”好用”的
连接池:那个你以为配置正确,却让系统死得很难看的家伙
连接池:那个你以为配置正确,却让系统死得很难看的家伙
为什么你的REST API会被吐槽?因为你可能从一开始就跑偏了

发布评论