如何在 Go 中实现全局唯一的 Request ID

为什么不能直接用 uuid.New() 做 Request ID?
直接在 HTTP handler 里调用 uuid.New(),生成一个唯一 ID 当然没问题。但问题出在哪呢?它和整个请求的生命周期脱钩了。这意味着,你的中间件、日志记录器、下游服务调用,全都拿不到这个 ID。除非你手动把它一层层传递下去——这既违背了 Go 语言崇尚的简洁哲学,也极易在复杂的调用链中漏传或传错。
说到底,我们需要的是一种“请求上下文绑定的、可无缝透传的、无感注入”的标识符。Go 语言内置的 context.Context 正是为此而设计的。不过,这里有个关键:必须通过中间件来统一注入。否则,每个 handler 都得重复写一遍 ctx = context.WithValue(ctx, key, id),代码不仅啰嗦,维护起来也是个隐患。
用中间件 + context.WithValue 统一注入
最轻量、也最可控的方案是什么?在请求入口的中间件里生成 ID,然后把它塞进 context.Context。这样一来,后续所有的 handler、业务逻辑、数据库调用,只要拿到 req.Context(),就能轻松取出这个 ID。
- 生成 ID:推荐使用
uuid.NewString()(Go 1.20+ 版本),它比传统的uuid.New().String()少一次内存分配,性能更优。 - 定义 Key:为了避免使用字符串作为 key 可能引发的包间冲突,最好定义一个全局的、非导出的空结构体类型:
type requestIDKey struct{} - 注入上下文:在中间件里,注意不要直接用
http.Request.WithContext()覆盖。正确的姿势是:req = req.WithContext(context.WithValue(req.Context(), requestIDKey{}, id)) - 安全取值:下游在取值时,务必进行类型断言,并检查值是否为 nil:
if id, ok := req.Context().Value(requestIDKey{}).(string); ok { ... }
如何让日志自动带上 Request ID?
标准库的 log 包并不支持上下文,所以别指望它能自动读取 context.Value。你需要的是一个能从 context.Context 中提取 ID 的日志封装层,或者直接改用支持上下文的日志库,比如 zerolog 或 zap。
如果坚持使用标准库 log,一个最简单的变通方案是:在中间件里把 ID 注入到 req.Header 中(例如设为 X-Request-ID),然后日志函数从 req.Header.Get(“X-Request-ID”) 获取。但请注意,这只是“透传”,而非“上下文绑定”。一旦请求跨入新的 goroutine(比如触发异步任务),这个 ID 就会丢失。
- 推荐做法:封装一个自定义的
Logger类型,在构造时接收context.Context,并在内部通过ctx.Value()提取并缓存 ID。 - 性能提示:避免在每次记录日志的格式字符串中动态调用
ctx.Value(),在高频日志场景下,这会带来不必要的性能损耗。 - 重要原则:绝对不要把
context.Context存储为全局变量或长期持有,它的有效性应严格限定在单个请求的生命周期之内。
并发安全与性能陷阱
uuid.NewString() 本身是并发安全的,但如果你打算自己实现 ID 生成器(例如基于原子计数器加时间戳),就必须处理好并发控制,要么加锁,要么使用 atomic 包。不过话说回来,通常并不建议这么做——UUID v4 的碰撞概率已经足够低,并且 uuid 包底层已经做了充分的性能优化。
- 避开误区一:别在 handler 里用
time.Now().UnixNano()拼接进程 PID 来生成 ID。单机高并发下,纳秒级时间戳可能重复,PID 也存在复用可能。 - 避开误区二:别用
rand.Intn()这类非加密安全的随机数生成器来造 ID。它们的熵不足,具有可预测性,完全不符合链路追踪场景的要求。 - 版本注意:如果使用
github.com/google/uuid包,请确保版本在 v1.3+,旧版本在某些平台上存在竞态条件问题。
真正的复杂性往往不在于生成 ID 本身,而在于透传的深度和一致性:从 HTTP 层到 gRPC 调用,再到数据库查询,乃至后台任务,每一跳都必须显式地携带 context。只要漏掉一环,整个调用链的 ID 就断了,这才是关键所在。
