监控埋点与可观测性

2023-01-20T14:11:02+08:00 | 18分钟阅读 | 更新于 2026-01-20T14:11:02+08:00

@

学习目标

  • 理解可观测性「三支柱」:日志(Logging)、指标(Metrics)、链路追踪(Tracing)各自的定位与互补关系
  • 掌握 Prometheus 的四种指标类型(Counter / Gauge / Histogram / Summary)及适用场景
  • 学会用 Prometheus Go SDK + Gin middleware + GORM Callback 完成系统出入口和数据库的指标埋点
  • 掌握 OpenTelemetry 的核心抽象(Tracer / Span / Propagator / Exporter)及其与 context.Context 的协作方式
  • 学会用 Grafana 配置仪表盘与告警(Contact Point、阈值告警、慢查询/慢请求/异常请求告警)

前置知识(先看这部分,避免中途卡壳):

  • 已学完前面的分层架构、Gin 中间件、GORM 监控基础。
  • 知道 Docker / Docker Compose 怎么起服务(Prometheus、Kafka 都用过)。
  • 知道 HTTP 请求从进到出的流程(前面 Gin 讲过)。
  • 不用先会 Grafana、不用先会 OTel,本章从零讲。

本章你会动手做的事

  • 给自己的 Gin 服务加一个 Prometheus middleware,本地访问几个接口看 /metrics 里出现 P99。
  • 用 OTel 给一个函数手动打 Span,在 Zipkin 里看到它的耗时横条。
  • 在 Grafana 配一条"活跃请求数超阈值持续 N 分钟"的告警,绑到企业微信机器人。

一、可观测性三支柱

生活类比:可观测性就像给一辆车上"仪表盘 + 行车记录仪 + 导航轨迹"。仪表盘(Metrics)告诉你"车速、油量"这种整体数字,一眼看出不对劲;导航轨迹(Tracing)记录"你从哪拐到哪",出问题能定位到哪一段路;行车记录仪(Logging)记下"具体发生了什么事故"。三者配合,你才能在车坏了时既知道"坏了",又知道"坏在哪、怎么坏的"。

可观测性(Observability)= 通过外部行为推断系统内部状态的能力。在分布式系统里,它由三部分组成:

支柱解决什么典型工具数据特征
Logging 日志「发生了什么」ELK、Loki离散事件,可读性强,量大
Metrics 指标「整体表现如何」Prometheus、Grafana时序数据,可聚合,低成本
Tracing 链路追踪「请求怎么走的」Jaeger、Zipkin、OpenTelemetry树形结构,跨服务

一张图看三支柱怎么配合定位问题:

flowchart TD
    A[系统报警] --> M[Metrics 看整体
发现 P99 飙升] M --> T[Tracing 定位
慢在哪一跳] T --> L[Logging 查细节
具体错误是什么]

三者关系:Metrics 告诉你「有问题」,Tracing 告诉你「问题在哪一段」,Logging 告诉你「具体的错误是什么」。一个完整的可观测性体系三者缺一不可。


二、Prometheus 基础

2.1 拉模型(Pull Model)

Prometheus 采用服务端主动拉取的方式采集指标,跟常见的「客户端推」不同。

工作流程:

  1. 应用暴露一个 /metrics 接口(通常在独立端口,比如 :8081/metrics
  2. Prometheus server 按配置的 scrape_interval 定时去拉
  3. 数据存在 Prometheus 自带的时序数据库
  4. Grafana 等可视化工具查询 Prometheus 展示

工程视角:为什么是"拉"而不是"推"?推模型里每个应用要配置"往哪推、推失败怎么办",服务多了配置爆炸;拉模型下 Prometheus 统一管"去哪些地址采",应用只管暴露 /metrics,新增服务只要在 Prometheus 配一个 target。而且拉模型天然带"健康检查"——采不到就说明实例挂了。代价是 Prometheus 要自己扛采集压力,超大规模时得用分片(Thanos / Cortex)。

一张图看清 Prometheus 的"拉模型"数据流:

flowchart LR
    App[应用暴露 /metrics] -->|Prometheus 定时拉取| P[(Prometheus
时序数据库)] P --> G[Grafana 查询展示] P --> A[告警规则评估]

2.2 Docker 安装 Prometheus

# docker-compose.yaml
services:
  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

2.3 最小配置

# prometheus.yml
global:
  scrape_interval: 15s       # 全局采集间隔,高并发应用不宜太频繁
  evaluation_interval: 15s   # 告警规则评估间隔

scrape_configs:
  - job_name: "webook"
    static_configs:
      - targets: ["host.docker.internal:8081"]  # 应用 /metrics 端口

2.4 Go 应用暴露指标

package main

import (
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "net/http"
)

func main() {
    // ...业务 server 启动 :8080
    // 单独起一个 server 暴露 metrics,避免业务请求干扰
    go func() {
        http.Handle("/metrics", promhttp.Handler())
        http.ListenAndServe(":8081", nil)
    }()
}

浏览器访问 localhost:8081/metrics 可以看到 Prometheus 自动采集的 Go runtime 指标(goroutine 数、GC 耗时、内存等)。


三、Prometheus 四种指标类型

一张图先区分四种指标的"性格":

flowchart TD
    C[Counter
只增不减
累计次数] G[Gauge
可增可减
瞬时值] H[Histogram
分桶计数
算分位/P99] S[Summary
直接算分位
不可跨实例聚合]

3.1 Counter 计数器

只能递增(除非重启归零),适合统计累计次数。

典型场景:订单创建总数、HTTP 请求总数、错误发生次数。

生活类比:Counter 像"里程表"——车开得越多数字越大,你不会把里程表往回拨。所以它适合记"累计发生了多少次",配合 rate() 求"每秒多少次",就能得到 QPS。千万别用 Counter 记"当前在线人数"这种会降的值,那是 Gauge 的活。

import "github.com/prometheus/client_golang/prometheus"

orderCounter := prometheus.NewCounter(prometheus.CounterOpts{
    Namespace: "shop",          // 部门 / 大业务
    Subsystem: "order",         // 子系统 / 模块
    Name:      "created_total", // 指标名,Counter 习惯加 _total 后缀
    Help:      "累计创建的订单数",
})
prometheus.MustRegister(orderCounter)

// 业务调用
orderCounter.Add(1) // 或 Inc()

3.2 Gauge 度量

可增可减,反映瞬时值。

典型场景:当前在线人数、当前活跃请求数、数据库连接数、goroutine 数。

生活类比:Gauge 像"温度计"——随时在变,可高可低。所以"当前正在处理的请求数"用 Gauge 最贴切:Inc() 请求进来、defer Dec() 请求结束,你就能实时看到系统承压情况。它和 Counter 的区别就一句话:Counter 只上不下,Gauge 上下都行。

activeReqGauge := prometheus.NewGauge(prometheus.GaugeOpts{
    Namespace: "webook",
    Subsystem: "http",
    Name:      "active_requests",
    Help:      "当前正在处理的 HTTP 请求数",
})

// middleware 里:
activeReqGauge.Inc()  // 请求进来 +1
defer activeReqGauge.Dec() // 请求结束 -1

3.3 Histogram 柱状图

采样分桶,把观测值分到一个个 bucket 里。适合分类分析。

典型场景:错误码分布、按业务类型统计、响应时间分布(按区间)。

// Buckets 是「上界」集合,例如 0.01, 0.05, 0.1, 0.5, 1, 5 表示
// <= 10ms 的个数、<= 50ms 的个数、...
// 最后一个隐藏桶是 +Inf
hist := prometheus.NewHistogram(prometheus.HistogramOpts{
    Namespace: "webook",
    Subsystem: "db",
    Name:      "query_duration_seconds",
    Help:      "数据库查询耗时分布",
    Buckets:   []float64{0.01, 0.05, 0.1, 0.5, 1, 5},
})

hist.Observe(0.12) // 记录一次 120ms 的查询

Bucket 设置原则: 让落到各区间内的数据量在同一量级,比等间距更有意义。响应时间这种长尾分布通常用非等间距桶。

3.4 Summary 分位数

直接计算分位数,比 Histogram 精确但聚合性差。

典型场景:响应时间 P99、P999。

summary := prometheus.NewSummary(prometheus.SummaryOpts{
    Namespace:  "webook",
    Subsystem:  "http",
    Name:       "request_duration_seconds",
    Help:       "HTTP 请求耗时",
    Objectives: map[float64]float64{
        0.5:    0.01,   // P50,误差 1%
        0.9:    0.005,  // P90,误差 0.5%
        0.99:   0.001,  // P99,误差 0.1%
        0.999:  0.0001, // P999,误差 0.01%
    },
})

summary.Observe(0.128) // 记录一次 128ms 请求

⚠️ 新手必踩的坑:多实例部署别用 Summary 算全局 P99。Summary 在每个实例本地独立算分位数,不能跨实例聚合——你有三台机器各自算出 P99,没法正确合并成一个全局 P99。Histogram 用分桶存储,可以用 histogram_quantile 在 Prometheus 服务端把所有实例的桶汇总后再算分位,结果才对。所以多实例场景优先 Histogram。

Summary vs Histogram: Summary 不能跨实例聚合(每个实例独立算分位),Histogram 可以(用 histogram_quantile 在服务端计算)。所以多实例场景优先 Histogram。

3.5 Vector 向量:带 label 的指标

实际业务几乎都用 Vector 形式,可以按 label 分维度统计:

// SummaryVec:带 label 的 Summary
httpDuration := prometheus.NewSummaryVec(prometheus.SummaryOpts{
    Namespace: "webook",
    Subsystem: "http",
    Name:      "request_duration_seconds",
    Objectives: map[float64]float64{0.99: 0.001},
}, []string{"pattern", "method", "status"}) // 三个动态 label

// 记录:当次请求 pattern=/user/:id, method=POST, status=200, 耗时 128ms
httpDuration.With(prometheus.Labels{
    "pattern": "/user/:id",
    "method":  "POST",
    "status":  "200",
}).Observe(0.128)

label 设计原则:

  • 固定 label:所有业务取值都一样,用于全局筛选(如 app="webook"
  • 动态 label:取值随业务变化(如 statusmethod
  • 基数控制:label 取值种类不能太多,否则 Prometheus 内存爆炸。绝对不要把 user_id 当 label。

⚠️ 新手必踩的坑:把高基数字段当 label 会拖垮 Prometheus。如果你用 user_id 当 label,假设有 1000 万用户,Prometheus 就要为这个指标维护 1000 万个时间序列,内存直接爆炸,server 可能 OOM 挂掉。label 只放"取值种类有限"的维度(如 statusmethodpattern)。需要按用户维度查询时,应该走日志或专门的 OLAP 库,而不是指标。


四、namespace / subsystem / name 设计

namespace + subsystem + name 能快速定位到具体业务即可:

公司组织namespacesubsystemname
按部门部门名子系统数据名
按小组小组名系统/模块数据名

例如 shop_order_created_totalwebook_http_request_duration_seconds


五、Gin 中间件:HTTP 指标埋点

5.1 统计响应时间(Summary)

package middleware

import (
    "github.com/gin-gonic/gin"
    "github.com/prometheus/client_golang/prometheus"
    "time"
)

var httpDuration = prometheus.NewSummaryVec(prometheus.SummaryOpts{
    Namespace:  "webook",
    Subsystem:  "http",
    Name:       "request_duration_seconds",
    Objectives: map[float64]float64{0.99: 0.001},
}, []string{"pattern", "method", "status"})

func init() { prometheus.MustRegister(httpDuration) }

func Prometheus() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 步骤 1:记录请求开始时间
        start := time.Now()
        // 步骤 2:放行,让后续 middleware 和 Handler 先执行
        c.Next()
        duration := time.Since(start).Seconds()
        // c.FullPath() 拿到路由模板,如 /users/:id,避免按 id 分裂指标
        httpDuration.With(prometheus.Labels{
            "pattern": c.FullPath(),
            "method":  c.Request.Method,
            "status":  strconv.Itoa(c.Writer.Status()),
        }).Observe(duration)
    }
}

注册:r.Use(middleware.Prometheus())

生活类比:Gin middleware 埋点就像在餐厅每个出入口装计数器——客人进门 +1、出门 -1,你不用改厨房(业务 Handler)一行代码,就能实时知道"现在店里有几个人"。这正是"无侵入埋点"的精髓。

flowchart LR
    Req[请求进入] --> MW[Prometheus middleware]
    MW -->|Inc 活跃请求数| H[Handler 业务]
    H -->|Defer Dec| MW
    MW -->|Observe 耗时| P[(Prometheus)]

5.2 统计当前活跃请求数(Gauge)

var activeReq = prometheus.NewGauge(prometheus.GaugeOpts{
    Namespace: "webook",
    Subsystem: "http",
    Name:      "active_requests",
})

func init() { prometheus.MustRegister(activeReq) }

func ActiveRequests() gin.HandlerFunc {
    return func(c *gin.Context) {
        activeReq.Inc()
        defer activeReq.Dec()
        c.Next()
    }
}

5.3 Summary 响应时间怎么读

  • 绝对值:P99 是不是慢?平均值是不是慢?
  • 增长率:发版前后 P99 是否变慢?平均数是否上升?
  • 相对值:平均值 vs P99 差距大 → 存在长尾请求,部分用户体验差。

工程视角:不要被平均值骗了。一个接口平均 20ms 很漂亮,但如果 P99 是 2s,说明有 1% 的用户每次都要等 2 秒——这 1% 可能正好是 VIP 或大请求。告警和容量评估都该盯 P99 / P999,而不是平均。平均值只在"整体吞吐趋势"这种粗粒度场景看。


六、GORM 监控

6.1 官方 Prometheus 插件

import "gorm.io/plugin/prometheus"

db.Use(prometheus.New(prometheus.Config{
    DBName:          "webook",
    StartServer:     false, // 不另起 server,复用应用的 :8081
    PushAddr:        "",    // 不推送,让 Prometheus 来拉
}))

插件自动采集的指标(重点关注):

  • gorm_dbstats_idle:空闲连接数
  • gorm_dbstats_in_use:使用中的连接数
  • gorm_dbstats_wait_count:等待连接的请求数
  • gorm_dbstats_wait_duration:等待连接的总时长
  • gorm_dbstats_max_open_connections:最大连接数

工程视角:连接池是最容易被忽视的瓶颈来源。看到 wait_count / wait_duration 涨,说明请求在"等连接",直接调大 MaxOpenConns;看到 idle 长期很高,说明池子开太大浪费资源,调小 MaxIdleConns。这套指标就是给前面"连接池要配"那句话提供数据依据——监控不是装了好看,是用来指导调参的。

怎么解读:

  • wait_count / wait_duration 大 → 连接池不够,调大 MaxOpenConns
  • idle 大 → 调小 MaxIdleConns
  • max_idletime_closed 大 → ConnMaxIdleTime 设得太小

6.2 用 Callback 统计查询耗时

GORM 自带插件不统计 SQL 耗时。用 Callback 注册回调:

type queryMetrics struct {
    duration *prometheus.HistogramVec
}

func (q *queryMetrics) Register(db *gorm.DB) {
    // 在所有 CRUD 之前插入 Before
    db.Callback().Create().Before("*").Register("metrics_before", q.before)
    db.Callback().Query().Before("*").Register("metrics_before", q.before)
    db.Callback().Update().Before("*").Register("metrics_before", q.before)
    db.Callback().Delete().Before("*").Register("metrics_before", q.before)
    // 在所有 CRUD 之后插入 After
    db.Callback().Create().After("*").Register("metrics_after", q.after)
    db.Callback().Query().After("*").Register("metrics_after", q.after)
    db.Callback().Update().After("*").Register("metrics_after", q.after)
    db.Callback().Delete().After("*").Register("metrics_after", q.after)
}

func (q *queryMetrics) before(tx *gorm.DB) {
    start := time.Now()
    // 通过 InstanceSet / InstanceGet 把 start 传递到 after
    tx.InstanceSet("metrics_start", start)
}

func (q *queryMetrics) after(tx *gorm.DB) {
    val, ok := tx.InstanceGet("metrics_start")
    if !ok { return }
    start := val.(time.Time)
    duration := time.Since(start).Seconds()
    q.duration.WithLabelValues(tx.Statement.Table).Observe(duration)
}

七、错误码设计与监控

7.1 分段式错误码

第一位:类型(2 成功 / 4 客户端错误 / 5 服务端错误)
中间两位:模块号(公司内部统一分配)
后三位:具体错误编号

例如:

  • 2000000 = 用户模块成功
  • 5001001 = 用户模块 - DB 查询失败
  • 4002003 = 文章模块 - 参数错误

监控重点:只关注 5 开头的错误(服务端错误)。

7.2 在 Wrap 系列方法里统一监控错误码

func WrapResp[T any](c *gin.Context, code int, msg string, data T) {
    // 业务返回前统一埋点
    errCodeCounter.WithLabelValues(strconv.Itoa(code)).Inc()
    c.JSON(http.StatusOK, Result{
        Code: code, Msg: msg, Data: data,
    })
}

比反序列化响应体后再统计性能好得多。


八、第三方调用监控

跟第三方打交道必须加监控,掌握调用性能数据。用装饰器模式

// sms 服务装饰器
type promoSmsService struct {
    svc    sms.Service
    cnt    *prometheus.CounterVec   // 调用次数
    dur    *prometheus.SummaryVec   // 调用耗时
}

func (p *promoSmsService) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
    start := time.Now()
    defer func() {
        p.dur.WithLabelValues(tplId).Observe(time.Since(start).Seconds())
    }()
    err := p.svc.Send(ctx, tplId, args, numbers...)
    if err != nil {
        p.cnt.WithLabelValues(tplId, "error").Inc()
    } else {
        p.cnt.WithLabelValues(tplId, "ok").Inc()
    }
    return err
}

微信 API、支付、OSS 等第三方调用同理。


九、Redis 监控:用 Hook 算缓存命中率

import "github.com/redis/go-redis/v9"

var redisCmd = prometheus.NewCounterVec(prometheus.CounterOpts{
    Namespace: "webook",
    Subsystem: "redis",
    Name:      "commands_total",
}, []string{"cmd", "hit"})

func RedisHook() redis.Hook {
    return redis.Hook{
        ProcessHook: func(ctx context.Context, cmd redis.Cmder) error {
            err := cmd.Process(ctx)
            hit := "yes"
            if errors.Is(err, redis.Nil) {
                hit = "no" // 缓存未命中
            }
            redisCmd.WithLabelValues(cmd.Name(), hit).Inc()
            return err
        },
    }
}

// 注册:rdb.AddHook(RedisHook())

hit=no 占比就是缓存未命中率,反过来就是命中率。想区分业务可以在 ctx 里带业务信息。


十、OpenTelemetry 链路追踪

10.1 OpenTelemetry 是什么

OpenTelemetry(简称 OTel / OTLP)是 CNCF 主导的云原生可观测性标准协议。它的目标:

  • 与供应商无关:同一套 API,后端可换 Jaeger、Zipkin、Datadog 等
  • 统一覆盖 Trace / Metrics / Logs
  • 不绑定具体实现

老师建议:优先用 OpenTelemetry 的 API,后端按需选择。

10.2 核心抽象

概念作用
Tracer创建 Span 的工厂
Span一次操作的一段,有父子关系
SpanContextSpan 的标识,跨进程传递
Propagator把 SpanContext 注入/提取 HTTP header 等载体
TracerProviderTracer 的全局管理器
Exporter把数据上报到后端(Jaeger/Zipkin/OTLP)

工程视角:这套抽象看着多,其实就一层意思——“写代码时只依赖 Tracer/TracerProvider 这些接口,后端换成谁都行”。今天用 Zipkin,明天换 Jaeger,业务代码一行不动,只改 InitTracer 里的 Exporter。这就是 OpenTelemetry 作为"标准协议"的价值: vendor-neutral,不被任何一家监控厂商绑架。所以新项目起步就直接用 OTel API,是最省未来的选择。

10.3 初始化 TracerProvider(用 Zipkin 作后端)

import (
    "context"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/zipkin"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
)

func InitTracer(ctx context.Context, endpoint string) (func(), error) {
    // 1. 创建 Exporter(这里用 Zipkin)
    exp, err := zipkin.New(endpoint) // endpoint 形如 http://localhost:9411/api/v2/spans
    if err != nil { return nil, err }

    // 2. 创建 TracerProvider
    res, _ := resource.New(ctx, resource.WithAttributes(
        semconv.ServiceNameKey.String("webook"), // 服务名
    ))
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),     // 批量上报
        sdktrace.WithResource(res),
    )

    // 3. 全局注册
    otel.SetTracerProvider(tp)
    // 4. 设置 Propagator:让链路信息跨服务传递
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    // 5. 返回清理函数,进程退出前调用
    return func() {
        _ = tp.Shutdown(ctx) // 必须 Shutdown,否则可能丢数据
    }, nil
}

>  **新手必踩的坑OTel Provider  `Shutdown` 会丢数据**上面用 `WithBatcher` 批量上报Span 是先攒在内存里再发的如果进程退出前不调用 `tp.Shutdown(ctx)`那些还没发出去的 Span 就丢了链路里出现缺口所以 `InitTracer` 返回的清理函数一定要在 `main` 退出前 `defer` 调用
}

10.4 手动打 Span

func doSomething(ctx context.Context) error {
    // 步骤 1:从 ctx 里找父 Span,没有就新建根 Span
    ctx, span := otel.Tracer("webook").Start(ctx, "doSomething")
    defer span.End() // 原则:谁创建谁 End
    // ... 业务逻辑
    return nil
}

一张图理解 Span 的父子树和跨进程传递:

flowchart TD
    R[HTTP 请求根 Span] --> DB[GORM 查询子 Span]
    R --> S[发送短信子 Span]
    DB --> Q[SQL 执行]
    R -. ctx 携带 SpanContext 跨进程 .-> X[下游服务 Span]

关键点:

  • 调用 Start 时如果传入的 ctx 里有 Span 信息,会自动作为父 Span
  • 每个 Span 必须调用 End,原则「谁创建谁 End」

⚠️ 新手必踩的坑:忘记 span.End() 链路就不完整。Span 靠 End 来"结束计时并上报",如果只 StartEnd,这条 Span 会一直挂着不上报,调用链里出现空缺、耗时算不出来。所以约定俗成用 defer span.End(),确保函数退出时一定结束。

  • 进程内一直传递 ctx,链路信息靠 ctx 携带

10.5 context.Context 在 otel 中的关键作用

context.Context 在 Go 里承担两大职责:

// 1. 传值(类似其他语言的 thread local)
ctx = context.WithValue(ctx, "uid", 123)
uid, _ := ctx.Value("uid").(int)

// 2. 超时/取消控制
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 所有派生的子 ctx 会一起被取消

Context 接口四个核心方法:

方法用途使用频率
Deadline()返回过期时间不常用
Done()返回取消信号 channel常用
Err()返回取消原因(Canceled / DeadlineExceeded)常用
Value(key)取上下文值常用

特点:

  • 父子关系:父取消/超时,所有子都被取消(控制从上至下)
  • 查找 key:子先找自己,没有去祖先找(查找从下至上)

使用规范:

  • 作为第一个参数

工程视角context.Context 是 Go 并发工程的"主动脉"。它同时承担"传值(用户信息、traceID)“和"控制(超时、取消)“两件事,而且父子 context 形成树——父取消,所有子孙一起取消。这就是为什么超时控制能在整条调用链生效:最外层设一个 3 秒超时,里面无论调几次下游、起多少 goroutine,到点全部中断,不会出现"上游都返回了下游还在傻跑"的资源泄漏。

  • 公共方法都加 ctx(util / helper 除外)
  • 不要用作结构体字段(除非结构体本身表达上下文)

10.6 Gin + GORM 接入

Gin middleware(社区已封装好):

import "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"

r.Use(otelgin.Middleware("webook"))

GORM 接入:

import "gorm.io/plugin/opentelemetry/tracing"

db.Use(tracing.NewPlugin())

每个 HTTP 请求会自动创建一个根 Span,GORM 操作作为子 Span,调用链一目了然。

10.7 业务关键点手动打点

// sms.Service 的 otel 装饰器
type otelSmsService struct {
    svc sms.Service
}

func (o *otelSmsService) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
    ctx, span := otel.Tracer("webook").Start(ctx, "sms_send")
    defer span.End()
    // 给 Span 加属性,方便筛选
    span.SetAttributes(attribute.String("tpl_id", tplId))
    err := o.svc.Send(ctx, tplId, args, numbers...)
    if err != nil {
        // 记录错误
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
    }
    return err
}

经验法则: 程序出口、关键步骤、易错步骤都打 Span。缓存访问性能敏感,一般不打 Span,但也可以打。

10.8 Span 解读

  • 横条长度 = 执行时间
  • 横条之间的空隙 = 父 Span 自身代码执行时间(没有子 Span 覆盖)
  • 空隙多/长 = 需要补充打点

十一、Grafana 仪表盘与告警

11.1 接入数据源

Grafana 支持多种数据源,本课程接入:

  • Prometheus:用于指标
  • Zipkin / Jaeger:用于链路

11.2 创建 Dashboard

为每类监控创建仪表盘:HTTP 响应时间、GORM 连接池、Redis 命中率、第三方调用、错误码分布等。

工程视角:Dashboard 不是"把所有指标堆一屏”,而是"按排查思路组织”。一个好用的排障看板通常是:最上面是"有没有问题"(错误率、P99 红绿)、中间是"瓶颈在哪"(连接池等待、缓存命中率、第三方耗时)、底下是"细节钻取"(链路追踪入口)。新人接手系统时,第一件事往往就是照着这套看板学会"怎么看系统健不健康"。

11.3 配置告警 Contact Point

Contact Point = 「告警怎么发给你」。课程用企业微信机器人:

  1. 在企业微信群创建机器人,拿到 webhook URL
  2. Grafana → Alerting → Contact points → 新建 → 选企业微信 → 粘贴 webhook
  3. Test 验证群里能收到消息

老外习惯用邮件,邮件方式需要单独配置 SMTP 服务器。

11.4 设置告警规则

一张图看清一条告警是怎么从指标变成企业微信消息的:

flowchart LR
    M[指标 active_requests] -->|超阈值且持续 N 分钟| R[告警规则触发]
    R --> C[Contact Point
企业微信机器人] C --> W[群里收到告警]

例如「活跃请求数 > 阈值且持续 N 分钟」:

  1. 选指标 webook_http_active_requests
  2. 设置阈值(图表上的红线)
  3. 设置持续时间(防止瞬时抖动误报)
  4. 绑定 Contact Point

11.5 业务告警清单

慢查询告警: GORM Prometheus 监控 + 阈值(如 max > 100ms)。慢查询理论上不该出现,一出现就要查。

慢请求告警: HTTP / RPC 响应时间,按接口单独配置。

异常请求告警:

  • 非 2xx 响应码数量
  • error 出现次数超阈值

系统状态告警:

  • CPU 使用率持续高
  • 内存使用率持续高
  • goroutine 数超阈值持续一段时间

业务告警: 例如短信发送频率错误短时间内超阈值。

工程视角:告警的终极目标是"只在该人介入时才叫你"。所以每条规则都要有"持续时间"门槛——瞬时抖动一律不告警,否则告警风暴会让人对真正的告警也麻木(狼来了效应)。同时按业务分层:系统层(CPU/内存/goroutine)保机器不挂,接口层(慢请求/错误率)保用户体验,业务层(短信失败率)保收入。三层告警的关注人和阈值都不一样,别一锅炖。


十二、工程实践要点

  1. scrape_interval 不宜过短:高并发应用太频繁采集会拖累业务,15s~30s 比较合理。
  2. 业务指标独立端口暴露:避免 /metrics 接口被业务请求挤占。
  3. label 基数要控制:user_id、order_id 这种千万级取值绝对不能做 label,否则 Prometheus 内存爆炸。
  4. 路由模板做 label,不要用具体路径:用 c.FullPath()/users/:id,不要用 /users/123,否则指标会被分裂。
  5. Histogram vs Summary: 多实例场景优先 Histogram(可聚合),单实例或需精确 P99 用 Summary。
  6. OpenTelemetry Provider 必须 Shutdown:Batcher 会缓存数据,不 Shutdown 会丢数据。
  7. Span 必须 End:忘记 End 会导致链路不完整,建议 defer span.End()
  8. 告警要有持续时间:瞬时抖动不要告警,否则告警风暴会让人麻木。
  9. 缓存访问慎用 Tracing:缓存本身要求极高性能,Tracing 开销累积起来不小,按需打开。

十三、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 可观测性三支柱(Logging / Metrics / Tracing)各自回答什么问题?它们是怎么配合定位一个线上故障的?
  2. Prometheus 是"拉模型"还是"推模型"?应用要怎么暴露指标,Grafana 的数据从哪来?
  3. Counter、Gauge、Histogram、Summary 四种指标分别适合什么场景?为什么说多实例部署时优先用 Histogram 而不是 Summary?
  4. 为什么"绝对不要把 user_id 当 Prometheus 的 label"?违反会怎样?
  5. OpenTelemetry 里 Span 的父子关系靠什么传递?忘记 span.End() 会有什么后果?

动手练习(建议真做一遍)

  1. 加一个 Gin 埋点:把 middleware.Prometheus() 注册到你的 Engine 上,本地访问几个接口,打开 /metrics 找到 request_duration_seconds 的 P99。
  2. 手动打一个 Span:用 otel.Tracer 给某个函数 Start 一个 Span 并 defer span.End(),在 Zipkin 里确认能看到这条链路和耗时横条。
  3. 配一条告警:在 Grafana 里给 webook_http_active_requests 设"超阈值持续 N 分钟"的告警规则,绑到企业微信机器人,故意压一下接口看群里是否收到消息。

十四、本章小结

  • 可观测性三支柱互补:Metrics 看整体、Tracing 找位置、Logging 查细节。
  • Prometheus 四种指标各有定位:Counter 累计、Gauge 瞬时、Histogram 分桶、Summary 分位;多实例聚合优先 Histogram。
  • HTTP / DB / Redis / 第三方调用都可以通过中间件、Callback、Hook、装饰器模式无侵入埋点。
  • 错误码用分段设计便于监控,5 开头才告警。
  • OpenTelemetry 是供应商无关的可观测性标准,配合 context.Context 实现跨进程链路传递;Gin / GORM 都有现成插件。
  • Grafana 告警 = 数据源 + 阈值 + 持续时间 + Contact Point,关键是控制告警噪声、关注真正需要人为介入的异常。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!