日志体系:slog 结构化日志与请求全链路 traceId
一句话结论:日志的目标是“出问题能按一次请求把所有相关日志捞出来”。用 Go 1.21+ 标准库 log/slog 输出 JSON 结构化日志(键值对,机器可解析、可采集);入口中间件为每个请求生成/透传 traceId(上游有 X-Request-Id 就沿用,没有就生成 UUID),放进响应头并注入 context;每层日志带上 traceId、method、path、status、latency、user_id 等固定字段,跨服务时把 traceId 放 HTTP header/gRPC metadata 传给下游,形成全链路。纪律:不记密码/token/完整卡号等敏感字段,错误要带关键上下文与 requestId。
初始化与请求日志中间件
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, // 开发可切 Debug
})))
func TraceID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-Id")
if id == "" { id = uuid.NewString() }
c.Set("request_id", id)
c.Header("X-Request-Id", id) // 返回给客户端便于反馈排障
start := time.Now()
c.Next() // 处理完打访问日志
slog.Info("http_access",
"request_id", id,
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"latency_ms", time.Since(start).Milliseconds(),
"client_ip", c.ClientIP(),
)
}
}
业务日志带 traceId
slog 标准库不会自动从 context 取值,常规做法是请求级 logger 用 With 注入后放进 context 使用:
lg := slog.Default().With("request_id", id, "user_id", uid)
ctx := context.WithValue(c.Request.Context(), ctxKeyLogger{}, lg)
lg.Info("order_created", "order_id", oid) // 一条日志串起请求
// 或:所有日志入口收敛到一个 helper:GetLogger(ctx).Info(...)
体系配套
- 输出 stdout + JSON:采集端(Loki/ELK)直接消费,应用层不写文件、不轮转,交给部署层。
- 固定字段约定:request_id/trace_id、user_id、接口、耗时、状态;敏感字段一律脱敏或禁止。
- 错误日志:记录 err 与最小必要上下文(如 order_id),不带堆栈刷屏;panic 由自定义 Recovery 打全栈并关联 request_id。
- 全链路:单体内靠 context 传递;跨服务把 traceId 放进出站请求头;接 OpenTelemetry 可升级为标准 trace/span。
- 级别与采样:生产 Info 起步,Debug 开关按需;高 QPS 日志可采样降噪。
追问记忆点
- 追问:为什么用 slog 而不是自己拼字符串?——结构化=键值对可检索可聚合(按 traceId 拉全链路、按 status 聚合);文本日志只能 grep 靠运气。
- 追问:slog 为什么不自动打 traceId?——标准库 Handler 拿不到 context 值,需要 With 注入或自实现支持 ctx 的 Handler/封装。
- 追问:访问日志放哪个中间件位置?——最先注册(外层),c.Next() 前后分别取开始时间与状态码,能覆盖整条链。
- 记忆点:JSON + stdout;入口生成/透传 request_id 并回写响应头;日志经 context 带 traceId;敏感字段不落盘。