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

30 阅读9分钟

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

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

16:05 发生了什么?

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

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,看到:

时间      原始值
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 指标的标准做法是:

increase(alarm_log_error_total[1m])

这个查询的结果是:

时间      原始值    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 的设计理念:关注的是"速率"和"累计趋势",不是"瞬时事件"。

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


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

理想效果

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

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:0510 ✗1 ✓
16:0610 ✓0 ✓
16:1021 ✓1 ✓
16:1120 ✓0 ✓

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


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

核心思路

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

  1. 查询原始 Counter 值
  2. 在后端计算相邻两个点的差值
  3. 把差值返回给前端
原始值:   [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<br/>存储原始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 值

// 直接查询指标,不使用 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:

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 专用)

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 查询规则,统计"待处理订单数":

SELECT COUNT(*) FROM orders WHERE status = 'pending'

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

时间      查询结果
16:00     100
16:05     150
16:10     120
16:15     180

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

Gauge 不需要计算差值

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

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

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

Counter vs Gauge 对比

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

七、前端:零负担渲染

前端只做 3 件事

// 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
})

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

后端返回的数据格式

{
  "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())

16:05 发生错误 → 图上显示 0
16:10 又发生错误 → 图上显示 1

业务团队:看不懂

改造后(计算相邻差值)

16:05 发生错误 → 图上显示 1(波峰)
16:10 又发生错误 → 图上显示 1(波峰)

业务团队:一目了然


九、关键设计点

1. 采样粒度自适应

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

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. 指标类型识别

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

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

3. 前后端职责分离

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

十、处理边界情况

Counter 重置

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

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

修正逻辑:

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

指标首次出现

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

时间      原始值    补齐后     差值
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. 性能优化建议

// 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. 前端渲染优化

// 虚拟滚动(图表数量 > 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 查询 ✗
  • 高度自定义图表 ✗

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