功能上线只是开始,出问题能不能定位才是考验。散落的 fmt.Println 查不了、串不起一次请求经过的多个服务。这一篇把日志结构化,再给每次请求发一个 trace_id,跨函数跨服务都能追。
1. 用 zap 产出 JSON 日志
Gin 默认的日志是给人看的彩色文本,机器不好解析。换成 go.uber.org/zap:
import "go.uber.org/zap" func newLogger() *zap.Logger { cfg := zap.NewProductionConfig() cfg.Encoding = "json" // 每行一条 JSON cfg.OutputPaths = []string{"stdout"} l, _ := cfg.Build() return l } func main() { logger := newLogger() defer logger.Sync() r := gin.New() r.Use(gin.Recovery()) }
JSON 日志字段规整,丢进 Loki 或 ES 后能按 path、status、latency 直接检索和画图。比翻文本日志快得多。
2. 请求日志中间件
一个中间件记录每次请求的关键信息,顺手生成 trace_id:
func RequestLog(logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() traceID := c.GetHeader("X-Trace-Id") if traceID == "" { traceID = uuid.NewString() } c.Set("trace_id", traceID) c.Next() logger.Info("http", zap.String("trace_id", traceID), 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("ip", c.ClientIP()), ) } }
c.Set("trace_id", ...) 让后面的 handler 也能取到,写业务日志时带上,一次请求的所有日志同一个 trace_id。
3. trace_id 贯穿整个处理链
handler 里打的日志带上 trace_id,排查时拿它一搜全出来:
func (h *OrderHandler) Create(c *gin.Context) { traceID, _ := c.Get("trace_id") logger.Info("下单开始", zap.String("trace_id", traceID.(string)), zap.String("uid", fmt.Sprint(c.GetString("uid"))), ) // 调下游服务也把 trace_id 放进请求头 req.Header.Set("X-Trace-Id", traceID.(string)) ... }
跨服务调用把 trace_id 放进 X-Trace-Id 头传下去,链路就连上了。下游日志带同一 id,问题发生在哪一步一目了然。
4. 接 OpenTelemetry 做真正链路追踪
trace_id 只能串日志,OpenTelemetry 能记录每个环节的耗时和父子关系,画成调用树。先初始化 tracer:
import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func initTracer(ctx context.Context) func() { exp, _ := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint("otel-collector:4317"), otlptracegrpc.WithInsecure(), ) tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp), sdktrace.WithResource(resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName("gin-demo")), ), ) otel.SetTracerProvider(tp) return func() { tp.Shutdown(context.Background()) } }
中间件里为每个请求起一个 span,handler 内部再起子 span:
func Trace() gin.HandlerFunc { tracer := otel.Tracer("gin") return func(c *gin.Context) { ctx, span := tracer.Start(c.Request.Context(), c.Request.URL.Path) defer span.End() c.Request = c.Request.WithContext(ctx) c.Next() span.SetAttributes(attribute.Int("http.status", c.Writer.Status())) } }
c.Request.Context() 往下传,service 层用同一个 ctx 起子 span,整条调用链自动拼成树。导出到 Jaeger 或 Tempo,慢请求点开就能看到卡在查库还是调下游。
5. 错误也记录
Recovery 里用 zap 把 panic 记下来,带 trace_id 和堆栈:
func Recovery(logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { logger.Error("panic", zap.String("trace_id", getTraceID(c)), zap.Any("error", err), zap.Stack("stack"), ) } }() c.Next() } }
堆栈只在日志里,不回传前端(第 4 篇讲过)。线上报错靠这条日志的 trace_id 反查整条链路。
6. 上线前清单
日志用 JSON 编码,方便采集检索。每条请求日志带 trace_id、方法、路径、状态码、耗时。trace_id 跨服务通过 X-Trace-Id 头传递。敏感字段(密码、 token)不进日志。OpenTelemetry 导出走 otel-collector 批处理,别每条直发。日志级别生产用 info,调试用 debug,靠环境变量切。
十二篇走完,从第一个接口到缓存、鉴权、部署、测试、实时推送、链路追踪,一套能上生产的 Gin 后端骨架齐了。后面想加什么按业务往里填就行。