Prometheus 动态指标可视化的深度优化:Counter 与 Gauge 的差异化处理

本文是《告别 Grafana 手搓 Dashboard:基于指标分组的 Prometheus 可视化新方案》的深度实践篇

一、一个让人困惑的监控图

16:05 发生了什么?

下午 4 点,订单团队的小王收到告警:订单创建失败。他立刻打开监控平台,点击"订单监控"菜单,看到这样一张图:

scss 复制代码
alarm_log_error_total (订单创建失败)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 1 |                    ╭─╮
   |                    │ │
   |                    │ │
 0 |────────────────────╯ ╰─────────
   └──────────────────────────────────
   16:00  16:05  16:10  16:15  16:20

小王懵了:"16:05 明明发生了错误,为什么图上显示的是 0?"

查看 Prometheus 原始数据

他去 Prometheus 查询 alarm_log_error_total,看到:

makefile 复制代码
时间      原始值
16:00     (不存在)
16:01     (不存在)
16:05     1        ← 第一次出现,错误已发生
16:06     1        ← 保持不变
16:07     1
16:10     2        ← 又增加了1次
16:11     2

原始数据清楚地记录了:16:05 发生了第一次错误,16:10 发生了第二次错误。

可为什么图上看不出来?


二、问题根源:Prometheus 的 increase() 不适合这个场景

传统做法:使用 increase()

在 Grafana 或其他可视化工具中,监控 Counter 指标的标准做法是:

promql 复制代码
increase(alarm_log_error_total[1m])

这个查询的结果是:

makefile 复制代码
时间      原始值    increase(1m)
16:05     1         0           ← 因为没有"前值",无法计算增量
16:06     1         0           ← 从 1 到 1,增量为 0
16:07     1         0
16:08     1         0
16:09     1         0
16:10     2         1           ← 从 1 到 2,增量为 1
16:11     2         0

结果:除了 16:10 这个点,其他全是 0。

小王看到的图就是基于这个结果画的------一条几乎全是 0 的线,只在 16:10 有一个小凸起。

为什么 increase() 会这样?

Prometheus 的 increase() 函数计算的是时间窗口内的变化量

  • 如果 Counter 从 0 变成 1,increase() = 1 ✓
  • 如果 Counter 保持为 1,increase() = 0 ✗

但在告警场景中:

  • 16:05 指标第一次被采集时,值就已经是 1(错误已经发生了)
  • 后续几分钟没有新错误,值保持为 1
  • increase() 认为"没有变化",所以返回 0

Prometheus 的设计理念:关注的是"速率"和"累计趋势",不是"瞬时事件"。

但业务团队要的是:"我就想知道每个时刻发生了几次错误"


三、我们想要什么样的图?

理想效果

小王期望看到的图应该是这样:

scss 复制代码
alarm_log_error_total (订单创建失败次数)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 1 |     ╭─╮         ╭─╮
   |     │ │         │ │
   |     │ │         │ │
 0 |─────╯ ╰─────────╯ ╰──────────
   └──────────────────────────────────
   16:00  16:05  16:10  16:15  16:20
          ↑            ↑
       第一次错误    第二次错误

清晰地看到

  • 16:05 有一个波峰,值为 1
  • 16:10 有一个波峰,值为 1
  • 其他时刻值为 0(没有新增错误)

对比两种图

时刻 Prometheus 原始值 传统 increase() 我们想要的
16:05 1 0 ✗ 1 ✓
16:06 1 0 ✓ 0 ✓
16:10 2 1 ✓ 1 ✓
16:11 2 0 ✓ 0 ✓

关键差异 :16:05 这个时刻,increase() 返回 0,但我们想要的是 1。


四、解决方案:计算相邻点的差值

核心思路

不使用 Prometheus 的 increase(),而是:

  1. 查询原始 Counter 值
  2. 在后端计算相邻两个点的差值
  3. 把差值返回给前端
ini 复制代码
原始值:   [0, 0, 0, 1, 1, 1, 2, 2, 2]
时间:    [t1,t2,t3,t4,t5,t6,t7,t8,t9]

计算差值:
t2: 0 - 0 = 0
t3: 0 - 0 = 0
t4: 1 - 0 = 1  ← 第一次错误
t5: 1 - 1 = 0
t6: 1 - 1 = 0
t7: 2 - 1 = 1  ← 第二次错误
t8: 2 - 2 = 0
t9: 2 - 2 = 0

返回前端: [0, 0, 1, 0, 0, 1, 0, 0]
时间:    [t2,t3,t4,t5,t6,t7,t8,t9]
         ↑ 注意:第一个点 t1 被丢弃

为什么丢弃第一个点?

因为第一个点没有"前值"可比较,无法计算差值。丢弃它之后,每个返回的点都表示"相对于前一时刻的变化"。

技术架构

graph LR A[Prometheus
存储原始Counter] -->|QueryRange| B[后端服务] B -->|1.补齐时间点| C[完整时序] C -->|2.计算差值| D[相邻点相减] D -->|3.移除首点| E[返回增量值] E -->|JSON| F[前端ECharts] F --> G[波峰图] style D fill:#ff9999 style G fill:#99ff99

五、后端核心实现(3 步搞定)

步骤 1:查询原始 Counter 值

go 复制代码
// 直接查询指标,不使用 increase() 或 rate()
query := "alarm_log_error_total"

result, _, err := promAPI.QueryRange(ctx, query, v1.Range{
    Start: startTime,
    End:   endTime,
    Step:  30 * time.Second,  // 30秒采样一次
})

// 返回: [[timestamp, value], ...]
// 例如: [[1736049600, 0], [1736049630, 0], [1736049660, 1], ...]

步骤 2:补齐缺失的时间点

Prometheus 不保证每个时间点都有数据(指标可能还未创建),需要补 0:

go 复制代码
func fillMissingPoints(points [][]float64, start, end time.Time, step time.Duration) [][]float64 {
    pointMap := make(map[int64]float64)
    for _, p := range points {
        pointMap[int64(p[0])] = p[1]
    }

    var result [][]float64
    for t := start.Unix(); t <= end.Unix(); t += int64(step.Seconds()) {
        if val, exists := pointMap[t]; exists {
            result = append(result, []float64{float64(t), val})
        } else {
            result = append(result, []float64{float64(t), 0})  // 补0
        }
    }
    return result
}

步骤 3:计算相邻差值(Counter 专用)

go 复制代码
func processCounter(points [][]float64) [][]float64 {
    if len(points) <= 1 {
        return [][]float64{}
    }
    
    result := make([][]float64, 0, len(points)-1)
    
    for i := 1; i < len(points); i++ {
        prevValue := points[i-1][1]
        currValue := points[i][1]
        timestamp := points[i][0]
        
        diff := currValue - prevValue
        
        // 处理 Counter 重置(服务重启)
        if diff < 0 {
            diff = currValue
        }
        
        result = append(result, []float64{timestamp, diff})
    }
    
    return result  // 注意:长度比原始数据少1
}

完整数据流

sequenceDiagram participant P as Prometheus participant B as 后端 participant F as 前端 F->>B: 查询"订单监控",时间范围1小时 B->>P: QueryRange(alarm_log_error_total) P-->>B: [0,0,0,1,1,1,2,2,2] Note over B: 补齐缺失时间点 Note over B: 计算相邻差值 Note over B: 移除第一个点 B-->>F: [[t2,0], [t3,0], [t4,1], [t5,0], [t6,0], [t7,1], [t8,0], [t9,0]] Note over F: 渲染图表 F->>F: 16:05和16:10出现波峰

六、Gauge 指标怎么办?

场景对比

假设我们还有一个 SQL 查询规则,统计"待处理订单数":

sql 复制代码
SELECT COUNT(*) FROM orders WHERE status = 'pending'

这个指标是 Gauge 类型(可增可减的瞬时值):

makefile 复制代码
时间      查询结果
16:00     100
16:05     150
16:10     120
16:15     180

业务团队想看的是:"当前有多少待处理订单",而不是"相对于上一时刻增加了多少"。

Gauge 不需要计算差值

go 复制代码
func processGauge(points [][]float64) [][]float64 {
    // 直接返回原始值,不做任何处理
    return points
}

返回前端:[[16:00, 100], [16:05, 150], [16:10, 120], [16:15, 180]]

前端渲染出一条平滑的曲线,清晰展示数值变化趋势。

Counter vs Gauge 对比

维度 Counter Gauge
语义 累计值(只增不减) 当前值(可增可减)
业务关心 增量:这一分钟新增了几次 绝对值:现在是多少
处理方式 计算相邻差值 保持原值
示例 错误次数、请求总数 连接数、队列长度、SQL查询结果

七、前端:零负担渲染

前端只做 3 件事

javascript 复制代码
// 1. 接收后端数据
const response = await getMetricData(groupId, timeRange)

// 2. 转换时间格式(秒 → 毫秒)
const chartData = response.series.map(s => ({
  name: formatLegend(s.metric),
  type: 'line',
  data: s.points.map(p => [p[0] * 1000, p[1]])  // 秒转毫秒
}))

// 3. 渲染图表
chart.setOption({
  xAxis: { type: 'time' },
  yAxis: { type: 'value' },
  series: chartData
})

不做任何差值计算、不做任何业务逻辑------所有复杂处理都在后端完成。

后端返回的数据格式

json 复制代码
{
  "name": "alarm_log_error_total",
  "type": "counter",
  "series": [{
    "metric": {
      "service": "order-service",
      "level": "error"
    },
    "points": [
      [1736049660, 0],
      [1736049720, 0],
      [1736049780, 1],  // 16:05 波峰
      [1736049840, 0],
      [1736049900, 0],
      [1736049960, 1]   // 16:10 波峰
    ]
  }]
}

八、效果对比

改造前(使用 increase())

makefile 复制代码
16:05 发生错误 → 图上显示 0
16:10 又发生错误 → 图上显示 1

业务团队:看不懂

改造后(计算相邻差值)

makefile 复制代码
16:05 发生错误 → 图上显示 1(波峰)
16:10 又发生错误 → 图上显示 1(波峰)

业务团队:一目了然


九、关键设计点

1. 采样粒度自适应

根据查询时间长度,自动调整采样间隔:

go 复制代码
func calculateStep(timeRange string) time.Duration {
    switch timeRange {
    case "15m":  return 15 * time.Second  // 15分钟 / 60点
    case "1h":   return 30 * time.Second  // 1小时 / 120点
    case "3h":   return 60 * time.Second  // 3小时 / 180点
    case "6h":   return 2 * time.Minute   // 6小时 / 180点
    case "12h":  return 4 * time.Minute   // 12小时 / 180点
    case "24h":  return 8 * time.Minute   // 24小时 / 180点
    }
}

设计原则:每个图表 60~180 个数据点,平衡精度与性能。

2. 指标类型识别

从规则类型推断指标类型:

go 复制代码
func getMetricType(rule Rule) string {
    if rule.Type == "keyword" {
        return "counter"  // 日志关键词规则 → Counter
    }
    if rule.Type == "query" {
        return "gauge"    // SQL 查询规则 → Gauge
    }
}

3. 前后端职责分离

层次 职责 为什么
后端 指标类型识别、差值计算、数据补齐 理解业务语义,处理复杂逻辑
前端 时间格式转换、图表渲染 专注展示,保持简洁

十、处理边界情况

Counter 重置

服务重启时,Counter 从大值重置为 0:

makefile 复制代码
时间      原始值    天真计算     修正后
16:00     100       -            -
16:01     150       50 ✓        50 ✓
16:02     0         -150 ✗      0 ✓
16:03     5         5 ✓         5 ✓

修正逻辑:

go 复制代码
diff := currValue - prevValue
if diff < 0 {
    diff = currValue  // 负数说明重置,用当前值
}

指标首次出现

指标第一次被采集时,前面的时间点都补 0:

makefile 复制代码
时间      原始值    补齐后     差值
15:55     (无)      0          -
15:56     (无)      0          0
15:57     (无)      0          0
15:58     1         1          1  ← 第一次出现的增量

十一、与 Grafana 对比

维度 Grafana + increase() 本方案
Counter 首次出现 显示 0(看不出来) 显示 1(清晰波峰)
学习成本 需要懂 PromQL 点击菜单即可
Dashboard 配置 手动配置 Panel 自动生成
适用场景 通用监控平台 业务告警可视化

Grafana 的优势:灵活、强大、通用

本方案的优势:简单、直观、符合业务直觉


十二、实践建议

1. 何时使用这个方案?

适合:

  • 告警系统的指标可视化
  • 业务团队日常巡检
  • 非技术人员查看监控

不适合:

  • 需要复杂 PromQL 查询(rate、histogram_quantile)
  • 高度自定义图表布局
  • 专业运维团队的深度分析

2. 性能优化建议

go 复制代码
// 1. 限制数据点数量
if pointCount > 180 {
    step = calculateAdaptiveStep(timeRange, 180)
}

// 2. 缓存查询结果
cache.Set(cacheKey, result, 30*time.Second)

// 3. 并发查询多个指标
var wg sync.WaitGroup
for _, metric := range metrics {
    wg.Add(1)
    go queryMetric(metric)
}

3. 前端渲染优化

javascript 复制代码
// 虚拟滚动(图表数量 > 20)
<virtual-list :data="metrics" :item-height="400">
  <template v-slot="{ item }">
    <Chart :data="item" />
  </template>
</virtual-list>

// 懒加载(点击展开才渲染)
<el-collapse>
  <el-collapse-item v-for="metric in metrics">
    <Chart v-if="expanded" :data="metric" />
  </el-collapse-item>
</el-collapse>

十三、总结

核心解决的问题

Prometheus 的 increase() 无法展示 Counter 首次出现时的值 → 通过计算相邻差值解决

技术亮点

  1. 后端做重活:指标类型识别、差值计算、数据补齐
  2. 前端很轻松:接收数据、转时间、渲染图表
  3. 配置即生效:新增指标关联到指标组,立即可视化

与上一篇的关系

  • 上一篇:解决了"指标分组 + 动态菜单"问题
  • 本篇:解决了"Counter 可视化"问题

两者结合 = 完整的"零配置、业务化"监控方案

适用场景

  • 告警系统指标可视化 ✓
  • 业务团队自助查看 ✓
  • 非技术人员使用 ✓
  • 复杂 PromQL 查询 ✗
  • 高度自定义图表 ✗

效果:业务团队不再问"为什么图上看不到",而是问"能不能多加几个指标"。

相关推荐
序安InToo22 分钟前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy12322 分钟前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记25 分钟前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang0525 分钟前
VS Code 配置 Markdown 环境
后端
navms28 分钟前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang0528 分钟前
离线数仓的优化及重构
后端
Nyarlathotep011329 分钟前
gin01:初探gin的启动
后端·go
JxWang0530 分钟前
安卓手机配置通用多屏协同及自动化脚本
后端
JxWang0531 分钟前
Windows Terminal 配置 oh-my-posh
后端
SimonKing1 小时前
OpenCode AI编程助手如何添加Skills,优化项目!
java·后端·程序员