Appearance
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。