1. Prometheus 简介
1.1 可观测性入门
可观测性 是指在软件系统当中通过度量、监控和分析系统当中各个组件的行为,以便于了解整个系统当前的运行状态。它可以帮助开发人员快速定位和解决系统出现的问题,被广泛运用于分布式系统当中,具体来说分为三个部分:
- 日志(Logging):记录系统的状态和行为
- 度量(Metrics):量化系统的性能指标,比如内存使用量、CPU负载
- 追踪(Tracing):追踪系统的请求与响应,帮助诊断问题
度量(Metrics):metrics 就是可聚合可度量的数据,比如各种响应时间,正在处理的请求数、CPU负载率、错误码统计频率都在这个范畴内
追踪(Tracing):也叫链路追踪数据,比如下图就是一个经典的 tracing 图像,由各种父子 span 构成,span的长度就表示执行时间长度,空隙代表的是父span执行的长度(因此如果有很多空隙,那么就需要考虑补充打点)
1.2 Prometheus 参考文档
💡 文档参考:
- 中文官方文档:https://prometheus.ac.cn/docs/prometheus/latest/getting_started/
- 服务端 Github 地址:https://github.com/prometheus/prometheus
- Go 客户端 Github 地址:https://github.com/prometheus/client_golang
1.3 Prometheus 基本架构
如上图所示就是 Prometheus 运行的基本架构:
- 服务器会主动轮询数据进行拉取(因此需要有配置文件交由服务器决定拉取策略(拉取时间间隔、客户端目标端口),客户端需要暴露端口供服务端采集数据)
2. Prometheus 安装
本次我们依旧通过使用 Docker 的方式来启动 Prometheus,在项目中编写如下docker-compose.yaml
文件
version: "3"
services:
prometheus:
image: prom/prometheus:v2.47.2
# 挂载数据卷
volumes:
- ./prometheus.yaml:/etc/prometheus/prometheus.yml
# 数据访问的端口
ports:
- 9090:9090 # 数据访问的端口
其中本地数据卷prometheus.yaml
文件内容如下:
scrape_configs:
- job_name: "webook"
scrape_interval: 5s
scrape_timeout: 3s
static_configs:
- targets: ["host.docker.internal:8081"]
然后在命令行中输入docker compose up
即可启动
看到如上图所示内容证明 Prometheus 启动成功!访问"http://localhost:9090"即可访问到 Prometheus 提供的图形界面:
3. Prometheus API 入门
3.1 指标类型
Prometheus 支持的指标非常多种多样,所以在实践过程中就要选择最适合的指标
- Counter:计数器,统计次数,比如统计某件事具体出现了多少次
- Gauge:度量,可以增加也可以较少,比如说当前正在处理的请求数
- 统计当前正在执行的 HTTP 请求数量
- 统计当前实例开启的数据库事务数量
- Histogram:柱状图,对观察对象进行采样,然后将结果分到不同的桶内
- 统计每个错误码出现的次数
- Summary:采样点按照百分位进行统计,比如99线、999线
- 统计接口的响应时间,平均值、中位数
3.2 Counter 和 Gauge
使用 Prometheus 的 API 相当简单,只需要使用go get github.com/prometheus/client_golang/prometheus@latest
引入客户端依赖即可
func Counter() {
counter := prometheus.NewCounter(prometheus.CounterOpts{
Namespace: "my_namespace",
Subsystem: "my_subsystem",
Name: "my_counter",
})
prometheus.MustRegister(counter)
counter.Inc()
counter.Add(10.2)
}
func Gauge() {
gauge := prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "my_namespace",
Subsystem: "my_subsystem",
Name: "my_gauge",
})
prometheus.MustRegister(gauge)
gauge.Inc()
gauge.Dec()
gauge.Add(10.2)
gauge.Sub(5.6)
}
其中需要关注的实际上是一些通用配置:
- namespace:命名空间(实际项目中可能代表部门名称或者小组名称)
- subsystem:子系统(实际项目中可能代表部门名称下的小组名称或者小组名称下的模块名称)
- name:名字
总之上述参数的配置不同公司有不同的规范,只需要三者能够定位到唯一的具体业务即可!
3.3 Histogram
Histogram 相关的 API 也是大同小异,直接上代码:
func Histogram() {
histogram := prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: "my_namespace",
Subsystem: "my_subsystem",
Name: "my_histogram",
Buckets: []float64{10, 50, 100, 1000, 5000, 10000},
})
prometheus.MustRegister(histogram)
histogram.Observe(10.2)
}
这里需要特别注意的是直方图有一个特定的配置项:buckets(可以理解为桶),此处划分了几个区间:[0, 10],[10,50],[50, 100],[100, 1000],[1000, 5000],[5000, 10000],并且使用 observe 方法塞入了一个观察值10.2,会自动分配到[10, 50]区间
3.4 Summary
Summary 相关的 API 也是大同小异,直接上代码:
func Summary() {
summary := prometheus.NewSummary(prometheus.SummaryOpts{
Namespace: "my_namespace",
Subsystem: "my_subsystem",
Name: "my_summary",
Objectives: map[float64]float64{
0.5: 0.01,
0.75: 0.01,
0.90: 0.005,
0.99: 0.001,
0.999: 0.0001,
},
})
prometheus.MustRegister(summary)
summary.Observe(12.3)
}
这里需要特别注意的是 summary 也有一个特定的配置项:Objectives,这是一个map类型,以第一个举例就是 50% 的请求的响应时间,其中误差范围为1%,以此类推
3.5 Vector
实际上我们采集的数据有可能是根据一些业务特征划分为,比如分开统计状态码为2xx、3xx、4xx、5xx,在这种情况下我们就可以借助 Prometheus 提供的 vector:
func VectorSummary() {
vec := prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: "my_namespace",
Subsystem: "my_subsystem",
Name: "my_summary_vec",
ConstLabels: map[string]string{
"server": "localhost:8080",
"env": "test",
"appname": "test_app",
},
}, []string{"pattern", "method", "status"})
prometheus.MustRegister(vec)
vec.WithLabelValues("/user/:id", "POST", "200").Observe(128)
}
其中 Vector 指标有两类 labels:
- 固定labels:这是所有业务都通用的
- 动态label:根据业务进行取值,比如此处的pattern,method,status
4. Prometheus 实战
4.1 统计接口响应时间
上面我们已经提到过此类场景适合使用 summary 指标进行统计,并且这是一个通用功能(对每个接口都适用)因此此处完全可以使用 gin 的 middleware 实现:
summary.go
package main
import (
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"strconv"
"time"
)
type SummaryBuilder struct {
namespace string
subsystem string
name string
help string
}
func NewSummaryBuilder(namespace string, subsystem string, name string, help string) *SummaryBuilder {
return &SummaryBuilder{
namespace: namespace,
subsystem: subsystem,
name: name,
help: help,
}
}
func (builder *SummaryBuilder) Build() gin.HandlerFunc {
summaryVec := prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: builder.namespace,
Subsystem: builder.subsystem,
Name: builder.name,
Help: builder.help,
ConstLabels: map[string]string{},
Objectives: map[float64]float64{
0.5: 0.01,
0.75: 0.01,
0.90: 0.005,
0.99: 0.001,
0.999: 0.0001,
},
}, []string{"pattern", "method", "status"})
prometheus.MustRegister(summaryVec)
return func(ctx *gin.Context) {
now := time.Now()
defer func() {
duration := time.Since(now)
var pattern = ctx.FullPath()
var method = ctx.Request.Method
var status = ctx.Writer.Status()
summaryVec.WithLabelValues(pattern, method, strconv.Itoa(status)).Observe(float64(duration.Milliseconds()))
}()
ctx.Next()
}
}
main.go
func initPrometheus() {
go func() {
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8081", nil)
}()
}
func main() {
initPrometheus()
server := gin.Default()
// 接入中间件
server.Use(NewSummaryBuilder("my_namespace",
"my_subsystem",
"test",
"统计响应时间").Build())
server.GET("/test", func(ctx *gin.Context) {
num := rand.Intn(5)
time.Sleep(time.Duration(num) * time.Second)
})
server.Run(":8080")
}
然后我们使用 wrk 接口测试工具模拟发送100个请求,观察现象:
以下是 Prometheus 统计结果:
4.2 统计当前活跃的请求数量
上面我们已经提到过此类场景适合使用 gauge 指标进行统计,并且这是一个通用功能(对每个接口都适用)因此此处也可以使用 gin 的 middleware 实现:
gauge.go
:
package main
import (
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
)
type GaugeBuilder struct {
namespace string
subsystem string
name string
help string
}
func NewGaugeBuilder(namespace string, subsystem string, name string, help string) *GaugeBuilder {
return &GaugeBuilder{
namespace: namespace,
subsystem: subsystem,
name: name,
help: help,
}
}
func (builder *GaugeBuilder) Build() gin.HandlerFunc {
gauge := prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: builder.namespace,
Subsystem: builder.subsystem,
Name: builder.name + "_active_req",
Help: builder.help,
})
prometheus.MustRegister(gauge)
return func(ctx *gin.Context) {
gauge.Inc()
defer gauge.Dec()
ctx.Next()
}
}
main.go
:
func initPrometheus() {
go func() {
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8081", nil)
}()
}
func main() {
initPrometheus()
server := gin.Default()
// 接入中间件
server.Use(NewSummaryBuilder("my_namespace",
"my_subsystem",
"test",
"统计响应时间").Build())
server.Use(NewGaugeBuilder("my_namespace",
"my_subsystem",
"http_req",
"统计当前活跃的请求数").Build())
server.GET("/test", func(ctx *gin.Context) {
num := rand.Intn(5)
time.Sleep(time.Duration(num) * time.Second)
})
server.Run(":8080")
}
然后我们使用 wrk 接口测试工具模拟发送100个请求,观察现象:
以下是 Prometheus 统计结果:
4.3 统计 GORM 执行时间
事实上在整个业务系统当中,数据库操作比 HTTP 更加高频,因此我们特别需要关注 SQL 执行的时间效率,那么我们如何获取到 GORM 当中 SQL 执行的时长呢?事实上 GORM 提供了一些钩子函数(如果感兴趣可以在 GORM 官网上寻找相关资料),此处我们使用一个更加底层的API:Callback 机制
callback.go
:
package main
import (
"github.com/prometheus/client_golang/prometheus"
"gorm.io/gorm"
"time"
)
type Callback struct {
summaryVec *prometheus.SummaryVec
}
func NewCallback(namespace string, subsystem string, name string, help string) *Callback {
summaryVec := prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: name,
Help: help,
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
},
[]string{"type"},
)
prometheus.MustRegister(summaryVec)
return &Callback{
summaryVec: summaryVec,
}
}
func (c *Callback) Before(tx *gorm.DB) {
now := time.Now()
tx.Set("startTime", now)
}
func (c *Callback) After(tx *gorm.DB, typ string) {
val, _ := tx.Get("startTime")
startTime, _ := val.(time.Time)
duration := time.Since(startTime)
c.summaryVec.WithLabelValues(typ).Observe(float64(duration.Milliseconds()))
}
main.go
:
func initPrometheus() {
go func() {
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8081", nil)
}()
}
func initDB() *gorm.DB {
db, err := gorm.Open(mysql.Open("root:QWEzxc123456@tcp(localhost:3306)/webook"))
if err != nil {
panic(err)
}
var callBack = NewCallback(
"my_namespace",
"my_subsystem",
"gorm_test",
"统计GORM执行时间")
err = db.Callback().Query().Before("*").Register("prometheus_query_before", func(tx *gorm.DB) {
callBack.Before(tx)
})
err = db.Callback().Query().After("*").Register("prometheus_query_after", func(tx *gorm.DB) {
callBack.After("query", tx)
})
return db
}
// User 对应数据库中的user表
type User struct {
Id int64 `gorm:"primaryKey,autoIncrement"`
Email string `gorm:"unique"`
Phone string `gorm:"unique"`
Password string
Nickname string `gorm:"type=varchar(128)"`
// YYYY-MM-DD
Birthday int64
Aboutme string `gorm:"type=varchar(4096)"`
// 时区,UTC 0 的毫秒数
// 创建时间
Ctime int64
// 更新时间
Utime int64
// json 存储
//Addr string
WeChatOpenID string `gorm:"unique"`
WeChatUniqueID string `gorm:"unique"`
}
func VectorSummary() {
vec := prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: "my_namespace",
Subsystem: "my_subsystem",
Name: "my_summary_vec",
ConstLabels: map[string]string{
"server": "localhost:8080",
"env": "test",
"appname": "test_app",
},
}, []string{"pattern", "method", "status"})
prometheus.MustRegister(vec)
vec.WithLabelValues("/user/:id", "POST", "200").Observe(128)
}
func main() {
initPrometheus()
db := initDB()
server := gin.Default()
// 接入中间件
server.Use(NewSummaryBuilder("my_namespace",
"my_subsystem",
"test",
"统计响应时间").Build())
server.Use(NewGaugeBuilder("my_namespace",
"my_subsystem",
"http_req",
"统计当前活跃的请求数").Build())
server.GET("/test", func(ctx *gin.Context) {
num := rand.Intn(5)
time.Sleep(time.Duration(num) * time.Second)
})
server.GET("/gorm", func(ctx *gin.Context) {
// 执行数据库操作
var users []User
err := db.WithContext(ctx).Find(&users).Error
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "系统错误",
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"code": 200,
"data": users,
"msg": "OK",
})
})
server.Run(":8080")
}
然后我们使用 wrk 接口测试工具模拟发送100个请求,观察现象:
以下是 Prometheus 统计结果:
4.4 统计 Redis 执行时间
同理我们有需求获取 redis 缓存的执行时间,这里我们可以借助 redis 客户端提供的钩子函数
redis_hook.go
:
package main
import (
"context"
"github.com/prometheus/client_golang/prometheus"
"github.com/redis/go-redis/v9"
"net"
"strconv"
"time"
)
type PrometheusHook struct {
summaryVec *prometheus.SummaryVec
}
func NewPrometheusHook(namespace string, subsystem string, name string, help string) *PrometheusHook {
summaryVec := prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: name,
Help: help,
Objectives: map[float64]float64{
0.5: 0.01,
0.75: 0.01,
0.9: 0.01,
0.99: 0.001,
0.999: 0.0001,
},
}, []string{"cmd", "keyExists"},
)
prometheus.MustRegister(summaryVec)
return &PrometheusHook{
summaryVec: summaryVec,
}
}
func (p *PrometheusHook) DialHook(next redis.DialHook) redis.DialHook {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
return next(ctx, network, addr)
}
}
func (p *PrometheusHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
startTime := time.Now()
var err error
defer func() {
duration := time.Since(startTime)
keyExists := err == redis.Nil
p.summaryVec.WithLabelValues(cmd.Name(), strconv.FormatBool(keyExists)).
Observe(float64(duration.Milliseconds()))
}()
err = next(ctx, cmd)
return err
}
}
func (p *PrometheusHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
return next(ctx, cmds)
}
}
main.go
:
func initPrometheus() {
go func() {
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8081", nil)
}()
}
func initDB() *gorm.DB {
db, err := gorm.Open(mysql.Open("root:QWEzxc123456@tcp(localhost:3306)/webook"))
if err != nil {
panic(err)
}
var callBack = NewCallback(
"my_namespace",
"my_subsystem",
"gorm_test",
"统计GORM执行时间")
err = db.Callback().Query().Before("*").Register("prometheus_query_before", func(tx *gorm.DB) {
callBack.Before(tx)
})
err = db.Callback().Query().After("*").Register("prometheus_query_after", func(tx *gorm.DB) {
callBack.After("query", tx)
})
return db
}
func initRedis() redis.Cmdable {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
hook := NewPrometheusHook("my_namespace", "my_subsystem", "redis_test", "统计redis执行时间")
client.AddHook(hook)
return client
}
// User 对应数据库中的user表
type User struct {
Id int64 `gorm:"primaryKey,autoIncrement"`
Email string `gorm:"unique"`
Phone string `gorm:"unique"`
Password string
Nickname string `gorm:"type=varchar(128)"`
// YYYY-MM-DD
Birthday int64
Aboutme string `gorm:"type=varchar(4096)"`
// 时区,UTC 0 的毫秒数
// 创建时间
Ctime int64
// 更新时间
Utime int64
// json 存储
//Addr string
WeChatOpenID string `gorm:"unique"`
WeChatUniqueID string `gorm:"unique"`
}
func main() {
initPrometheus()
db := initDB()
client := initRedis()
server := gin.Default()
// 接入中间件
server.Use(NewSummaryBuilder("my_namespace",
"my_subsystem",
"test",
"统计响应时间").Build())
server.Use(NewGaugeBuilder("my_namespace",
"my_subsystem",
"http_req",
"统计当前活跃的请求数").Build())
server.GET("/test", func(ctx *gin.Context) {
num := rand.Intn(5)
time.Sleep(time.Duration(num) * time.Second)
})
server.GET("/gorm", func(ctx *gin.Context) {
// 执行数据库操作
var users []User
err := db.WithContext(ctx).Find(&users).Error
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "系统错误",
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"code": 200,
"data": users,
"msg": "OK",
})
})
server.GET("/redis", func(ctx *gin.Context) {
// 执行redis操作
err := client.Set(ctx, "test_key", []byte("aaaaaa"), time.Second*60).Err()
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "系统错误",
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "OK",
})
})
server.Run(":8080")
}
然后我们使用 wrk 接口测试工具模拟发送100个请求,观察现象:
以下是 Prometheus 统计结果: