学习目标
- 理解可观测性「三支柱」:日志(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 采用服务端主动拉取的方式采集指标,跟常见的「客户端推」不同。
工作流程:
- 应用暴露一个
/metrics接口(通常在独立端口,比如:8081/metrics) - Prometheus server 按配置的
scrape_interval定时去拉 - 数据存在 Prometheus 自带的时序数据库
- 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:取值随业务变化(如
status、method) - 基数控制:label 取值种类不能太多,否则 Prometheus 内存爆炸。绝对不要把 user_id 当 label。
⚠️ 新手必踩的坑:把高基数字段当 label 会拖垮 Prometheus。如果你用
user_id当 label,假设有 1000 万用户,Prometheus 就要为这个指标维护 1000 万个时间序列,内存直接爆炸,server 可能 OOM 挂掉。label 只放"取值种类有限"的维度(如status、method、pattern)。需要按用户维度查询时,应该走日志或专门的 OLAP 库,而不是指标。
四、namespace / subsystem / name 设计
让 namespace + subsystem + name 能快速定位到具体业务即可:
| 公司组织 | namespace | subsystem | name |
|---|---|---|---|
| 按部门 | 部门名 | 子系统 | 数据名 |
| 按小组 | 小组名 | 系统/模块 | 数据名 |
例如 shop_order_created_total、webook_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大 → 连接池不够,调大MaxOpenConnsidle大 → 调小MaxIdleConnsmax_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 | 一次操作的一段,有父子关系 |
| SpanContext | Span 的标识,跨进程传递 |
| Propagator | 把 SpanContext 注入/提取 HTTP header 等载体 |
| TracerProvider | Tracer 的全局管理器 |
| 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来"结束计时并上报",如果只Start不End,这条 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 = 「告警怎么发给你」。课程用企业微信机器人:
- 在企业微信群创建机器人,拿到 webhook URL
- Grafana → Alerting → Contact points → 新建 → 选企业微信 → 粘贴 webhook
- 点 Test 验证群里能收到消息
老外习惯用邮件,邮件方式需要单独配置 SMTP 服务器。
11.4 设置告警规则
一张图看清一条告警是怎么从指标变成企业微信消息的:
flowchart LR
M[指标 active_requests] -->|超阈值且持续 N 分钟| R[告警规则触发]
R --> C[Contact Point
企业微信机器人]
C --> W[群里收到告警]例如「活跃请求数 > 阈值且持续 N 分钟」:
- 选指标
webook_http_active_requests - 设置阈值(图表上的红线)
- 设置持续时间(防止瞬时抖动误报)
- 绑定 Contact Point
11.5 业务告警清单
慢查询告警: GORM Prometheus 监控 + 阈值(如 max > 100ms)。慢查询理论上不该出现,一出现就要查。
慢请求告警: HTTP / RPC 响应时间,按接口单独配置。
异常请求告警:
- 非 2xx 响应码数量
- error 出现次数超阈值
系统状态告警:
- CPU 使用率持续高
- 内存使用率持续高
- goroutine 数超阈值持续一段时间
业务告警: 例如短信发送频率错误短时间内超阈值。
工程视角:告警的终极目标是"只在该人介入时才叫你"。所以每条规则都要有"持续时间"门槛——瞬时抖动一律不告警,否则告警风暴会让人对真正的告警也麻木(狼来了效应)。同时按业务分层:系统层(CPU/内存/goroutine)保机器不挂,接口层(慢请求/错误率)保用户体验,业务层(短信失败率)保收入。三层告警的关注人和阈值都不一样,别一锅炖。
十二、工程实践要点
scrape_interval不宜过短:高并发应用太频繁采集会拖累业务,15s~30s 比较合理。- 业务指标独立端口暴露:避免
/metrics接口被业务请求挤占。 - label 基数要控制:user_id、order_id 这种千万级取值绝对不能做 label,否则 Prometheus 内存爆炸。
- 路由模板做 label,不要用具体路径:用
c.FullPath()拿/users/:id,不要用/users/123,否则指标会被分裂。 - Histogram vs Summary: 多实例场景优先 Histogram(可聚合),单实例或需精确 P99 用 Summary。
- OpenTelemetry Provider 必须 Shutdown:Batcher 会缓存数据,不 Shutdown 会丢数据。
- Span 必须 End:忘记 End 会导致链路不完整,建议
defer span.End()。 - 告警要有持续时间:瞬时抖动不要告警,否则告警风暴会让人麻木。
- 缓存访问慎用 Tracing:缓存本身要求极高性能,Tracing 开销累积起来不小,按需打开。
十三、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 可观测性三支柱(Logging / Metrics / Tracing)各自回答什么问题?它们是怎么配合定位一个线上故障的?
- Prometheus 是"拉模型"还是"推模型"?应用要怎么暴露指标,Grafana 的数据从哪来?
- Counter、Gauge、Histogram、Summary 四种指标分别适合什么场景?为什么说多实例部署时优先用 Histogram 而不是 Summary?
- 为什么"绝对不要把
user_id当 Prometheus 的 label"?违反会怎样? - OpenTelemetry 里 Span 的父子关系靠什么传递?忘记
span.End()会有什么后果?
动手练习(建议真做一遍):
- 加一个 Gin 埋点:把
middleware.Prometheus()注册到你的 Engine 上,本地访问几个接口,打开/metrics找到request_duration_seconds的 P99。 - 手动打一个 Span:用
otel.Tracer给某个函数Start一个 Span 并defer span.End(),在 Zipkin 里确认能看到这条链路和耗时横条。 - 配一条告警:在 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,关键是控制告警噪声、关注真正需要人为介入的异常。