Go 企业级工程能力实战(5):日志、指标、链路追踪——可观测性三件

一、开篇引入:那次排查了 3 小时的生产故障

凌晨 2 点,告警响了。

"用户登录成功率从 99.7% 降到 67%。"

我打开日志,看到 200 万行日志混杂在一起:

复制代码
[INFO] request begin: Method: POST, url: localhost:8080/v1/auth/login
[ERROR] exec end: api: /v1/auth/login, code: -2, msg: internal error
[INFO] request begin: Method: GET, url: localhost:8080/v1/user/1
[INFO] exec end: api: /v1/user/1, execute time: 340ms

问题来了:哪条错误日志对应哪个请求? 我没办法关联"请求开始"和"请求结束"的日志,也没办法知道这个错误属于哪个 trace。日志量太大(每秒 2000 条),grep 都要跑好几秒。

3 个小时后,我终于找到根因:数据库连接池被一个慢查询耗尽,导致后续请求都超时。但这 3 个小时本可以是 3 分钟------如果我们有结构化日志 + 监控指标 + 链路追踪

这就是"可观测性三件套"的意义:

组件 解决的问题 类比
日志(Logging) "发生了什么?" 📝 飞机的飞行记录仪
指标(Metrics) "现在多少?趋势如何?" 📊 飞机的仪表盘
链路追踪(Tracing) "请求经过了哪些服务?哪个环节慢了?" 🗺️ 飞机的航迹追踪

二、概念铺垫:三个不可替代的维度

2.1 为什么需要三个?

你可能会问:"日志不就够了吗?grep 一下不就行了?"

不够。让我们用一个外卖配送的类比例子:

场景 日志能回答吗? 指标能回答吗? 链路追踪能回答吗?
为什么这个订单延迟了? ⚠️ 需要关联多条日志 ✅ 看到每个环节耗时
今天的下单量是多少? ⚠️ 需要统计日志 ✅ Grafana 面板直接看
错误原因是什么? ✅ 错误日志有详细堆栈 ⚠️ 只知道有错误发生 ⚠️ 知道哪个 span 出错

结论:三者在不同场景下不可互相替代,必须三者兼备。

2.2 结构化日志 vs 纯文本日志

复制代码
# 纯文本日志(传统方式)
2024-01-15 02:30:15 [ERROR] User login failed for bob

# 结构化日志(本项目使用的方式)
{"level":"ERROR","ts":"2024-01-15 02:30:15","msg":"User login failed","user":"bob","trace_id":"abc123","span_id":"def456"}

结构化日志(JSON 格式)的优势:

  • 可以用 jq 工具直接查询和过滤
  • 可以被 Elasticsearch/Loki 等日志系统索引
  • 可以按字段聚合分析

2.3 Prometheus 四种指标类型

类型 用途 示例
Counter 只增不减的计数器 请求总数、登录次数
Gauge 可增可减的瞬时值 当前连接数、内存使用量
Histogram 统计值的分布 请求延迟分布、响应体大小分布
Summary 类似 Histogram,客户端计算分位数 请求延迟的 P99

三、循序渐进:日志的进化之路

阶段 1:标准库 log 包(最简单)

go 复制代码
log.Println("user login failed")
// 输出: 2024/01/15 02:30:15 user login failed

问题:没有日志级别、没有结构化、无法控制输出目标、性能差(每次写日志都有系统调用)。

阶段 2:Zap 结构化日志(本项目的选择)

go 复制代码
logger.Infof("user login failed: user=%s", username)
// 输出(JSON): {"level":"INFO","ts":"2024-01-15 02:30:15","msg":"user login failed: user=bob","caller":"auth.go:89"}

优势:高性能(零内存分配)、结构化输出、日志级别控制、支持日志轮转。

阶段 3:Zap + Lumberjack(自动日志轮转)

日志轮转是什么?如果所有日志写到一个文件,一个月后这个文件可能 50GB。日志轮转会:

  • 按大小切分(如每 1MB 切一个新文件)
  • 按时间归档(保留最近 30 天)
  • 自动压缩旧文件(.gz

四、代码实战

4.1 结构化日志系统:Zap + Lumberjack

pkg/logger/Logger.go:22-31,首先定义了日志接口:

go 复制代码
type Logger interface {
    Info(args ...interface{})
    Infof(format string, args ...interface{})
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    Debug(args ...interface{})
    Debugf(format string, args ...interface{})
    Warn(args ...interface{})
    Warnf(format string, args ...interface{})
}

为什么定义接口?

  1. 依赖注入 :Service 依赖 logger.Logger(接口),而非 *ZapLogger(具体类型)
  2. 可测试性 :测试时用 MockLogger(空实现),不需要真实的日志文件
  3. 可替换性:将来换用其他日志库(如 zerolog),只需要新的实现

pkg/logger/Logger.go:52-58NewZapLogger 创建 Zap 实例:

go 复制代码
func NewZapLogger() *ZapLogger {
    writeSyncer := getLogWriter()   // 输出目标(含 lumberjack 轮转)
    encoder := getEncoder()         // 输出格式(JSON)
    core := zapcore.NewCore(encoder, writeSyncer, getLogLevel())
    logger := zap.New(core, zap.AddCaller())  // AddCaller 记录调用位置
    return &ZapLogger{sugar: logger.Sugar()}
}

为什么用 logger.Sugar() 而不是直接使用 *zap.Logger

Zap 提供了两种 API:

  • *zap.Logger :强类型,最快,但只能 .Info("msg", zap.String("key", "val"))
  • *zap.SugaredLogger :语法糖,稍慢(约慢 10%),但支持 printf 风格 .Infof("msg %s", val)

对于业务代码来说,printf 风格更方便使用,性能损失几乎可以忽略不计。

pkg/logger/Logger.go:97-104,日志格式的自定义:

go 复制代码
func getEncoder() zapcore.Encoder {
    encoderConfig := zap.NewProductionEncoderConfig()
    encoderConfig.EncodeTime = func(time time.Time, encoder zapcore.PrimitiveArrayEncoder) {
        encoder.AppendString("[" + time.Format("2006-01-02 15:04:05") + "]")
    }
    encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
    return zapcore.NewJSONEncoder(encoderConfig)
}

自定义时间格式 :Go 的时间格式化使用一个特殊的参考时间 2006-01-02 15:04:05(Mon Jan 2 15:04:05 MST 2006 = 1 2 3 4 5 6 7),这是 Go 语言的设计哲学------用数字记忆而不是字符串记忆。

pkg/logger/Logger.go:106-115,日志轮转配置:

go 复制代码
func getLogWriter() zapcore.WriteSyncer {
    lumberJackLogger := &lumberjack.Logger{
        Filename:   "../log/user.log",  // 日志文件路径
        MaxSize:    1,                   // 每个文件最大 1 MB
        MaxBackups: 300,                 // 最多保留 300 个备份文件
        MaxAge:     30,                  // 最多保留 30 天
        Compress:   true,                 // 自动压缩旧文件为 .gz
        LocalTime:  true,                 // 使用本地时间命名文件
    }
    return zapcore.AddSync(lumberJackLogger)
}

日志轮转的效果

复制代码
log/
├── user.log              # 当前日志(正在写入)
├── user-2024-01-15.gz    # 昨天的日志(已压缩)
├── user-2024-01-14.gz
└── ...

日志级别的控制在 pkg/logger/Logger.go:37-50

go 复制代码
func getLogLevel() zapcore.Level {
    switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
    case "debug":
        return zapcore.DebugLevel   // 开发环境:记录所有日志
    case "info":
        return zapcore.InfoLevel    // 生产环境:记录 Info 及以上
    case "warn":
        return zapcore.WarnLevel    // 高流量环境:只记录 Warn 和 Error
    case "error":
        return zapcore.ErrorLevel   // 只记录错误
    default:
        return zapcore.InfoLevel    // 默认:Info 级别
    }
}

4.2 GORM SQL 日志适配器

pkg/logger/GormLogger.go:33-41,项目实现了一个自定义的 GORM 日志适配器:

go 复制代码
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
    elapsed := time.Since(begin)
    sql, rows := fc()
    if err != nil {
        l.Logger.Errorf("[%.2fms] [rows:%d] %s | error: %v", float64(elapsed.Nanoseconds())/1e6, rows, sql, err)
    } else {
        l.Logger.Infof("[%.2fms] [rows:%d] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql)
    }
}

这个适配器做了什么?

它将 GORM 的 SQL 执行信息转换为统一的结构化日志格式:

复制代码
[3.45ms] [rows:1] SELECT * FROM user WHERE id=? | error: connection refused

每个 SQL 日志包含:

  • 执行时间(毫秒)
  • 影响行数
  • 完整 SQL 语句
  • 如果有错误,包含错误信息

这是排查慢查询问题的关键数据源。配合 Grafana 或 ELK,你可以快速找到"执行时间超过 100ms 的 SQL 语句"。

4.3 Prometheus 指标体系

service/metrics.go:13-73,定义了业务和 HTTP 层面的全套指标:

go 复制代码
var (
    // Counter: 登录尝试次数,按状态(success/fail)区分
    loginAttempts = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "login_attempts_total",
            Help: "Total number of login attempts",
        },
        []string{"status"},  // 标签:success 或 fail
    )

    // Counter: 用户注册数
    userCreations = prometheus.NewCounter(
        prometheus.CounterOpts{
            Name: "user_creations_total",
            Help: "Total number of user registrations",
        },
    )

    // Counter: 好友请求数(发送/接受)
    friendRequestsSent     = prometheus.NewCounter(...)
    friendRequestsAccepted = prometheus.NewCounter(...)
    friendAdditions        = prometheus.NewCounter(...)

    // CounterVec: HTTP 请求总数(按 method, path, status 分类)
    reqCnt = prometheus.NewCounterVec(
        prometheus.CounterOpts{Name: "http_requests_total", ...},
        []string{"method", "path", "status"},
    )

    // Histogram: HTTP 请求耗时分布
    reqDur = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Buckets: []float64{.01, .05, .1, .5, 1, 3, 5, 10, 30},
        },
        []string{"method", "path"},
    )

    // Gauge: 当前正在处理的请求数
    reqInFlight = prometheus.NewGauge(
        prometheus.GaugeOpts{Name: "http_requests_in_flight", ...},
    )
)

指标设计原则

  1. Counter vs Gauge

    • login_attempts_total 是 Counter(只增不减),适合统计"总共发生了多少次"
    • http_requests_in_flight 是 Gauge(可增可减),适合反映"当前正在进行多少"
  2. Histogram 的 Bucket 设计(第 62 行):

    复制代码
    Buckets: []float64{.01, .05, .1, .5, 1, 3, 5, 10, 30}

    这 9 个桶对应了现实中的请求耗时分布:

    • < 10ms:极快的响应(缓存命中)
    • 10-50ms:正常的响应
    • 50-100ms:稍慢的响应
    • 100-500ms:慢响应
    • 500ms-1s:很慢,可能有问题
    • > 1s:严重问题,需要排查
    • > 30s:超时请求
  3. 标签(Label)设计

    • methodpath 作为标签,可以分别查看 "GET /v1/user/:uid" 和 "POST /v1/auth/login" 的指标
    • status 作为标签,可以分别统计成功和失败的登录

service/metrics.go:75-78init() 函数注册所有指标:

go 复制代码
func init() {
    prometheus.MustRegister(reqCnt, reqDur, reqInFlight, loginAttempts, userCreations, friendAdditions, friendRequestsSent, friendRequestsAccepted)
    prometheus.Register(collectors.NewGoCollector())  // Go 运行时指标(goroutine 数、GC 等)
}

collectors.NewGoCollector() 自动暴露 Go 运行时指标,包括:

  • go_goroutines:当前 goroutine 数量
  • go_gc_duration_seconds:GC 耗时
  • go_memstats_alloc_bytes:已分配的内存

service/metrics.go:87-103MetricsMiddleware 记录 HTTP 指标:

go 复制代码
func MetricsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        path := c.FullPath()  // 获取路由模板,如 /v1/user/:uid
        if path == "" {
            path = "unknown"
        }

        reqInFlight.Inc()            // 请求开始:当前处理数 +1
        start := time.Now()

        c.Next()

        status := strconv.Itoa(c.Writer.Status())
        reqCnt.WithLabelValues(c.Request.Method, path, status).Inc()  // 请求完成:计数 +1
        reqDur.WithLabelValues(c.Request.Method, path).Observe(time.Since(start).Seconds())  // 记录耗时
        reqInFlight.Dec()            // 请求完成:当前处理数 -1
    }
}

关键细节c.FullPath() 返回的是路由模板(/v1/user/:uid),而不是实际请求路径(/v1/user/42)。如果用实际路径,user_id 的高基数会导致 Prometheus 时间序列爆炸(每个用户 ID 一个时间序列)。

4.4 自定义数据库连接池指标收集器

Prometheus 默认不会暴露数据库连接池的指标。项目在 service/dbmetrics.go 中实现了一个自定义的 Collector

service/dbmetrics.go:9-20,定义了收集器结构体:

go 复制代码
type dbStatsCollector struct {
    db *sql.DB

    maxOpenDesc       *prometheus.Desc  // 最大连接数配置
    openDesc          *prometheus.Desc  // 当前打开的连接数
    inUseDesc         *prometheus.Desc  // 正在使用的连接数
    idleDesc          *prometheus.Desc  // 空闲连接数
    waitCountDesc     *prometheus.Desc  // 等待连接的次数
    waitDurDesc       *prometheus.Desc  // 等待连接的总时间
    maxIdleClosed     *prometheus.Desc  // 因超过最大空闲时间而关闭的连接数
    maxLifetimeClosed *prometheus.Desc  // 因超过最大存活时间而关闭的连接数
}

service/dbmetrics.go:79-90Collect 方法每次被 Prometheus 拉取时调用:

go 复制代码
func (c *dbStatsCollector) Collect(ch chan<- prometheus.Metric) {
    stats := c.db.Stats()

    ch <- prometheus.MustNewConstMetric(c.maxOpenDesc, prometheus.GaugeValue, float64(stats.MaxOpenConnections))
    ch <- prometheus.MustNewConstMetric(c.openDesc, prometheus.GaugeValue, float64(stats.OpenConnections))
    ch <- prometheus.MustNewConstMetric(c.inUseDesc, prometheus.GaugeValue, float64(stats.InUse))
    ch <- prometheus.MustNewConstMetric(c.idleDesc, prometheus.GaugeValue, float64(stats.Idle))
    ch <- prometheus.MustNewConstMetric(c.waitCountDesc, prometheus.CounterValue, float64(stats.WaitCount))
    ch <- prometheus.MustNewConstMetric(c.waitDurDesc, prometheus.CounterValue, stats.WaitDuration.Seconds())
    ch <- prometheus.MustNewConstMetric(c.maxIdleClosed, prometheus.CounterValue, float64(stats.MaxIdleClosed))
    ch <- prometheus.MustNewConstMetric(c.maxLifetimeClosed, prometheus.CounterValue, float64(stats.MaxLifetimeClosed))
}

service/dbmetrics.go:92-94,注册收集器:

go 复制代码
func RegisterDBMetrics(db *sql.DB) {
    prometheus.MustRegister(newDBStatsCollector(db))
}

cmd/main.go:96,调用注册:

go 复制代码
sqlDB, err := db.DB()
// ...
service.RegisterDBMetrics(sqlDB)

这些数据库指标能告诉你什么?

指标 告警条件 含义
db_connections_in_use > 40(MaxOpen 50) 连接池即将耗尽
db_connections_wait_count_total 持续增长 大量请求在等待连接
db_connections_wait_duration_seconds_total 持续增长 等待时间越来越长
db_connections_max_lifetime_closed_total 频繁变化 连接可能被频繁回收

4.5 OpenTelemetry 链路追踪

service/tracing.go:48-64InitTracerProvider 初始化链路追踪:

go 复制代码
func InitTracerProvider(serviceName string) (*sdktrace.TracerProvider, error) {
    exp, err := newTraceExporter()  // 创建导出器
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(time.Second)),  // 批量导出
        sdktrace.WithSampler(sdktrace.TraceIDRatioBased(sampleRate())),     // 采样率
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName(serviceName),
        )),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

service/tracing.go:21-30,导出器的选择逻辑:

go 复制代码
func newTraceExporter() (sdktrace.SpanExporter, error) {
    endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
    if endpoint != "" {
        return otlptracegrpc.New(context.Background(),
            otlptracegrpc.WithEndpoint(endpoint),
            otlptracegrpc.WithInsecure(),
        )
    }
    return stdouttrace.New(stdouttrace.WithWriter(os.Stdout))
}

两种导出模式

  1. 生产环境 :设置了 OTEL_EXPORTER_OTLP_ENDPOINT 环境变量 → 通过 gRPC 发送到 Jaeger/Tempo
  2. 开发环境:未设置端点 → 输出到标准输出,方便调试

service/tracing.go:32-46,采样率控制:

go 复制代码
func sampleRate() float64 {
    const defaultRate = 0.1  // 默认采样 10%
    s := os.Getenv("OTEL_TRACE_SAMPLE_RATE")
    if s == "" {
        return defaultRate
    }
    f, err := strconv.ParseFloat(s, 64)
    if err != nil {
        return defaultRate
    }
    if f < 0 || f > 1 {
        return defaultRate
    }
    return f
}

为什么需要采样?

生产环境的请求量可能非常大(如每秒 10 万 QPS)。如果每条请求都记录完整 trace,会产生海量数据。10% 的采样率意味着:

  • 100 万请求中记录 10 万条 trace
  • 仍然有足够的统计意义
  • 存储成本降低 90%

service/tracing.go:70-84,从上下文中提取 trace_id 和 span_id:

go 复制代码
func traceIDFromContext(ctx context.Context) string {
    spanCtx := trace.SpanContextFromContext(ctx)
    if spanCtx.HasTraceID() {
        return spanCtx.TraceID().String()
    }
    return ""
}

func spanIDFromContext(ctx context.Context) string {
    spanCtx := trace.SpanContextFromContext(ctx)
    if spanCtx.HasSpanID() {
        return spanCtx.SpanID().String()
    }
    return ""
}

trace_id 和 span_id 的作用

  • trace_id:贯穿整个请求链路,连接前端 → API → 数据库的所有 span
  • span_id:标识链路中的单个操作(一个 HTTP 请求、一次 SQL 查询)

4.6 将 trace_id 注入日志

这是"可观测性三件套"最关键的一步------将链路追踪的 trace_id 和 span_id 注入到日志中,让你可以根据 trace_id 在日志系统中搜索该请求的所有日志。

service/ResponseHandler.go:43-50logWithTrace 函数实现了这一功能:

go 复制代码
func logWithTrace(s *Service, ctx context.Context, format string, args ...interface{}) {
    traceID := traceIDFromContext(ctx)
    spanID := spanIDFromContext(ctx)
    if traceID != "" {
        format = "[trace_id=" + traceID + "] [span_id=" + spanID + "] " + format
    }
    s.Logger.Infof(format, args...)
}

效果对比

没有 trace_id 的日志:

复制代码
[INFO] request begin: Method: POST, request url: localhost:8080/v1/auth/login
[INFO] request body: {"name":"bob","password":"***"}
[INFO] exec end: api: /v1/auth/login, execute time: 120ms

有 trace_id 的日志:

复制代码
[INFO] [trace_id=abc123def456] [span_id=span001] request begin: Method: POST, url: localhost:8080/v1/auth/login
[INFO] [trace_id=abc123def456] [span_id=span001] request body: {"name":"bob","password":"***"}
[ERROR] [trace_id=abc123def456] [span_id=span002] [sql] UPDATE user SET ... | error: deadlock
[INFO] [trace_id=abc123def456] [span_id=span001] exec end: api: /v1/auth/login, execute time: 520ms

有了 trace_id,你可以用一条命令找到这个请求的所有日志:

bash 复制代码
grep "trace_id=abc123def456" user.log

同样的逻辑也应用在 service/middleware.go:185-208AuditLog 中间件的审计日志中:

go 复制代码
auditFormat := "[audit] request_id=%s user_id=%s method=%s path=%s status=%d"
if traceID != "" {
    auditFormat = "[trace_id=" + traceID + "] [span_id=" + spanID + "] " + auditFormat
}

以及 service/ResponseHandler.go:87-98returnError 错误日志中:

go 复制代码
func (s *Service) returnError(c *gin.Context, code int, msg string) {
    traceID := traceIDFromContext(ctx)
    spanID := spanIDFromContext(ctx)
    logFormat := "[%s] api: %s, code: %d, msg: %s"
    if traceID != "" {
        logFormat = "[trace_id=" + traceID + "] [span_id=" + spanID + "] " + logFormat
    }
    s.Logger.Errorf(logFormat, reqID, c.Request.RequestURI, code, msg)
}

4.7 Grafana 面板设计建议

基于本项目的指标,以下是推荐配置的 Grafana 面板:

面板 1:HTTP 请求概览

复制代码
指标:rate(http_requests_total[5m])
图表类型:时间序列图
按:path 分组

面板 2:请求延迟分布

复制代码
指标:histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
图表类型:时间序列图
按:path 分组

解释:histogram_quantile(0.99, ...) 计算 P99 延迟(99% 的请求在此时间内完成)。

面板 3:数据库连接池健康

复制代码
面板 3a:db_connections_in_use(折线图)
面板 3b:db_connections_wait_count_total(折线图)
如果 in_use > 40 或 wait_count 持续增长 → 需要扩容或优化查询

面板 4:登录成功率

复制代码
指标:rate(login_attempts_total{status="success"}[5m]) / rate(login_attempts_total[5m])
图表类型:Stat(百分比显示)
告警:< 95% 时触发

面板 5:当前请求并发数

复制代码
指标:http_requests_in_flight
图表类型:Stat(数字显示)
告警:> 100 时触发

五、日志记录最佳实践

基于本项目的日志使用方式,总结几条最佳实践:

5.1 日志级别使用规范

级别 使用场景 示例
Debug 开发调试信息,生产不记录 SQL 参数详情、中间变量
Info 重要的业务流程节点 用户登录、请求开始/结束、审计日志
Warn 潜在问题但不影响服务 Redis 不可用降级、连接重试
Error 需要人工介入的错误 数据库写入失败、认证失败

5.2 日志脱敏

如第 2 篇文章中提到的,sanitizeBody 函数确保敏感信息不会出现在日志中:

go 复制代码
// 错误:日志中不要打印 raw 请求体
log.Infof("request body: %s", rawBody)

// 正确:先脱敏再打印
log.Infof("request body: %s", sanitizeBody(rawBody))

5.3 可搜索性

日志应包含足够的元数据以便搜索:

复制代码
// 不好的日志
log.Error("failed")

// 好的日志
log.Errorf("[trace_id=%s] user_id=%d api=%s error=%v", traceID, userID, api, err)

六、总结

回顾可观测性三件套的完整体系:

复制代码
                                    ┌──────────────────┐
                                    │   业务代码        │
                                    │   (Service 层)    │
                                    └──┬───────┬──────┬┘
                                       │       │      │
                            ┌──────────▼┐ ┌───▼──┐ ┌─▼──────────┐
                            │  日志系统  │ │指标  │ │ 链路追踪   │
                            │ Zap+      │ │系统   │ │ OpenTele-  │
                            │ Lumberjack│ │Promet-│ │ metry      │
                            └─────┬─────┘ │heus   │ └─┬──────────┘
                                  │       └──┬───┘   │
                                  ▼          ▼        ▼
                            ┌─────────┐ ┌────────┐ ┌──────┐
                            │  ELK /  │ │Grafana │ │Jaeger│
                            │  Loki   │ │        │ │Tempo │
                            └─────────┘ └────────┘ └──────┘

核心实现要点

  1. 结构化日志:Zap JSON 输出 + Lumberjack 自动轮转,支持按日志级别动态切换
  2. Prometheus 指标:Counter(登录次数)+ Gauge(并发请求数)+ Histogram(延迟分布)+ 自定义 DB 连接池 Collector
  3. 链路追踪:OpenTelemetry 自动注入 trace_id/span_id 到日志,支持 Jaeger 导出和采样率控制
  4. 三件套关联 :通过 trace_idspan_id 将日志、指标、追踪三者串联

这套可观测性体系投入生产后,我们排查故障的平均时间从 3 小时降到了 10 分钟。不是因为问题变少了,而是因为"看清发生了什么"变得简单了。


完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:

① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性

⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash

⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile