本文是《告别 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(),而是:
- 查询原始 Counter 值
- 在后端计算相邻两个点的差值
- 把差值返回给前端
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 被丢弃
为什么丢弃第一个点?
因为第一个点没有"前值"可比较,无法计算差值。丢弃它之后,每个返回的点都表示"相对于前一时刻的变化"。
技术架构
存储原始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
}
完整数据流
六、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 首次出现时的值 → 通过计算相邻差值解决
技术亮点
- 后端做重活:指标类型识别、差值计算、数据补齐
- 前端很轻松:接收数据、转时间、渲染图表
- 配置即生效:新增指标关联到指标组,立即可视化
与上一篇的关系
- 上一篇:解决了"指标分组 + 动态菜单"问题
- 本篇:解决了"Counter 可视化"问题
两者结合 = 完整的"零配置、业务化"监控方案
适用场景
- 告警系统指标可视化 ✓
- 业务团队自助查看 ✓
- 非技术人员使用 ✓
- 复杂 PromQL 查询 ✗
- 高度自定义图表 ✗
效果:业务团队不再问"为什么图上看不到",而是问"能不能多加几个指标"。