不能用全局 logger 注入 trace_id,因为全局 slog.logger 或 zap.logger 初始化后无法感知请求的 context.context,导致不同请求日志混杂、trace_id 为空或固定,链路断裂;根本原因是 go 无 tls,context 不自动跨 goroutine 传递,必须在 logger 构造或调用时绑定当前 ctx。

为什么不能用全局 logger 注入 trace_id
全局 *slog.Logger 或 *zap.Logger 实例一旦初始化,就无法感知当前请求的 context.Context,所有日志都会共享同一组静态字段。结果是:不同请求的日志混在一起,trace_id 字段要么为空、要么固定为启动时的值,链路完全断裂。
常见错误现象包括:
- 并发压测时多条请求日志都显示同一个
trace_id - 中间件打了一条日志,service 层打了一条,但字段对不上、甚至字段缺失
- goroutine 中调用 DB 后打日志,
trace_id变成<nil></nil>
根本原因在于:Go 没有线程局部存储(TLS),context 不会自动跨 goroutine 传递,更不会“附着”在 logger 实例上。必须让 logger 的构造或调用时刻绑定到当前 ctx。
如何用 context.WithValue 安全注入 trace_id
直接用字符串做 key 是高危操作:ctx = context.WithValue(ctx, "trace_id", id) 会导致 key 冲突 —— 其他库也可能用相同字符串,覆盖或读错值。
正确做法是定义私有类型作为 key:
type ctxKey string const traceIDKey ctxKey = "trace_id" // 使用时 ctx = context.WithValue(r.Context(), traceIDKey, reqID)
下游提取也必须用同一 key:
if id, ok := ctx.Value(traceIDKey).(string); ok {
logger = logger.With("trace_id", id)
}
关键点:
- key 类型必须是未导出的私有类型(如
ctxKey),避免包间冲突 - 不要在 handler 里临时生成
trace_id——应由网关或最外层中间件统一生成并注入 - HTTP 中间件中务必用
r.WithContext(ctx)替换 request,否则后续 handler 拿不到新 context
gRPC 调用中 trace_id 透传失败的三个典型原因
HTTP 到 gRPC 或 gRPC 到 gRPC 的链路断裂,90% 出现在 metadata 透传环节。不是“写了就传出去”,而是每一步都可能漏掉。
客户端常见错误:
- 只调用
metadata.Pairs("trace-id", id),但没用metadata.AppendToOutgoingContext(ctx, pairs)把它真正挂到 outgoing context 上 - 用
grpc.Dial()创建 client 时没加grpc.WithUnaryInterceptor(),导致拦截器根本不生效 - header 名用了下划线(如
X-Trace_ID)——gRPC metadata 不支持下划线和空格,必须用连字符trace-id
服务端常见错误:
- 在
UnaryServerInterceptor中从metadata.FromIncomingContext(ctx)读出了trace-id,但没用context.WithValue()注入回新 context 就直接handler(ctx, req) - 拦截器返回了新 context,但 handler 函数签名没接收或忽略它(如写成
func(ctx context.Context, req interface{}) (interface{}, error)却没实际传入)
zap 日志自动带 trace_id 的最小可行封装
zap 本身不读 context,所以不能指望“配置一下就自动生效”。最轻量、可控的方式是封装一个函数,在每次需要 logger 时动态提取:
func LoggerFromCtx(ctx context.Context) *zap.Logger {
if id, ok := ctx.Value(traceIDKey).(string); ok {
return zap.L().With(zap.String("trace_id", id))
}
return zap.L()
}
// 使用
logger := LoggerFromCtx(r.Context())
logger.Info("handling request")
这个方案比“自定义 Core + hook”更简单,也比“全局 logger.With()”更安全。但它有个容易被忽略的边界:
- 如果业务逻辑里启了新 goroutine(比如异步发消息),必须显式把
ctx传进去,再调用LoggerFromCtx(ctx)—— 不能只传 logger 实例 - DB 查询、HTTP client 调用等下游操作,也要确保它们使用的 logger 是基于当前
ctx构造的,而不是复用上层的 logger - 别在
init()或包级变量里提前构造 logger,那会锁死 context 生命周期
真正的难点不在“怎么加字段”,而在于确保每个日志输出点,背后都有一条完整的、未被截断的 context 传递链。任何一处用了 context.Background() 或忘了传 ctx,trace_id 就断了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











