本节把 TaskHub 推进到「日志能串起来」:从卷一里那个只会
slog.Info的 TaskAPI,升级成每条日志都带correlation_id与tenant_id、能被一条命令捞出来的多租户服务。
适用版本:Go 1.27(实测go1.27.0)。
10.1 结构化日志与关联 ID
卷一已经讲过 log/slog 的基本用法:slog.NewJSONHandler、InfoContext、With。会打 JSON 只是起点。TaskHub 是一个多租户服务,一个进程里同时跑着几十上百个请求,日志里混着 acme 和 globex 两个租户的记录。这时真正的问题不是「怎么打日志」,而是**「用户报了一个 500,我怎么在几百万行日志里把这一次请求的全部记录捞出来」**。
答案就是关联 ID(correlation ID,也叫 request ID / trace ID):给每次请求分配一个全局唯一字符串,让它出现在这次请求产生的每一条日志里。排查时 grep <id>,整条链路一次捞全。
10.1.1 关联 ID 该放在哪里
三个候选位置,各有致命问题:
| 放法 | 问题 |
|---|---|
| 全局变量 | 并发请求互相覆盖,等于没有 |
| 函数参数一路透传 | 每个签名都要多一个参数,跨层污染严重,中间件里加不进去 |
context.Context | 请求作用域天然一致,随调用链下传,中间件可注入 |
结论没有悬念:放 context。context 的语义就是「一次请求的作用域」,而关联 ID 的定义正是「一次请求的标识」。这也是 OpenTelemetry 的 trace.SpanContext 走 context 的同一个理由。
一个常见的反对意见是「用 context 传值不是反模式吗」。Go 官方的说法是:context 用来传请求作用域的数据(请求 ID、认证主体、截止时间)是合理的,用来传「函数可选参数」才是反模式。关联 ID 属于前者。
10.1.2 类型安全的 key 与两个存取函数
不要把 key 定义成裸字符串——不同包用 "id" 会撞车。用自定义的未导出类型,配合两个存取函数:
package obs
import "context"
type ctxKey int
const (
corrKey ctxKey = iota
logKey
)
// WithCorrelationID 把关联 ID 放进 context
func WithCorrelationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, corrKey, id)
}
// CorrelationID 取出关联 ID,没有则返回空串
func CorrelationID(ctx context.Context) string {
if v, ok := ctx.Value(corrKey).(string); ok {
return v
}
return ""
}
ctxKey 是未导出类型,别的包根本无法构造出同类型的 key,从根上杜绝了撞车。这两个函数是整章的地基。
10.1.3 用 Handler 包装,把 ID 注入每条记录
有了 context 里的 ID,还要让 slog 在每条记录上自动带上它。slog 的设计留好了口子:slog.Handler 接口的 Handle 方法第一个参数就是 context.Context。我们包一层 handler,在真正写出去之前补一个属性:
type correlationHandler struct{ slog.Handler }
func (h correlationHandler) Handle(ctx context.Context, r slog.Record) error {
if id := CorrelationID(ctx); id != "" {
r.AddAttrs(slog.String("correlation_id", id))
}
return h.Handler.Handle(ctx, r)
}
func (h correlationHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return correlationHandler{h.Handler.WithAttrs(attrs)}
}
func (h correlationHandler) WithGroup(name string) slog.Handler {
return correlationHandler{h.Handler.WithGroup(name)}
}
三个方法缺一不可。Handle 负责注入;WithAttrs 与 WithGroup 必须返回包装后的 handler,否则一旦有人调用 logger.With(...),包装层就被丢掉,注入静默失效。这是最容易踩的坑:代码看起来对,跑起来 ID 全没了。
包装完成后的 logger 这样构造:
var baseLogger = slog.New(correlationHandler{
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
})
注意这里只包一次,全局共享。不要在每个请求里重新 slog.New,那会白白多分配。
10.1.4 中间件:从 header 取 ID,没有就生成
关联 ID 有两种来源:上游服务传过来的(跨服务串联),或者本服务生成的(请求入口)。中间件负责这个决策:
func withCorrelation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Correlation-ID")
if id == "" {
id = uuid.NewString() // 入口请求,自己生成
}
ctx := WithCorrelationID(r.Context(), id)
// 派生一个带静态字段的子 logger,随 context 传递
l := baseLogger.With("tenant_id", r.Header.Get("X-Tenant-ID"))
ctx = context.WithValue(ctx, logKey, l)
w.Header().Set("X-Correlation-ID", id) // 回写给调用方,便于跨服务追
next.ServeHTTP(w, r.WithContext(ctx))
})
}
三个细节值得说:
- 回写 header:
X-Correlation-ID一定要回写。客户端拿到它才能报给客服,或者传给下游继续用。 baseLogger.With("tenant_id", ...):多租户系统里tenant_id是几乎每条日志都要的字段,用With派生一次,比每次Info都手写省事也不易漏。r.WithContext(ctx):http.Request是不可变的,必须用返回的新 request 传给next。
10.1.5 深层函数只拿 ctx 也能打对日志
有了放进 context 的 logger,业务函数签名里就不需要 logger 参数了:
func LoggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(logKey).(*slog.Logger); ok {
return l
}
return slog.Default() // 兜底,避免 panic
}
func listTasks(ctx context.Context, tenant string) (int, error) {
l := LoggerFrom(ctx)
l.DebugContext(ctx, "cache lookup", "key", "tasks:"+tenant) // Info 级别下被过滤
l.InfoContext(ctx, "listing tasks", "tenant", tenant)
return 3, nil
}
listTasks 完全不知道自己在 HTTP 里跑,也不认识关联 ID 和租户,但打出来的日志自动带上了 correlation_id 和 tenant_id。这就是「观测能力随 context 流动」的价值。
10.1.6 本机实测输出
把上面拼起来跑一遍(go build + 两个 curl),日志原样如下:
$ curl -s -H "X-Correlation-ID: req-abc-123" -H "X-Tenant-ID: acme" \
http://127.0.0.1:18091/tasks
{"tasks":3}
$ curl -s -H "X-Tenant-ID: globex" http://127.0.0.1:18091/tasks
{"tasks":3}
$ cat 服务 stdout
{"time":"2026-10-10T10:20:41.152327+08:00","level":"INFO","msg":"listing tasks","tenant_id":"acme","tenant":"acme","correlation_id":"req-abc-123"}
{"time":"2026-10-10T10:20:41.152834+08:00","level":"INFO","msg":"request done","tenant_id":"acme","count":3,"duration_ms":0,"correlation_id":"req-abc-123"}
{"time":"2026-10-10T10:20:41.187102+08:00","level":"INFO","msg":"listing tasks","tenant_id":"globex","tenant":"globex","correlation_id":"fd4efe41-0d5f-4015-a18f-127471700aa4"}
{"time":"2026-10-10T10:20:41.187151+08:00","level":"INFO","msg":"request done","tenant_id":"globex","count":3,"duration_ms":0,"correlation_id":"fd4efe41-0d5f-4015-a18f-127471700aa4"}
两点可验证的结论:
- 带 header 的那次请求,
correlation_id就是我们传进去的req-abc-123;不带的那次自动生成了 UUID。 cache lookup那条Debug日志没有出现——handler 配的是LevelInfo,被正确过滤了。日志级别不是摆设,Debug 级的高频日志在生产必须关掉。
10.1.7 陷阱一:goroutine 里误用 context.Background
这是关联 ID 方案最常见的破功点。看这段反面代码:
// 反面案例:派生 goroutine 里误用 context.Background(),关联 ID 丢失
go func() {
baseLogger.InfoContext(context.Background(), "audit written", "tenant", "acme")
}()
实测输出里,这一条变成了:
{"time":"2026-10-10T10:21:00.840293+08:00","level":"INFO","msg":"audit written","tenant":"acme"}
correlation_id 和 tenant_id 都消失了。原因是新起的 goroutine 用了 context.Background(),一个全新的空 context,里面既没有 ID 也没有子 logger。正确写法是把请求的 ctx 传进去(并想清楚这个 goroutine 能不能活过请求结束):
go func(ctx context.Context) {
LoggerFrom(ctx).InfoContext(ctx, "audit written", "tenant", "acme")
}(r.Context())
如果异步任务要在请求结束后继续跑,就不能直接用请求 ctx——它会被取消。正确做法是从请求 ctx 里把关联 ID 拷出来,塞进一个新的、带自己超时的 context:
id := CorrelationID(r.Context())
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ctx = WithCorrelationID(ctx, id)
LoggerFrom(ctx).InfoContext(ctx, "audit written")
}()
10.1.8 陷阱二:不要用 log.Printf 混打
TaskHub 里偶尔还会有人图快写 log.Printf("task %d done", id)。这行日志是纯文本,没有 correlation_id、没有 level、没有 time 字段,结构化日志系统解析不了它。混打会让「按 ID 捞日志」出现盲区。统一走 slog,哪怕只是一行调试输出。
10.1.9 成本:日志不是免费午餐
结构化日志的代价常被低估:
| 成本项 | 量级 | 应对 |
|---|---|---|
| 序列化 CPU | 每条几百纳秒 | 热路径上避免 Debug,用 Enabled 先判断 |
| 磁盘/带宽 | 每请求 1–2 KB | 采样、只记关键节点 |
| 下游查询 | 量越大越慢 | 用关联 ID 建索引,按天分片 |
热路径里如果要拼一个昂贵的字段(比如序列化一个大对象),先问一句「这条日志在当前级别会不会被输出」:
if l := LoggerFrom(ctx); l.Enabled(ctx, slog.LevelDebug) {
l.DebugContext(ctx, "payload", "body", expensiveDump(v))
}
Enabled 是一次廉价的级别比较,能避免在 Info 级别下白拼一个巨大的字符串。这是 slog 相比老 log 包最实用的能力之一。
小结
- 关联 ID 放
context,key 用未导出的自定义类型,配WithCorrelationID/CorrelationID两个函数。 - 用
slog.Handler包装注入 ID,Handle、WithAttrs、WithGroup三个方法都要实现并保持包装。 - 中间件从
X-Correlation-ID取或生成,回写 header,并把带tenant_id的子 logger 放进 context。 - 派生 goroutine 是 ID 丢失的重灾区:要么传请求 ctx,要么把 ID 拷进新 context。
- 日志有成本,热路径上用
Enabled先判断级别。
日志回答「发生了什么」,指标回答「现在好不好」。下一节我们把 TaskHub 的计数器和延迟直方图接进 Prometheus,并给每个接口定下 SLO。
阅读导航:上一节:9.3 goroutine 泄漏与 goleak · 下一节:10.2 指标与 SLO 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。