Web 平台开发日记 - 可观测性实践

14 阅读12分钟

Web 平台开发日记 - 可观测性实践

核心内容: Prometheus 监控集成、健康检查、请求追踪、结构化日志、可观测性体系 技术栈: Go + Gin + Prometheus + Correlation ID + Structured Logging


📋 目录

  1. 目标
  2. 可观测性架构
  3. Prometheus 指标集成
  4. 健康检查实现
  5. Correlation ID 请求追踪
  6. 结构化日志系统

🎯 目标

  • Prometheus 指标收集与暴露
  • Health/Readiness 探针实现
  • Correlation ID 请求追踪
  • 结构化日志(JSON 格式)
  • 完整的验收测试体系
  • 监控栈配置(Prometheus + Grafana)

核心价值

  1. 可观测性 - 实时掌握系统运行状态
  2. 故障诊断 - 快速定位和排查问题
  3. 请求追踪 - 跨服务的端到端追踪
  4. 生产就绪 - 符合企业级运维标准

项目 GitHub 地址:github.com/Mythetic/we…


🏗️ 可观测性架构

三大支柱

现代应用的可观测性(Observability)由三大支柱构成:

┌─────────────────────────────────────────────────────────────┐
│                     可观测性三大支柱                          │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  📊 Metrics (指标)          📝 Logs (日志)         🔍 Traces (追踪)  │
│  ────────────────          ───────────────         ────────────────  │
│  • 系统性能指标            • 应用运行日志           • 请求调用链路    │
│  • HTTP 请求计数           • 错误详细信息           • 跨服务追踪      │
│  • 响应时间分布            • 业务操作记录           • 性能瓶颈定位    │
│  • 资源使用率              • 结构化输出             • 依赖关系分析    │
│                                                               │
│  工具: Prometheus          工具: ELK/Loki           工具: Jaeger     │
│                                                               │
└─────────────────────────────────────────────────────────────┘

本章实现架构

┌────────────────────────────────────────────────────────────┐
│                        用户请求                             │
└──────────────────────┬─────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                   Gin 中间件层                               │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
│  │ CorrelationID│→ │StructuredLog │→ │PrometheusMetrics│   │
│  │  生成请求ID   │  │  JSON日志    │  │   收集指标    │      │
│  └──────────────┘  └──────────────┘  └──────────────┘      │
└─────────────────────────────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                    业务处理层                                │
│  • API Handlers                                             │
│  • Business Logic                                           │
│  • Database Access                                          │
└─────────────────────────────────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
┌──────────────┐ ┌──────────┐ ┌─────────────┐
│ /metrics     │ │ /health  │ │ server.log  │
│ (Prometheus) │ │ (K8s)    │ │ (JSON)      │
└──────┬───────┘ └────┬─────┘ └──────┬──────┘
       │              │               │
       ▼              ▼               ▼
┌──────────────┐ ┌──────────┐ ┌─────────────┐
│ Prometheus   │ │ LoadBalancer│ │ LogAggregator│
│   Server     │ │ HealthCheck │ │  (ELK/Loki) │
└──────┬───────┘ └──────────┘ └─────────────┘
       │
       ▼
┌──────────────┐
│   Grafana    │
│  Dashboard   │
└──────────────┘

数据流向

用户请求 → CorrelationID中间件(生成UUID)
         ↓
         StructuredLogger中间件(记录请求信息)
         ↓
         PrometheusMetrics中间件(开始计时、增加并发计数)
         ↓
         业务Handler处理
         ↓
         PrometheusMetrics中间件(记录延迟、状态码、递减并发)
         ↓
         StructuredLogger中间件(记录响应信息)
         ↓
         返回响应(携带 X-Request-ID header

📊 Prometheus 指标集成

为什么需要 Prometheus?

问题场景

  • ❓ 系统现在有多少并发请求?
  • ❓ API 响应时间是否正常?
  • ❓ 哪些接口最慢?
  • ❓ 错误率是否在增加?

Prometheus 的答案

  • ✅ 实时采集应用指标
  • ✅ 时间序列数据存储
  • ✅ 强大的查询语言(PromQL)
  • ✅ 图形化展示(Grafana)

指标类型设计

server/middleware/metrics.go 中定义了三类核心指标:

1. HTTP 请求计数(Counter)
var httpRequestsTotal = promauto.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total number of HTTP requests",
    },
    []string{"method", "path", "status"},
)

用途:统计每个接口的总请求次数,按 HTTP 方法、路径、状态码分类。

查询示例

# 查看所有接口的请求总数
sum(http_requests_total)

# 查看错误请求(5xx)
sum(http_requests_total{status=~"5.."})

# 查看登录接口的成功率
rate(http_requests_total{path="/base/login",status="200"}[5m])
2. HTTP 请求延迟(Histogram)
var httpRequestDuration = promauto.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request latency in seconds",
        Buckets: prometheus.DefBuckets, // [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    },
    []string{"method", "path"},
)

用途:记录接口响应时间的分布情况,支持百分位数计算(P50、P95、P99)。

查询示例

# 查看 API 的 P95 延迟(95% 的请求在这个时间内完成)
histogram_quantile(0.95, http_request_duration_seconds_bucket)

# 查看平均响应时间
rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])

# 查看慢接口(>1秒)
histogram_quantile(0.99, http_request_duration_seconds_bucket{path="/api/some-slow-endpoint"})
3. HTTP 并发请求数(Gauge)
var httpRequestsInFlight = promauto.NewGauge(
    prometheus.GaugeOpts{
        Name: "http_requests_in_flight",
        Help: "Current number of HTTP requests being served",
    },
)

用途:实时显示当前正在处理的请求数量。

查询示例

# 查看当前并发数
http_requests_in_flight

# 查看最近 5 分钟的最大并发数
max_over_time(http_requests_in_flight[5m])

中间件实现

func PrometheusMetrics() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 跳过 metrics 端点本身(避免递归)
        if c.Request.URL.Path == "/metrics" {
            c.Next()
            return
        }
        
        // 1. 增加并发计数
        httpRequestsInFlight.Inc()
        defer httpRequestsInFlight.Dec()
        
        // 2. 记录开始时间
        start := time.Now()
        
        // 3. 执行业务逻辑
        c.Next()
        
        // 4. 计算请求耗时
        duration := time.Since(start).Seconds()
        
        // 5. 收集指标
        status := strconv.Itoa(c.Writer.Status())
        method := c.Request.Method
        path := c.FullPath() // 使用路由路径而不是原始URL(避免高基数)
        
        httpRequestsTotal.WithLabelValues(method, path, status).Inc()
        httpRequestDuration.WithLabelValues(method, path).Observe(duration)
    }
}

关键设计考虑

  1. 避免高基数问题

    • ✅ 使用 c.FullPath() 而不是 c.Request.URL.Path
    • 原因:路由路径固定(如 /api/user/:id),而实际 URL 可能有无数个(/api/user/1, /api/user/2, ...)
    • 高基数会导致 Prometheus 内存暴涨
  2. 跳过 /metrics 端点

    • 避免 Prometheus 抓取自身指标时产生递归记录
    • 减少无意义的指标数据
  3. 使用 defer 确保计数正确

    • 即使请求 panic,并发计数也会正确递减

Metrics 端点暴露

// server/api/v1/system/metrics.go
type MetricsApi struct{}

func (m *MetricsApi) GetMetrics(c *gin.Context) {
    handler := promhttp.Handler()
    handler.ServeHTTP(c.Writer, c.Request)
}

// server/initialize/router.go
metricsApi := &system.MetricsApi{}
router.GET("/metrics", metricsApi.GetMetrics)

访问 http://localhost:8888/metrics 可以看到:

# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/health",status="200"} 145
http_requests_total{method="POST",path="/base/login",status="200"} 23
http_requests_total{method="GET",path="/api/user/getList",status="200"} 67

# HELP http_request_duration_seconds HTTP request latency in seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",path="/api/health",le="0.005"} 142
http_request_duration_seconds_bucket{method="GET",path="/api/health",le="0.01"} 145
http_request_duration_seconds_bucket{method="GET",path="/api/health",le="+Inf"} 145
http_request_duration_seconds_sum{method="GET",path="/api/health"} 0.523
http_request_duration_seconds_count{method="GET",path="/api/health"} 145

# HELP http_requests_in_flight Current number of HTTP requests being served
# TYPE http_requests_in_flight gauge
http_requests_in_flight 2

Prometheus 配置

deploy/monitoring/prometheus.yml 中配置抓取任务:

scrape_configs:
  - job_name: 'ewp-backend'
    static_configs:
      - targets: ['host.containers.internal:8888']
    metrics_path: '/metrics'
    scrape_interval: 15s  # 每 15 秒抓取一次
  • host.containers.internal 是 Podman 访问宿主机的特殊域名
  • 容器内的 Prometheus 通过这个域名连接到宿主机的 8888 端口
  • 在生产环境中,应该使用服务发现(Kubernetes Service、Consul 等)

🏥 健康检查实现

为什么需要健康检查?

场景

  • Kubernetes 需要知道 Pod 是否存活(Liveness)
  • 负载均衡器需要知道实例是否就绪(Readiness)
  • 运维人员需要快速判断服务状态

Liveness Probe - 存活探针

用途:判断应用进程是否存活,如果失败,Kubernetes 会重启 Pod。

// server/api/v1/system/health.go
func (h *HealthApi) GetHealth(c *gin.Context) {
    response.OkWithData(gin.H{
        "status":    "ok",
        "timestamp": time.Now().Format(time.RFC3339),
    }, c)
}

API 返回

GET /api/health

{
  "code": 0,
  "data": {
    "status": "ok",
    "timestamp": "2026-01-05T10:15:30Z"
  },
  "msg": "success"
}

Kubernetes 配置示例

livenessProbe:
  httpGet:
    path: /api/health
    port: 8888
  initialDelaySeconds: 30  # 启动后 30 秒开始检查
  periodSeconds: 10        # 每 10 秒检查一次
  timeoutSeconds: 5        # 超时时间 5 秒
  failureThreshold: 3      # 连续失败 3 次才重启

Readiness Probe - 就绪探针

用途:判断应用是否准备好接收流量,如果失败,负载均衡器会摘除这个实例。

func (h *HealthApi) GetReadiness(c *gin.Context) {
    checks := make(map[string]string)
    allHealthy := true

    // 1. 检查 MySQL 连接
    if err := checkMySQLConnection(); err != nil {
        checks["mysql"] = "error: " + err.Error()
        allHealthy = false
    } else {
        checks["mysql"] = "ok"
    }

    // 2. 检查 Redis 连接
    if err := checkRedisConnection(); err != nil {
        checks["redis"] = "error: " + err.Error()
        allHealthy = false
    } else {
        checks["redis"] = "ok"
    }

    // 3. 返回结果
    if allHealthy {
        response.OkWithData(gin.H{
            "status": "ready",
            "checks": checks,
        }, c)
    } else {
        c.JSON(503, gin.H{
            "code":   503,
            "status": "not ready",
            "checks": checks,
        })
    }
}

API 返回示例

成功时(HTTP 200):

{
  "code": 0,
  "data": {
    "status": "ready",
    "checks": {
      "mysql": "ok",
      "redis": "ok"
    }
  }
}

失败时(HTTP 503):

{
  "code": 503,
  "status": "not ready",
  "checks": {
    "mysql": "error: connection refused",
    "redis": "ok"
  }
}

Kubernetes 配置示例

readinessProbe:
  httpGet:
    path: /api/ready
    port: 8888
  initialDelaySeconds: 10   # 启动后 10 秒开始检查
  periodSeconds: 5          # 每 5 秒检查一次
  timeoutSeconds: 3         # 超时时间 3 秒
  successThreshold: 1       # 成功 1 次即认为就绪
  failureThreshold: 3       # 连续失败 3 次才摘除

健康检查实现细节

// 检查 MySQL 连接
func checkMySQLConnection() error {
    if global.EWP_DB == nil {
        return fmt.Errorf("Database connection not initialized")
    }
    
    sqlDB, err := global.EWP_DB.DB()
    if err != nil {
        return err
    }
    
    // 执行一个简单的查询来验证连接
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    
    return sqlDB.PingContext(ctx)
}

// 检查 Redis 连接
func checkRedisConnection() error {
    if global.EWP_REDIS == nil {
        return fmt.Errorf("Redis connection not initialized")
    }
    
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    
    return global.EWP_REDIS.Ping(ctx).Err()
}

关键设计

  1. 超时控制:每个检查都设置 2 秒超时,避免阻塞
  2. 依赖检查:只有所有依赖都健康,才返回就绪状态
  3. 详细反馈:返回每个依赖的具体状态,方便排查

🔍 Correlation ID 请求追踪

为什么需要 Correlation ID?

问题场景

  • 用户报告"登录失败",但日志里有成千上万条记录,如何找到这个用户的请求?
  • 一个请求经过了多个微服务,如何追踪完整的调用链路?
  • 如何将前端错误、后端日志、数据库慢查询关联起来?

Correlation ID 的答案

  • 为每个请求分配唯一的 UUID
  • 贯穿请求的整个生命周期
  • 记录在日志、响应头、调用链中
  • 支持分布式追踪

实现方式

// server/middleware/correlation.go
const CorrelationIDKey = "X-Request-ID"

func CorrelationID() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. 尝试从请求头获取 Correlation ID
        correlationID := c.GetHeader(CorrelationIDKey)
        
        // 2. 如果没有,生成新的 UUID
        if correlationID == "" {
            correlationID = uuid.New().String()
        }
        
        // 3. 存储到 Gin Context(供其他中间件使用)
        c.Set(CorrelationIDKey, correlationID)
        
        // 4. 设置响应头(返回给客户端)
        c.Writer.Header().Set(CorrelationIDKey, correlationID)
        
        c.Next()
    }
}

使用场景

场景 1:单次请求追踪
# 客户端发起请求(不带 Request ID)
curl -i http://localhost:8888/api/health

# 响应头包含自动生成的 Request ID
HTTP/1.1 200 OK
X-Request-ID: 3c5f6a8b-1e2d-4f9a-b3c7-8d6e5f4a9b2c
Content-Type: application/json
...

后端日志中可以看到:

{
  "correlation_id": "3c5f6a8b-1e2d-4f9a-b3c7-8d6e5f4a9b2c",
  "method": "GET",
  "path": "/api/health",
  "status": 200,
  "duration": "2.5ms"
}
场景 2:请求链传播
# 客户端主动带上 Request ID(用于追踪)
curl -H "X-Request-ID: my-custom-request-id" http://localhost:8888/api/user/getList

# 响应会保持相同的 Request ID
HTTP/1.1 200 OK
X-Request-ID: my-custom-request-id
...

分布式场景

前端 (Request ID: ABC123)
  ↓
API Gateway (透传 ABC123)
  ↓
User Service (使用 ABC123 记录日志)
  ↓ 调用数据库时在 SQL 注释中包含 ABC123
  ↓
MySQL Slow Query Log (/* RequestID: ABC123 */ SELECT ...)
场景 3:日志聚合与搜索

在 ELK/Loki 中搜索:

# 搜索某个请求的所有日志
correlation_id:"3c5f6a8b-1e2d-4f9a-b3c7-8d6e5f4a9b2c"

# 结果:
# [Service A] 接收请求
# [Service A] 调用 Service B
# [Service B] 查询数据库
# [Service B] 返回结果
# [Service A] 返回响应
  1. 客户端支持:前端应该在重试、长轮询时保持相同的 Request ID
  2. 下游传播:调用其他服务时,必须传递 Correlation ID
  3. 数据库注释:在 SQL 查询中添加注释 /* RequestID: xxx */
  4. 错误报告:错误信息中包含 Correlation ID,方便用户反馈时快速定位

📝 结构化日志系统

为什么需要结构化日志?

传统文本日志的问题

2026-01-05 10:15:30 [INFO] User login from IP 192.168.1.100
2026-01-05 10:15:31 [INFO] API /api/user/getList took 45ms, status=200
  • ❌ 难以解析和搜索
  • ❌ 没有统一格式
  • ❌ 缺少关键信息(如 Request ID)
  • ❌ 无法高效聚合分析

结构化日志(JSON)的优势

{
  "timestamp": "2026-01-05T10:15:30Z",
  "level": "info",
  "correlation_id": "3c5f6a8b-1e2d-4f9a-b3c7-8d6e5f4a9b2c",
  "method": "GET",
  "path": "/api/user/getList",
  "status": 200,
  "duration": "45ms",
  "duration_ms": 45,
  "ip": "192.168.1.100",
  "user_agent": "Mozilla/5.0...",
  "user_id": "123"
}
  • ✅ 机器可读,易于解析
  • ✅ 字段统一,便于搜索
  • ✅ 包含完整上下文
  • ✅ 支持高效聚合查询

实现方式

// server/middleware/logger.go
func StructuredLogger() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. 记录开始时间
        start := time.Now()
        
        // 2. 执行业务逻辑
        c.Next()
        
        // 3. 计算请求耗时
        duration := time.Since(start)
        
        // 4. 获取 Correlation ID
        correlationID, _ := c.Get(CorrelationIDKey)
        
        // 5. 获取用户信息(如果已认证)
        userID := ""
        if claims, exists := c.Get("claims"); exists {
            if jwtClaims, ok := claims.(*systemReq.CustomClaims); ok {
                userID = strconv.Itoa(int(jwtClaims.BaseClaims.ID))
            }
        }
        
        // 6. 构造结构化日志
        logData := map[string]interface{}{
            "timestamp":      time.Now().Format(time.RFC3339),
            "correlation_id": correlationID,
            "method":         c.Request.Method,
            "path":           c.Request.URL.Path,
            "status":         c.Writer.Status(),
            "duration":       duration.String(),
            "duration_ms":    duration.Milliseconds(),
            "ip":             c.ClientIP(),
            "user_agent":     c.Request.UserAgent(),
        }
        
        if userID != "" {
            logData["user_id"] = userID
        }
        
        // 7. 输出 JSON 日志
        logJSON, _ := json.Marshal(logData)
        global.EWP_LOG.Info(string(logJSON))
    }
}

日志字段说明

字段类型说明示例
timestampstring日志时间(ISO 8601)2026-01-05T10:15:30Z
correlation_idstring请求追踪 ID3c5f6a8b-1e2d-4f9a...
methodstringHTTP 方法GET, POST
pathstring请求路径/api/user/getList
statusintHTTP 状态码200, 404, 500
durationstring人类可读的耗时45ms, 1.2s
duration_msint毫秒数(便于聚合)45, 1200
ipstring客户端 IP192.168.1.100
user_agentstring浏览器标识Mozilla/5.0...
user_idstring用户 ID(如已登录)123

日志查询示例

在 ELK 中查询

// 查询某个用户的所有请求
user_id:"123"

// 查询慢请求(>1秒)
duration_ms:>1000

// 查询错误请求
status:>=500

// 查询某个时间段的请求
timestamp:[2026-01-05T10:00:00Z TO 2026-01-05T11:00:00Z]

// 聚合分析:统计各状态码的数量
{
  "aggs": {
    "status_codes": {
      "terms": { "field": "status" }
    }
  }
}

在 Loki 中查询

# 查询某个 Request ID 的所有日志
{job="ewp-backend"} | json | correlation_id="3c5f6a8b-1e2d-4f9a-b3c7-8d6e5f4a9b2c"

# 统计每分钟的请求数
sum(rate({job="ewp-backend"}[1m]))

# 查询 P99 响应时间
histogram_quantile(0.99, sum(rate({job="ewp-backend"} | json | __error__="" | unwrap duration_ms [5m])) by (le))

日志级别规范

// 不同场景使用不同日志级别
global.EWP_LOG.Debug(logJSON)   // 调试信息(生产环境不输出)
global.EWP_LOG.Info(logJSON)    // 正常请求(我们的选择)
global.EWP_LOG.Warn(logJSON)    // 警告信息(如慢查询)
global.EWP_LOG.Error(logJSON)   // 错误信息(如 5xx)
global.EWP_LOG.Fatal(logJSON)   // 致命错误(进程退出)

日志级别选择

  • Info:正常的 HTTP 请求(200, 201, 204)
  • Warn:可能有问题的请求(401, 403, 404, 请求超时)
  • Error:服务器错误(500, 502, 503, panic)

后续优化方向

1. 监控告警
# Prometheus 告警规则示例
groups:
  - name: ewp_alerts
    rules:
      - alert: HighErrorRate
        expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
        for: 5m
        annotations:
          summary: "High error rate detected"
          
      - alert: HighLatency
        expr: histogram_quantile(0.95, http_request_duration_seconds_bucket) > 1
        for: 5m
        annotations:
          summary: "API latency P95 > 1s"
2. 分布式追踪

集成 Jaeger 实现完整的分布式追踪:

// 使用 OpenTelemetry 标准
import "go.opentelemetry.io/otel"

func TracingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, span := tracer.Start(c.Request.Context(), c.FullPath())
        defer span.End()
        
        // 传播 Trace Context
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}
3. 日志聚合

将日志发送到 ELK 或 Loki:

# Promtail 配置(Loki)
clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: ewp-backend
    static_configs:
      - targets:
          - localhost
        labels:
          job: ewp-backend
          __path__: /path/to/server.log

📚 相关文档

技术文档

Kubernetes 健康检查

可观测性理论

🔗 项目地址