Go 日志
从 log 到 log/slog 的结构化日志:级别、Handler、字段、与 context/trace 关联,以及生产环境脱敏、动态级别与性能边界。
[!info] 关联笔记
Go 日志
这个概念为什么出现
服务出了问题,你需要在海量并发请求里回答:
- 发生了什么、在哪个请求上
- 关键上下文是什么(user、order、latency)
- 能否被检索系统高效过滤
- 能否在不重启的情况下临时提高详细度
- 会不会因为日志本身拖垮延迟与 GC
老式 log.Printf 字符串拼接能救脚本,救不了生产。Go 1.21 起标准库 log/slog 把结构化、可级别过滤、可替换 Handler 变成默认路径;第三方 zerolog/zap 仍在极致性能与生态里占位。
[!abstract] 一句话理解 生产日志应结构化(键值字段 + 级别 + 时间 + msg),用可替换 Handler 输出 JSON/文本;通过 Logger.With/context 关联请求,并严禁写入密钥与未脱敏 PII。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:下单接口打可检索的结构化访问日志
订单服务启动时要写一条进程级启动日志;
每个 POST /orders 成功后,还要带着 request_id、order_id、耗时写一条业务日志,
方便在 Loki/ELK 里按请求串联排查。
slog 的 JSON Handler + Logger.With 就是把「固定字段」绑在请求域 Logger 上,避免每行手写拼接。
package main
import (
"context"
"log/slog"
"os"
"time"
)
// handleCreateOrder 模拟“创建订单成功后的日志侧写”。
//
// 业务意图:
// - 入口已有 request_id / route;
// - 落库成功后补 order_id 与 latency;
// - 字段必须是键值,不能是 printf 糊成一串。
//
// 教学点:
// - With 产生子 Logger,固定字段自动出现在后续每条日志;
// - Duration 等类型化 Attr 便于索引与聚合。
func handleCreateOrder(base *slog.Logger, requestID, orderID string) {
// 请求域 Logger:中间件解析出的 request_id / route 在此挂上。
reqLog := base.With(
slog.String("request_id", requestID),
slog.String("route", "POST /orders"),
)
start := time.Now()
// 假装业务:写库、发事件……这里只演示日志落点。
// 真实 handler 会在 return 前打成功/失败日志。
reqLog.Info("order created",
slog.String("order_id", orderID),
slog.Duration("latency", time.Since(start)),
)
}
func main() {
// 生产默认:JSON 到 stdout,由采集侧收走。
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, // 低于 Info 的 Debug 在 Handler 侧直接丢弃
})
logger := slog.New(handler)
// 可选:把全局默认也换成 slog,兼容 slog.Info 调用点。
slog.SetDefault(logger)
// 进程启动日志(无 request 域):端口、环境便于对照发布。
ctx := context.Background()
slog.InfoContext(ctx, "server starting",
slog.Int("port", 8080),
slog.String("env", "production"),
)
// 模拟一次下单请求完成。
handleCreateOrder(logger, "req-1", "o-456")
}
建议运行:
go run .
期望输出(时间戳与 latency 数值会变):
{"time":"...","level":"INFO","msg":"server starting","port":8080,"env":"production"}
{"time":"...","level":"INFO","msg":"order created","request_id":"req-1","route":"POST /orders","order_id":"o-456","latency":...}
结合场景再看三个关注点
-
JSON Handler 面向检索
下单日志要能按order_id/request_id过滤;字符串拼接不适合生产。 -
With= 请求域固定字段
中间件设一次,业务里只补 order_id、错误码等差异字段。 -
级别在 Handler 过滤
LevelInfo时 Debug 不序列化,热路径少付成本。
核心概念与准确模型
标准库 log 的位置
log.Println("something")
log.Printf("count=%d", n)
特点:全局、无级别 API、弱结构、格式固定。适合 CLI 工具与短生命周期程序。长期服务应迁到 slog 或等价库。
log/slog 模型
| 概念 | 作用 |
|---|---|
Logger | 门面:Info/Warn/Error/Log/With |
Handler | 真正输出:过滤级别、编码、写到 io.Writer |
Attr / slog.String 等 | 类型化字段 |
Level / LevelVar | 静态或运行时可变阈值 |
Record | 一条日志的时间、级别、消息、字段 |
内置 Handler:
TextHandler:人类可读JSONHandler:机器友好
自定义 Handler 可做:采样、脱敏、冗余字段剥离、桥接到 syslog/OTel。
级别
常见:Debug < Info < Warn < Error。
var level slog.LevelVar
level.Set(slog.LevelInfo)
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &level})
// 运维接口或信号里:level.Set(slog.LevelDebug)
原则:
- Info:默认审计级关键业务事实
- Warn:可恢复异常、降级
- Error:失败需关注
- Debug:诊断细节,默认关
避免用 Fatal/panic 当日志级别:log.Fatal 会 os.Exit,跳过 defer,破坏优雅关闭。
Context 集成
slog.InfoContext(ctx, "query done", slog.Int64("rows", n))
自定义 Handler 可从 ctx 提取 trace_id/span_id。中间件负责把 request id 放进 context 或 Logger.With。见 中间件、可观测性。
错误字段
if err != nil {
logger.Error("save failed", slog.Any("err", err))
// 或 slog.String("err", err.Error()) 视检索需求
}
包装错误(错误包装)让日志保留链路上下文;但对外响应仍要脱敏。
第三方库速览
| 库 | 特点 | 何时考虑 |
|---|---|---|
| slog | 标准库、零额外依赖、生态增长快 | 新项目默认 |
| zap | 高性能、生态成熟、API 双模式 | 已有 Uber 栈/极致分配控制 |
| zerolog | 零分配链式 API | 超高 QPS 且团队接受其风格 |
桥接:可用适配器把旧 log 或第三方输出接到 slog,逐步迁移。
设计动机
- 可查询性
字段化后才能按order_id/code过滤,而不是正则啃字符串。 - 关联性
同一request_id/trace_id串起跨服务日志。 - 成本可控
级别与采样避免“全量 Debug”烧盘与烧 CPU。 - 与 Go 显式风格一致
不鼓励隐式全局魔法;但提供SetDefault降低改造成本。
边界情况与反直觉行为
1. 默认 Logger 与测试
测试中 SetDefault 会影响进程全局;并行测试需隔离或注入 Logger。
2. 高基数标签
把 user_id 当指标标签会爆炸;日志字段可以有高基数,但检索与保留策略要设计。指标与日志职责不同。
3. 结构化不是“把整对象塞进去”
slog.Any("req", bigStruct) 可能深反射、循环引用、泄露密码字段。显式白名单字段。
4. 异步日志丢数据
有些封装异步写盘,进程崩溃可能丢尾部日志。关键路径要理解是否同步 flush。
5. 多 Writer
同时写 stdout 与文件时,注意多写失败语义与阻塞;容器环境常只打 stdout,交给采集层。
常见误区
[!warning] 常见误区:字符串拼接当结构 错误:
fmt.Sprintf("user=%s order=%s", u, o)。
正确:独立 Attr,便于索引与类型一致。
[!warning] 常见误区:密码/Token 进日志 错误:
Authorization头原样打印。
正确:中间件脱敏;Handler 层黑名单。
[!warning] 常见误区:到处
log.Fatal错误:初始化失败直接 Exit,跳过资源清理。
正确:return err到main,统一退出码与清理。
[!warning] 常见误区:Info 刷屏 错误:每条循环/每字节一条 Info。
正确:聚合、采样、Debug 下沉、指标补量。
[!warning] 常见误区:无关联 ID 错误:全是“save failed”无法对齐请求。
正确:请求入口注入request_id/trace_id。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| 指标 metrics | 聚合数值;低基数;告警主通道之一 |
| 追踪 tracing | 跨服务跨度与时序;日志用 id 关联 |
| 审计 audit | 合规级不可抵赖记录;常独立管道 |
fmt 输出 | 给人看的临时打印,不是运维数据面 |
工程实践
- 新项目默认 slog + JSON(生产)/ Text(本地)。
- Logger 依赖注入:结构体字段或参数传递;请求级
With。 - 统一字段字典:
request_id、peer、error_code命名一致。 - 错误日志带操作名:
msg稳定可聚合,细节放字段。 - 动态级别:
LevelVar+ 受控 admin 接口(务必鉴权)。 - 与中间件配合:记录 method/path/status/latency,不在每个业务里复制。
- 测试:对 Handler 用
bytes.Buffer断言关键字段存在。 - 性能:热路径避免昂贵字段计算;Debug 字段可用惰性或先
Enabled判断。
if logger.Enabled(ctx, slog.LevelDebug) {
logger.Debug("payload", slog.String("body", debugDump(b)))
}
可验证实验
实验 1:JSON 输出
运行最小示例,确认一行一个 JSON 对象且含 level/msg。
实验 2:级别过滤
Level=Info 时 Debug 不应出现;改为 Debug 后出现。
实验 3:With 继承
子 Logger 打日志应同时含父字段与新字段。
实验 4:LevelVar
运行中修改 LevelVar,观察后续日志详细度变化。
实验 5:脱敏
写一个包装 Handler,drop/replace password 键,用单测验证。
本节总结
- 本质:结构化事件流,不是
printf日记。 - 标准答案:
log/slog+ 合适 Handler + 请求关联字段。 - 红线:无密钥、无滥用 Fatal、无无界刷盘。
- 下一步:可观测性 把日志与指标/追踪对齐;配置 管理级别与输出。
自测题
概念题
Logger与Handler如何分工?- 为什么生产更常选 JSONHandler?
log.Fatal的主要工程危害是什么?
代码推理题
l := slog.Default().With("k", 1)
l = l.With("k", 2)
l.Info("x")
最终记录里 k 通常以哪个为准?设计上应否重复同名键?
工程思考题
QPS 5 万的服务要把每个请求 body 打 Debug。有什么风险?如何改?
参考答案
展开
- Logger 提供调用 API 与字段叠加;Handler 负责级别过滤、编码与输出。
- 便于集中采集、索引字段查询、与告警流水线集成。
os.Exit跳过 defer,资源与在途请求无法优雅收尾。
代码题:后写字段覆盖/并存行为依赖 Handler 对重复键处理;工程上应避免重复键,保持字段字典干净。
工程题:体积极大、含 PII、CPU/IO 打满;应默认关闭、采样、截断、哈希、仅失败请求记录、或进专用审计通道。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package log/slog | 标准库文档 | Logger/Handler/Level |
| Structured Logging with slog | 官方博客 | 设计与用法 |
| Package log | 标准库文档 | 旧 API 边界 |
| OpenTelemetry Logs concepts | 外部规范 | 与可观测性对齐(扩展) |