后端

可跨服务和作业扩展的结构化日志

一旦请求跨越服务,自由格式的日志便会中断。下文说明了 JSON 日志、关联 ID 和一个共享模式如何使分布式请求变得可追踪。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

只有一个日志文件的单个服务很容易调试。你用 grep 搜索错误字符串,读取其周围的行,就能了解事情的来龙去脉。一旦请求触及第二个服务,或将工作交给异步工作单元,或派生出后台作业,这种模式就失效了。现在,一个请求的来龙去脉分散在三个日志流中,与成千上万条无关的行交错在一起,没有任何线索能将它们联系起来。解决方法不是记录更多日志。而是以结构化数据的形式记录日志,并使用一个共享标识符,让该标识符在请求的所到之处一路跟随。本文将展示如何实现这一目标:使用 JSON 日志代替句子,在入口点生成一个关联 ID 并在每一跳中传播,以及所有服务都共同遵守的一个模式。

为什么自由格式的文本日志无法扩展

user 4021 failed to charge card, retrying 这样的一行日志,人读起来没什么问题。但对日志系统而言,它只是一个不透明的字符串。如果你想回答“过去一小时内,年费套餐用户发生了多少次扣费失败”这样的问题,你是做不到的,因为“annual plan”(年费套餐)和“charge failure”(扣费失败)这些信息被埋藏在每次日志调用都各不相同的文本描述中。一位工程师可能会写 charge failed,另一位写 payment declined,第三位又写 could not bill。它们指的都是同一个事件,但没有任何查询能将它们归为一类。

当一个请求跨越多个服务时,更深层次的问题就出现了。一个 API 服务器接收到请求,调用了计费服务,而计费服务又将一个任务入队交由一个工作进程(worker)处理。结果工作进程中发生了故障。你能看到工作进程的错误,但没有任何信息能将其与最初的请求、用户或发起调用的 API 关联起来。你最终只能通过时间戳和猜测来进行关联,在三个终端里来回滚动,寄希望于时间点能够对上。在流量较低时,这样做很烦人。而在真实的大流量下,这根本不可能,因为在同一秒内,每个日志流中都交织着数十个不相关的请求。

自由格式的日志是基于“会由人按顺序阅读”这一假设的。分布式系统打破了这两个假设:日志不再有序,也没有人能读完如此巨大的日志量。日志必须转变为可查询的数据。

日志是数据,不是句子

心态的转变是这样的:日志行不是供人阅读的消息,而是供机器查询的记录。你不是将值格式化成句子,而是发出字段。事件名称是一个字段。用户 ID 是一个字段。结果是一个字段。消息(如果有的话)只是另一个字段,而且不是重要的那个。

下面是同一个扣费失败事件,分别以自由格式文本和结构化数据的形式呈现。

方面 自由格式文本 结构化 (JSON)
形式 user 4021 failed to charge, retrying {"event":"charge_failed","user_id":4021,"retry":true}
按事件筛选 子字符串匹配,脆弱 event = "charge_failed"
聚合 无法实现 count() group by user_plan
跨服务连接 猜测时间戳 trace_id 上连接
模式 因作者而异 一套商定的字段
读取对象 人类,按顺序 查询引擎,任意顺序

一旦日志成为字段,日志收集器就会完成你过去用肉眼所做的工作。你可以筛选到一种事件类型,按任意字段分组,在某个时间窗口内计数,并将结果制成图表。“过去一小时内,年费套餐用户的扣费失败次数是多少”这个问题变成了一个筛选和分组操作,而不是一个不可能完成的 grep

在 Go 中,标准库提供了 log/slog 正是为了实现这一点。其关键思想在于,你附加的是带类型的键值对,而不是内插字符串。

import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
	Level: slog.LevelInfo,
}))

// Not this. The values are trapped inside a sentence.
// logger.Info(fmt.Sprintf("user %d failed to charge, retry=%v", userID, true))

// This. Every value is a field the collector can query.
logger.Info("charge failed",
	slog.String("event", "charge_failed"),
	slog.Int("user_id", userID),
	slog.String("plan", "annual"),
	slog.Bool("retry", true),
)

输出是每行一个 JSON 对象,每个日志收集器都能原生解析:

{"time":"2026-07-17T09:14:22+09:00","level":"INFO","msg":"charge failed","event":"charge_failed","user_id":4021,"plan":"annual","retry":true}

请注意,event 字段与 msg 字段是分开的。消息是对人类友好的文本,可以随意更改。event 是一个稳定的机器可读键,你用它来查询,并且它绝不能改变。事件名称一旦选定就不要再改,并将其视为 API。

将请求串联起来的关联 ID

结构化字段使单个服务可查询。关联 ID 使整个系统可追溯。其理念很简单:当一个请求首次进入你的系统时,为其生成一个唯一的 ID。然后将该 ID 附加到每一行日志、每一次下游服务调用以及该请求衍生的每一个作业上。要重构一个请求在所有服务中的完整历程,你只需筛选一个值。

这个 ID 有好几个名字,例如 trace_idrequest_idcorrelation_id。重要的是,它在边缘处创建一次,并且永不重新生成。入口点(通常是 HTTP 中间件)是它诞生的地方。

// Middleware: reuse an inbound trace ID if a trusted upstream sent one,
// otherwise mint a fresh one. Store it on the context so every log call
// downstream can read it.
type ctxKey string

const traceIDKey ctxKey = "trace_id"

func TraceMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		traceID := r.Header.Get("X-Trace-Id")
		if traceID == "" {
			traceID = newTraceID() // e.g. a random 16-byte hex string
		}

		ctx := context.WithValue(r.Context(), traceIDKey, traceID)

		// Bind the trace ID into a logger stored on the context, so no
		// call site has to remember to pass it.
		reqLogger := slog.With(slog.String("trace_id", traceID))
		ctx = context.WithValue(ctx, loggerKey, reqLogger)

		w.Header().Set("X-Trace-Id", traceID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// Every handler pulls the request-scoped logger back off the context.
func loggerFrom(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
		return l
	}
	return slog.Default()
}

现在,请求内的任何日志调用都会自动携带跟踪 ID,因为日志记录器在边缘处与它绑定了一次。

func chargeHandler(w http.ResponseWriter, r *http.Request) {
	log := loggerFrom(r.Context())
	log.Info("charge started", slog.String("event", "charge_started"))
	// ... every line from here carries the same trace_id
}

跨服务和 worker 边界传播 ID

一个只存在于单个服务内部的 ID 在边界上毫无价值。关键在于它能在跨越后依然存在。有两种跨越需要处理:对另一个服务的同步调用,以及对一个 worker 或 job 的异步移交。

对于同步 HTTP 调用,调用方从其上下文中读取跟踪 ID,并将其写入一个出站标头中。被调用方的中间件会读取同一个标头,这就是为什么上面的中间件在生成新的 ID 之前会检查 X-Trace-Id

// Outbound call: carry the trace ID forward as a header.
func callBilling(ctx context.Context, body io.Reader) (*http.Response, error) {
	req, _ := http.NewRequestWithContext(ctx, http.MethodPost, billingURL, body)
	if id, ok := ctx.Value(traceIDKey).(string); ok {
		req.Header.Set("X-Trace-Id", id)
	}
	return http.DefaultClient.Do(req)
}

对于异步交接,header 技巧不适用,因为 worker 会在稍后运行,且与调用方没有实时连接。因此,ID 在消息内部传递。你将其放入作业负载中,worker 会将其取出,并像中间件那样重新绑定其 logger。

// Enqueue: the trace ID rides along inside the job payload.
type ChargeJob struct {
	TraceID string `json:"trace_id"`
	UserID  int    `json:"user_id"`
}

func enqueueCharge(ctx context.Context, q Queue, userID int) error {
	id, _ := ctx.Value(traceIDKey).(string)
	return q.Push(ChargeJob{TraceID: id, UserID: userID})
}

// Worker: rebuild the request-scoped logger from the payload, so the
// worker's logs share the trace_id of the request that queued the job.
func (w *Worker) handle(job ChargeJob) {
	log := slog.With(slog.String("trace_id", job.TraceID))
	log.Info("charge job picked up", slog.String("event", "charge_job_started"))
	// ... the worker's failure now joins to the original API request
}

有了这个之后,一个 trace_id 就能将 API 日志、计费服务日志和 worker 日志关联起来。当 worker 失败时,你可以根据该 ID 筛选每个流,并按顺序跨进程边界读取请求的完整路径,就好像它在单个日志文件中运行一样。

统一的 schema,统一的时间基准,处处适用

只有当每个服务都对字段达成一致时,传播才有价值。如果 API 服务器写入 trace_id,工作进程写入 traceId,而计费服务写入 request_id,你就无法将它们关联起来,只能回到猜测的老路。共享的 schema 是让跨服务查询真正起作用的关键。就一小组基础字段达成一致,让每个服务在每一行日志中都输出它们:

  • timestamp,整个集群使用同一个时间基准。首选 UTC 或单个固定时区,这样来自不同主机的日志行才能正确排序。混合的本地时区会让排序变得不可信。
  • level,严重性级别。
  • service,输出日志的服务名称,这样你就可以从合并的流中筛选出某个服务。
  • event,用于描述所发生事件的稳定机器可读键。
  • trace_id,来自上述的关联 ID。

在这些基础字段之外,每个事件可以添加自己的字段。基础字段集是契约;额外的字段是自由的。强制执行基础字段集成本最低的方法是将其构建到一个共享的 logger 构造函数中,让每个服务都导入它,而不是相信每个团队都能记住。如果字段名称存在于一个库中,它们就不会发生偏离。

时间基准值得特别强调,因为它是最常见的隐蔽故障。当一台主机以本地时间记录日志,而另一台以 UTC 记录时,合并后的视图会错误地排序事件,一个实际耗时 200 毫秒的请求可能会看起来像时间倒流了一样。为机器时间戳选择一个时区,并且绝不偏离。在仪表盘中读取时渲染友好的本地时间,而不是在日志中写入时。

级别规范与排除秘密信息

一旦系统部署到位,两条规则可保证其整体可用性。

首先,谨慎使用日志级别。error 级别应表示需要人工介入。如果将常规的、可恢复的情况记录为 error 级别,那么这个级别就失去了意义,基于它的警报就会变成“狼来了”,人们就会学会忽略这个本不应被忽略的唯一信号。使用 warn 记录可恢复但值得注意的情况,使用 info 记录请求的常规叙述,使用 debug 记录只在追踪特定问题时才需要的详细信息。一次重试成功应记录为 infowarn,而不是 error。将 error 级别留给那些确实失败且一直保持失败状态的结果。

其次,绝不记录秘密或个人数据。结构化日志让这个陷阱变得更糟,而不是更好,因为它太容易将整个结构体作为字段转储出来。那个结构体可能包含密码哈希、访问令牌、完整的卡号、电子邮件地址。日志会被发送到第三方收集器,保留数月,并被那些绝不应该看到原始个人数据的人读取,因此,日志行中的秘密信息就是一个等待被发现的安全漏洞。在日志记录的边界处,对敏感字段进行掩码处理或直接丢弃。记录用户 ID,而不是电子邮件地址。记录存在令牌这一事实,而不是令牌本身。当记录错误对象时,确保它不携带触发该错误的请求体。

结构化日志的成本

这一切都是有代价的,明确其代价是值得的,以便我们能够审慎地进行权衡。结构化日志比 printf 写起来更冗长,因为你需要为每个字段命名,而不是将值直接放入一个句子中。将跟踪 ID (trace ID) 贯穿于每个服务调用和作业负载中,是你必须构建和维护的连接逻辑,而一旦有单个服务忘记传递该 ID,就会在你最不希望出现的地方造成一个盲点。JSON 日志也比纯文本更大,而更大的日志在传输、存储和保留方面的成本也更高,尤其是在 infodebug 级别的大量日志下。

缓解措施是常规的。采样会丢弃一部分高容量、低价值的日志行,这样成功的请求就不必为每一条日志都付出全部成本,而失败的请求仍然会完整记录日志。级别规约默认情况下会将 debug 级别的日志排除在生产存储之外。一个共享的日志库可以封装传递的连接逻辑,从而使各个调用点保持简单。与另一种选择——即在事故发生、时间紧迫的情况下,开着三个终端、靠时间戳进行猜测——相比,这个成本是微不足道的。当一个请求在服务链的某个环节失败时,一个关联 ID (correlation ID) 和一个可查询的模式就能将一下午的猜测工作变成一次简单的筛选,而这正是决定你恢复速度的关键。