Web 平台开发日记 - 可观测性实践
核心内容: Prometheus 监控集成、健康检查、请求追踪、结构化日志、可观测性体系 技术栈: Go + Gin + Prometheus + Correlation ID + Structured Logging
📋 目录
🎯 目标
- Prometheus 指标收集与暴露
- Health/Readiness 探针实现
- Correlation ID 请求追踪
- 结构化日志(JSON 格式)
- 完整的验收测试体系
- 监控栈配置(Prometheus + Grafana)
核心价值:
- 可观测性 - 实时掌握系统运行状态
- 故障诊断 - 快速定位和排查问题
- 请求追踪 - 跨服务的端到端追踪
- 生产就绪 - 符合企业级运维标准
项目 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)
}
}
关键设计考虑:
-
避免高基数问题:
- ✅ 使用
c.FullPath()而不是c.Request.URL.Path - 原因:路由路径固定(如
/api/user/:id),而实际 URL 可能有无数个(/api/user/1,/api/user/2, ...) - 高基数会导致 Prometheus 内存暴涨
- ✅ 使用
-
跳过 /metrics 端点:
- 避免 Prometheus 抓取自身指标时产生递归记录
- 减少无意义的指标数据
-
使用
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()
}
关键设计:
- 超时控制:每个检查都设置 2 秒超时,避免阻塞
- 依赖检查:只有所有依赖都健康,才返回就绪状态
- 详细反馈:返回每个依赖的具体状态,方便排查
🔍 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] 返回响应
- 客户端支持:前端应该在重试、长轮询时保持相同的 Request ID
- 下游传播:调用其他服务时,必须传递 Correlation ID
- 数据库注释:在 SQL 查询中添加注释
/* RequestID: xxx */ - 错误报告:错误信息中包含 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))
}
}
日志字段说明
| 字段 | 类型 | 说明 | 示例 |
|---|---|---|---|
timestamp | string | 日志时间(ISO 8601) | 2026-01-05T10:15:30Z |
correlation_id | string | 请求追踪 ID | 3c5f6a8b-1e2d-4f9a... |
method | string | HTTP 方法 | GET, POST |
path | string | 请求路径 | /api/user/getList |
status | int | HTTP 状态码 | 200, 404, 500 |
duration | string | 人类可读的耗时 | 45ms, 1.2s |
duration_ms | int | 毫秒数(便于聚合) | 45, 1200 |
ip | string | 客户端 IP | 192.168.1.100 |
user_agent | string | 浏览器标识 | Mozilla/5.0... |
user_id | string | 用户 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
📚 相关文档
技术文档
- Prometheus 官方文档 - 指标收集与监控
- Prometheus 最佳实践 - 指标命名规范
- OpenTelemetry Go SDK - 分布式追踪标准
- Structured Logging in Go - Zap 日志库
Kubernetes 健康检查
- Configure Liveness, Readiness Probes - K8s 探针配置
- Health Check Best Practices - Google 最佳实践
可观测性理论
- The Three Pillars of Observability - O'Reilly 可观测性理论
- Logs vs Metrics vs Traces - 三者的区别与联系
🔗 项目地址
- GitHub: github.com/Mythetic/we…