5月27日 15:18

Gin 框架的日志记录和监控怎么做?从中间件到可观测性全链路实战

Gin 作为 Go 语言最流行的 Web 框架,其日志和监控能力直接决定了线上服务的可观测性。面试中这道题考察的不是"你能不能写出一个中间件",而是你对生产环境日志体系的理解深度——从日志采集、链路追踪到指标监控,能不能串成一条完整的可观测性链路。

内置日志:够用但不适合生产

Gin 的 gin.Default() 默认挂载了 Logger()Recovery() 两个中间件。Logger() 会在控制台输出类似这样的请求日志:

shell
[GIN] 2026/05/27 - 10:30:45 | 200 | 1.023ms | 192.168.1.1 | GET "/api/users"

开发阶段用着没问题,但生产环境至少有三个硬伤:格式不可解析(纯文本)、没有结构化字段、无法对接日志平台。gin.ReleaseMode 下连这些日志都不输出,更谈不上可观测。

通过 gin.LoggerWithFormatter 可以自定义输出格式,但它本质上还是在写 stdout,解决不了日志持久化和检索的问题。所以生产环境的第一步,就是把内置日志换成结构化日志库。

结构化日志:logrus 和 zap 怎么选

logrus 的 API 最友好,WithFields 语义清晰,JSONFormatter 开箱即用:

go
var log = logrus.New() func init() { log.SetFormatter(&logrus.JSONFormatter{}) log.SetOutput(os.Stdout) log.SetLevel(logrus.InfoLevel) } func logrusMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() latency := time.Since(start) status := c.Writer.Status() entry := log.WithFields(logrus.Fields{ "method": c.Request.Method, "path": c.Request.URL.Path, "status": status, "latency": latency.String(), "client_ip": c.ClientIP(), }) switch { case status >= 500: entry.Error("server error") case status >= 400: entry.Warn("client error") default: entry.Info("request completed") } } }

zap 性能更强,适合高吞吐场景。它的配置稍复杂,但结构化字段的表达力更好:

go
var logger *zap.Logger func initLogger() { cfg := zap.NewProductionConfig() cfg.EncoderConfig.TimeKey = "timestamp" cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder var err error logger, err = cfg.Build() if err != nil { panic(err) } } func zapMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() logger.Info("request", zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path), zap.Int("status", c.Writer.Status()), zap.Duration("latency", time.Since(start)), zap.String("client_ip", c.ClientIP()), ) } }

怎么选?日志量不大(QPS < 10k)用 logrus 足够,追求极致性能或日志量巨大就上 zap。Go 1.21 之后也可以考虑标准库 log/slog,三方依赖更少。

日志轮转:别让日志撑爆磁盘

结构化日志写到文件后,必须处理轮转,否则磁盘迟早被吃满。lumberjack 是最常用的方案:

go
import "gopkg.in/natefinch/lumberjack.v2" log.SetOutput(&lumberjack.Logger{ Filename: "/var/log/gin/app.log", MaxSize: 100, // 单文件最大 MB MaxBackups: 7, // 保留旧文件数 MaxAge: 30, // 保留天数 Compress: true, })

一个容易忽略的点:MaxBackupsMaxAge 是 AND 关系,不是 OR。超过 30 天的文件会被删,但只保留最近 7 个备份,两者都生效。另外,容器环境下建议直接输出到 stdout,让日志采集器(Fluentd/Filebeat)统一收集,比写文件再挂载卷要可靠得多。

请求追踪:从单机到分布式

单机场景下,给每个请求分配一个唯一 ID 是追踪的基础:

go
func requestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { rid := c.GetHeader("X-Request-ID") if rid == "" { rid = uuid.New().String() } c.Set("request_id", rid) c.Header("X-Request-ID", rid) c.Next() } }

微服务架构下,光有 Request ID 不够,还需要分布式链路追踪。OpenTelemetry 是当前的事实标准:

go
import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" ) func tracingMiddleware() gin.HandlerFunc { return func(c *gin.Context) { tracer := otel.Tracer("gin-server") ctx, span := tracer.Start(c.Request.Context(), c.Request.URL.Path) defer span.End() c.Request = c.Request.WithContext(ctx) c.Next() } }

这样每个请求会自动生成 Span,上下游服务通过 W3C TraceContext 传播 trace_id,Jaeger 或 Zipkin 上能看到完整的调用链。

Prometheus 指标:量化服务健康度

日志是事后排查,指标是实时感知。Prometheus + Grafana 是 Go 服务的标配监控方案。在 Gin 中暴露指标非常直接:

go
var ( httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration", Buckets: prometheus.DefBuckets, }, []string{"method", "path", "status"}) httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total HTTP requests", }, []string{"method", "path", "status"}) ) func prometheusMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() duration := time.Since(start).Seconds() status := strconv.Itoa(c.Writer.Status()) httpDuration.WithLabelValues(c.Request.Method, c.FullPath(), status).Observe(duration) httpRequestsTotal.WithLabelValues(c.Request.Method, c.FullPath(), status).Inc() } } // 暴露 /metrics 端点 r.GET("/metrics", gin.WrapH(promhttp.Handler()))

注意用 c.FullPath() 而不是 c.Request.URL.Path 作为 label。前者返回路由模板(如 /users/:id),后者返回实际路径,会导致 label 爆炸,直接把 Prometheus 内存吃光。这是新手最容易踩的坑。

错误监控:Sentry 捕获线上异常

Recovery 中间件只能防止进程崩溃,但异常信息丢了就不好排查。接入 Sentry 可以把 panic 和 error 自动上报:

go
func sentryMiddleware() gin.HandlerFunc { return func(c *gin.Context) { hub := sentry.CurrentHub().Clone() hub.Scope().SetRequest(c.Request) c.Set("sentry_hub", hub) defer func() { if err := recover(); err != nil { hub.CaptureException(fmt.Errorf("%v", err)) c.AbortWithStatusJSON(500, gin.H{"error": "internal server error"}) } }() c.Next() } }

初始化时配置 DSN 和采样率:

go
sentry.Init(sentry.ClientOptions{ Dsn: "your-sentry-dsn", TracesSampleRate: 0.2, // 生产环境不要 1.0,按流量调整 })

日志级别与敏感信息处理

日志级别不是摆设,用对级别才能在告警时精准定位:

  • Debug:仅开发环境,如请求体、响应体
  • Info:正常业务流程,如请求完成、用户登录
  • Warn:可容忍的异常,如降级触发、重试成功
  • Error:需要关注的故障,如数据库写入失败
  • Fatal:进程无法继续,如配置加载失败

敏感信息(token、密码、手机号)绝对不能出现在日志里。推荐的做法是在中间件中对 header 和 body 做脱敏过滤:

go
func sanitizeHeaders(headers http.Header) http.Header { sanitized := headers.Clone() for k := range sanitized { if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "Cookie") { sanitized.Set(k, "[REDACTED]") } } return sanitized }

一套完整的中间件串联方案

把上面这些串起来,一个生产级 Gin 应用的中间件注册顺序应该是:

go
r := gin.New() r.Use(requestIDMiddleware()) // 1. 最先注入 Request ID r.Use(tracingMiddleware()) // 2. 链路追踪 r.Use(sentryMiddleware()) // 3. 异常捕获 r.Use(prometheusMiddleware()) // 4. 指标采集 r.Use(zapMiddleware()) // 5. 请求日志 r.Use(gin.Recovery()) // 6. 兜底 panic 恢复

顺序有讲究:Request ID 要最早注入,后续所有中间件才能拿到;日志中间件放在指标之后,因为需要读取 status code;Recovery 放最后兜底。

面试回答要点

回答这道题,关键是展现从"能写代码"到"理解体系"的跨越:

  1. 内置日志的局限性你清楚,知道生产环境必须换结构化日志
  2. logrus/zap/slog 的选型有判断依据,不是随便选一个
  3. 日志轮转和采集方案你考虑过,知道容器环境的最佳实践
  4. Request ID 和分布式追踪的区别你能说清,知道什么时候用哪个
  5. Prometheus 指标 label 设计的坑你踩过或至少知道
  6. 中间件注册顺序有原则,不是随便排的

把这些点串起来,面试官就能看出你不只是会用 Gin,而是真在生产环境踩过坑。

标签:Gin