Skip to content

26|日志与链路

网关每天处理成千上万条请求,排查问题时不能靠猜。结构化日志把每条请求的关键信息(时间、路径、状态码、耗时、请求ID)按固定字段输出,方便日志平台检索和聚合。链路追踪则把一次请求经过的多个服务串起来,看到完整的调用路径。本章先在 go-gateway 里实现结构化请求日志和请求ID全链路透传,链路追踪作为延伸概念点到为止。

一、结构化日志 vs 纯文本日志

纯文本日志:

2024-06-01 10:00:00 GET /api/users 200 12ms

结构化日志(JSON):

json
{"time":"2024-06-01T10:00:00Z","level":"INFO","method":"GET","path":"/api/users","status":200,"cost_ms":12,"request_id":"abc123"}

JSON 日志可以直接被 Elasticsearch、Loki、ClickHouse 等日志平台索引,按字段筛选:path="/api/users" AND status >= 500。纯文本日志需要正则解析,字段变化后解析规则也要跟着改。

二、标准库 slog

Go 1.21 内置了 log/slog,支持结构化输出,不需要第三方库:

go
package main

import (
	"log/slog"
	"os"
)

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

	logger.Info("request handled",
		slog.String("method", "GET"),
		slog.String("path", "/api/users"),
		slog.Int("status", 200),
		slog.Int64("cost_ms", 12),
	)
}

输出:

json
{"time":"2024-06-01T10:00:00Z","level":"INFO","msg":"request handled","method":"GET","path":"/api/users","status":200,"cost_ms":12}

slog 的字段名稳定后,不要频繁变更,否则历史日志和新日志的字段对不上,查询时需要处理多个版本。

三、请求 ID 全链路透传

请求ID(也叫 Trace ID / Correlation ID)是排查分布式问题的关键线索。网关作为流量入口,应该为每个请求生成或继承请求ID,并透传到后端服务。

在第20讲已经实现了 Gin 中间件注入请求ID。代理转发层也要把请求ID传给后端:

go
func (p *Proxy) Director(req *http.Request) {
	// ... 其他头部设置

	// 透传请求ID
	if requestID := req.Context().Value("request_id"); requestID != nil {
		req.Header.Set("X-Request-ID", requestID.(string))
	}
}

后端服务收到 X-Request-ID 后,在日志里带上这个字段。这样一条请求从客户端 → 网关 → 后端 → 数据库,所有日志都能用同一个 request_id 串起来。

四、统一日志中间件

把代理层和管理层的日志统一格式:

go
type LogEntry struct {
	Time       string `json:"time"`
	Level      string `json:"level"`
	Method     string `json:"method"`
	Path       string `json:"path"`
	Status     int    `json:"status"`
	CostMS     int64  `json:"cost_ms"`
	RequestID  string `json:"request_id"`
	ClientIP   string `json:"client_ip"`
	Backend    string `json:"backend,omitempty"`
	Error      string `json:"error,omitempty"`
}

func LogMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rr := newResponseRecorder(w)

		next.ServeHTTP(rr, r)

		entry := LogEntry{
			Time:      time.Now().Format(time.RFC3339),
			Level:     "INFO",
			Method:    r.Method,
			Path:      r.URL.Path,
			Status:    rr.statusCode,
			CostMS:    time.Since(start).Milliseconds(),
			RequestID: r.Header.Get("X-Request-ID"),
			ClientIP:  r.RemoteAddr,
		}

		if rr.statusCode >= 500 {
			entry.Level = "ERROR"
		}

		// JSON 输出
		data, _ := json.Marshal(entry)
		fmt.Println(string(data))
	})
}

五、慢请求告警

请求耗时超过阈值时,提升日志级别或触发告警:

go
if entry.CostMS > 1000 {
	entry.Level = "WARN"
	entry.Error = "slow request"
}

生产环境里,慢请求日志接入告警系统(如 Prometheus Alertmanager),自动通知运维人员。

六、常见错误

日志字段不统一

代理层用 cost_ms,管理 API 用 duration,后端服务用 elapsed——同一个概念三个名字,查询时需要知道所有变体。团队内约定统一的字段命名规范,写入代码审查清单。

在日志里打印敏感信息

go
logger.Info("login", slog.String("password", user.Password)) // 错误

密码、Token、身份证号等敏感信息不能进日志。如果必须记录,脱敏或只记录部分特征。

日志输出阻塞请求处理

日志写到文件或网络时,如果 IO 阻塞,会拖慢请求响应。高并发场景下,用异步日志或带缓冲的 Handler。