时序数据可视化与异常分析:让数据开口说话
大家好,我是黒漂技术佬。前面几篇我们把数据采了、存了、查了,但你有没有发现一个问题——这些数据全是数字。运营人员对着满屏的26.3, 26.5, 27.1, 29.8...看半天也看不出啥名堂。但如果你把同样的数据画成一条曲线,温度从26度突然飙到30度的那个拐点,一眼就能看到。这就是可视化的力量。
一、时序数据可视化的价值
人脑对数字的感知能力很弱,对图形的感知能力极强。同样一组温度数据:
26.3, 26.5, 26.7, 27.1, 28.3, 30.1, 32.5, 33.8, 33.2, 31.5, 29.8, 28.2你能一眼看出哪个时刻温度开始异常吗?很难。但画成折线图,那个陡峭的上升曲线直接跳到你眼前。
可视化在工控物联网场景中至少解决三个问题:
- 实时监控:运维人员一眼掌握所有设备运行状态,不用挨个查数字
- 趋势分析:长期数据画成曲线,设备老化趋势、季节性规律一目了然
- 异常发现:数据可视化后,异常值往往表现为曲线上的尖峰、断崖或平台,比数值阈值告警更直观
二、可视化方案选型
方案一:Grafana 对接 InfluxDB(主流方案)
Grafana 是开源的数据可视化平台,和 InfluxDB 是天作之合。优点是配置即图表,几乎不用写代码;缺点是图表样式定制能力有限,且独立于你的业务系统。
适合场景:运维监控大盘、设备实时状态看板。
方案二:SpringBoot + ECharts 自研报表
ECharts 是百度开源的图表库(现在归Apache了),通过 JavaScript 渲染各种图表。后端用 SpringBoot 查 InfluxDB 返回 JSON,前端用 ECharts 绘图。
优点是完全可控,能和业务系统深度集成;缺点是要自己写前后端代码,开发量大。
适合场景:面向业务方的运营报表、需要和订单/库存数据联动的综合看板。
两种方案的对比
| 维度 | Grafana | SpringBoot + ECharts |
|---|---|---|
| 开发成本 | 低(配置为主) | 高(需开发前后端) |
| 图表丰富度 | 高(内置几十种面板) | 中(ECharts也够用) |
| 定制能力 | 中(模板变量、告警规则) | 高(完全自主控制) |
| 业务集成 | 弱(独立系统) | 强(嵌入业务页面) |
| 告警能力 | 内置(Alert规则) | 需自研 |
| 典型场景 | 运维监控 | 业务报表 |
实际项目中两种方案经常并用:Grafana 给运维团队用,ECharts 看板给业务团队用。
三、Grafana 对接 InfluxDB
安装 Grafana
# Docker方式(最简单)dockerrun-d--name=grafana\-p3000:3000\grafana/grafana:latest# 默认账号密码: admin / admin启动后访问http://localhost:3000,用默认账号登录。
配置 InfluxDB 数据源
- 进入 Grafana → Configuration → Data Sources → Add data source
- 选择InfluxDB
- 填写关键配置:
Query Language: Flux(必须选Flux,不是InfluxQL) URL: http://localhost:8086 Authentication: 勾选 With Credentials(或在下面填Token) Organization: your-org Token: 你的API Token Default Bucket: cabinet_raw- 点击Save & Test,看到 “Data source is working” 就说明通了
创建 Dashboard 和 Panel
Dashboard 是看板,一个 Dashboard 可以包含多个 Panel(图表)。创建一个 Panel 时,核心是写 Flux 查询。
Panel 1:实时温度折线图
在 Panel 的 Query 区域直接写 Flux:
from(bucket: "cabinet_raw") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["device_id"] == "cabinet-001") |> filter(fn: (r) => r["_field"] == "temp") |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)关键点:
v.timeRangeStart和v.timeRangeStop是 Grafana 内置变量,自动绑定面板右上角的时间范围选择器。用户选"过去1小时",查询就自动查1小时v.windowPeriod也是内置变量,Grafana 根据时间范围自动选择合适的聚合粒度。选"过去1小时"可能用1分钟聚合,选"过去7天"可能用1小时聚合
Panel 配置:
Visualization: Time Series(折线图) Title: cabinet-001 温度变化 X轴: time Y轴: temp (°C)Panel 2:多设备温度对比
from(bucket: "cabinet_raw") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["_field"] == "temp") |> filter(fn: (r) => r["device_id"] == "cabinet-001" or r["device_id"] == "cabinet-002" or r["device_id"] == "cabinet-003") |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)Grafana 会自动按device_id分组,画出三条不同颜色的曲线。
Panel 3:设备状态表格
from(bucket: "cabinet_raw") |> range(start: -5m) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["_field"] == "temp" or r["_field"] == "voltage" or r["_field"] == "current") |> group(columns: ["device_id", "_field"]) |> last()Visualization 选Table,展示每个设备的最新温度、电压、电流值。
使用模板变量
Grafana 的模板变量(Variables)让你在看板上加一个下拉框,动态切换查询参数。比如选择不同设备:
- Dashboard Settings → Variables → Add variable
- 配置:
Name: device Type: Query Query: import "influxdata/influxdb/schema" schema.measurements(bucket: "cabinet_raw") // 或者用tag values查询- 在 Panel 的 Flux 查询中使用变量:
from(bucket: "cabinet_raw") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn: (r) => r["device_id"] == "${device}") |> filter(fn: (r) => r["_field"] == "temp")看板顶部会出现一个下拉框,选择不同设备,所有 Panel 自动刷新。
Grafana 告警规则
Grafana 内置告警引擎,可以基于查询结果自动触发告警。
配置示例:温度超过10°C时告警
- 进入 Alerting → Alert Rules → New Alert Rule
- 配置查询和条件:
Query A: from(bucket: "cabinet_raw") |> range(start: -5m) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["_field"] == "temp") |> group(columns: ["device_id"]) |> last() Condition: WHEN last() OF query(A, 5m, now) IS ABOVE 10- 配置告警通知渠道(Contact Point):
类型: 钉钉 Webhook / 飞书 Webhook / 邮件 消息模板: ${device_id} 温度异常: 当前 ${temp}°C,阈值 10°C- 保存后,Grafana 每5秒检查一次查询结果,超过阈值就发通知
四、SpringBoot + ECharts 自研报表
当 Grafana 满足不了业务定制需求时,就得自己来。
后端接口
@RestController@RequestMapping("/api/chart")publicclassChartController{privatefinalInfluxDBUtilinfluxDBUtil;publicChartController(InfluxDBUtilinfluxDBUtil){this.influxDBUtil=influxDBUtil;}/** * 查询设备温度变化数据(给ECharts折线图用) */@GetMapping("/temp-trend")publicMap<String,Object>tempTrend(@RequestParamStringdeviceId,@RequestParam(defaultValue="1")inthours){Stringflux=String.format("from(bucket: \"cabinet_raw\")\n"+" |> range(start: -%dh)\n"+" |> filter(fn: (r) => r[\"_measurement\"] == \"cabinet_metrics\")\n"+" |> filter(fn: (r) => r[\"device_id\"] == \"%s\")\n"+" |> filter(fn: (r) => r[\"_field\"] == \"temp\")\n"+" |> aggregateWindow(every: 5m, fn: mean, createEmpty: false)\n"+" |> sort(columns: [\"_time\"])",hours,deviceId);List<FluxTable>tables=influxDBUtil.queryRaw(flux);List<String>timeAxis=newArrayList<>();List<Double>tempValues=newArrayList<>();for(FluxTabletable:tables){for(FluxRecordrecord:table.getRecords()){timeAxis.add(formatTime(record.getTime()));tempValues.add((Double)record.getValue());}}Map<String,Object>result=newHashMap<>();result.put("timeAxis",timeAxis);result.put("tempValues",tempValues);result.put("deviceId",deviceId);returnresult;}privateStringformatTime(Instantinstant){returnDateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()).format(instant);}}前端 ECharts 渲染
<divid="tempChart"style="width:100%;height:400px;"></div><scriptsrc="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script><script>constchart=echarts.init(document.getElementById('tempChart'));fetch('/api/chart/temp-trend?deviceId=cabinet-001&hours=1').then(res=>res.json()).then(data=>{chart.setOption({title:{text:data.deviceId+' 温度变化'},tooltip:{trigger:'axis'},xAxis:{type:'category',data:data.timeAxis},yAxis:{type:'value',name:'温度(°C)'},series:[{name:'温度',type:'line',data:data.tempValues,smooth:true,markLine:{data:[{yAxis:10,name:'高温阈值',lineStyle:{color:'#ff0000'}}]}}]});});</script>markLine画了一条红色的高温阈值线,数据曲线超过这条线就能直观看到异常。
五、报表统计场景
日报/周报/月报自动生成
用 InfluxDB Task 定时聚合数据,生成报表写入 MySQL,前端直接查 MySQL 展示:
// Task: 每天凌晨1点生成昨天的日报 option task = { name: "daily_report", every: 1d, offset: 1h } // 日均温度 avgTemp = from(bucket: "cabinet_raw") |> range(start: -1d) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["_field"] == "temp") |> group(columns: ["device_id"]) |> mean() |> set(key: "_field", value: "daily_avg_temp") // 日最高温度 maxTemp = from(bucket: "cabinet_raw") |> range(start: -1d) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["_field"] == "temp") |> group(columns: ["device_id"]) |> max() |> set(key: "_field", value: "daily_max_temp") // 合并写入日报Bucket union(tables: [avgTemp, maxTemp]) |> to(bucket: "cabinet_daily", org: "your-org")SpringBoot 再起一个定时任务,每天凌晨2点从cabinet_daily读取数据写入 MySQL 报表表,供前端查询展示。
设备运行率统计
设备运行率 = 运行时长 / 总时长 × 100%。通过统计设备状态 measurement 中compressor_on = true的时长占比来计算:
// 统计某设备过去24小时的压缩机运行率 running = from(bucket: "cabinet_raw") |> range(start: -24h) |> filter(fn: (r) => r["_measurement"] == "cabinet_status") |> filter(fn: (r) => r["device_id"] == "cabinet-001") |> filter(fn: (r) => r["_field"] == "compressor_on") |> filter(fn: (r) => r["_value"] == 1) |> count() total = from(bucket: "cabinet_raw") |> range(start: -24h) |> filter(fn: (r) => r["_measurement"] == "cabinet_status") |> filter(fn: (r) => r["device_id"] == "cabinet-001") |> filter(fn: (r) => r["_field"] == "compressor_on") |> count() // 运行率 = running / total × 100% // 在Java端计算百分比异常告警次数排行
统计各设备过去7天的告警次数,排出Top 10:
from(bucket: "cabinet_raw") |> range(start: -7d) |> filter(fn: (r) => r["_measurement"] == "cabinet_events") |> filter(fn: (r) => r["event_type"] == "ALARM") |> group(columns: ["device_id"]) |> count() |> group() |> sort(columns: ["_value"], desc: true) |> limit(n: 10)结果直接喂给 ECharts 画柱状图,哪个设备老出问题一目了然。
六、异常数据分析
阈值告警
最简单直接的异常检测:设一个上下限,超出就告警。
// 阈值规则配置publicclassAlarmRule{privateStringfield;// 监控字段: temp, voltage, currentprivatedoubleminValue;// 下限privatedoublemaxValue;// 上限privateintdurationSec;// 持续时间(秒),避免瞬时波动误报}// 检查逻辑publicbooleancheckThreshold(Stringfield,doublevalue,AlarmRulerule){returnvalue<rule.getMinValue()||value>rule.getMaxValue();}关键细节是持续时间判断:温度瞬间跳到11°C然后马上回落,不算异常(可能是传感器噪声)。只有持续超过阈值5分钟才告警。用 Flux 实现:
// 温度持续5分钟超过10°C才告警 from(bucket: "cabinet_raw") |> range(start: -10m) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics") |> filter(fn: (r) => r["_field"] == "temp") |> filter(fn: (r) => r["_value"] > 10.0) |> group(columns: ["device_id"]) |> count() // 如果5分钟内有超过60条(5分钟÷5秒=60条),说明持续超温突变检测
有些异常不是超阈值,而是"突然跳变"。比如温度一直稳定在5°C,突然跳到8°C——虽然没超10°C阈值,但这种跳变本身可能意味着制冷系统出了问题。
/** * 突变检测:当前值与过去N分钟平均值的偏差超过阈值 */publicbooleancheckSuddenChange(StringdeviceId,doublecurrentValue,Stringfield){// 查过去10分钟的平均值Stringflux=String.format("from(bucket: \"cabinet_raw\")\n"+" |> range(start: -10m)\n"+" |> filter(fn: (r) => r[\"_measurement\"] == \"cabinet_metrics\")\n"+" |> filter(fn: (r) => r[\"device_id\"] == \"%s\")\n"+" |> filter(fn: (r) => r[\"_field\"] == \"%s\")\n"+" |> mean()",deviceId,field);doubleavgValue=queryAvgValue(flux);doubledeviation=Math.abs(currentValue-avgValue);// 温度偏差超过3°C视为突变returndeviation>3.0;}同比环比
把当前数据和历史同期对比,发现"不正常但没超阈值"的异常。比如某台售货柜本周平均温度比上周高了2°C,虽然都还在正常范围内,但这个上升趋势可能意味着制冷系统在缓慢老化。
// 本周平均温度 thisWeek = from(bucket: "cabinet_10m") |> range(start: -7d) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics_10m") |> filter(fn: (r) => r["device_id"] == "cabinet-001") |> filter(fn: (r) => r["_field"] == "temp") |> mean() |> findRecord(fn: (key) => true, idx: 0) // 上周平均温度 lastWeek = from(bucket: "cabinet_10m") |> range(start: -14d, stop: -7d) |> filter(fn: (r) => r["_measurement"] == "cabinet_metrics_10m") |> filter(fn: (r) => r["device_id"] == "cabinet-001") |> filter(fn: (r) => r["_field"] == "temp") |> mean() |> findRecord(fn: (key) => true, idx: 0) // 输出环比变化 difference = thisWeek._value - lastWeek._value七、无人售货柜运营数据可视化看板实战
把以上内容整合成一个完整的运营看板:
看板布局
┌─────────────────────────────────────────────────────────┐ │ 无人售货柜运营看板 │ ├──────────────┬──────────────┬──────────────┬───────────┤ │ 在线设备数 │ 今日告警次数 │ 平均温度 │ 异常设备数 │ │ 985/1000 │ 23 │ 5.2°C │ 15 │ ├──────────────┴──────────────┴──────────────┴───────────┤ │ │ │ 全设备实时温度分布图(热力图) │ │ │ ├─────────────────────────┬───────────────────────────────┤ │ │ │ │ Top10 告警设备排行 │ 设备运行率统计(柱状图) │ │ (柱状图) │ │ │ │ │ ├─────────────────────────┴───────────────────────────────┤ │ │ │ 选中设备温度/电压/电流趋势图(多Y轴折线图) │ │ │ ├─────────────────────────────────────────────────────────┤ │ 设备明细表格(设备ID | 温度 | 电压 | 状态 | 最后上报时间) │ └─────────────────────────────────────────────────────────┘数据来源拆分
| 看板模块 | 数据源 | 查询方式 |
|---|---|---|
| 在线设备数 | InfluxDB cabinet_raw | 统计5分钟内有数据的设备数 |
| 今日告警次数 | InfluxDB cabinet_events | count + filter(event_type=ALARM) |
| 平均温度 | InfluxDB cabinet_raw | 所有设备temp的mean() |
| 异常设备数 | 应用层计算 | 温度超阈值的设备去重计数 |
| 温度热力图 | InfluxDB cabinet_raw | group(device_id) + last() |
| 告警排行 | InfluxDB cabinet_events | group(device_id) + count + sort + limit |
| 运行率 | InfluxDB cabinet_status | 压缩机ON时长占比 |
| 趋势图 | InfluxDB cabinet_raw | aggregateWindow(mean) |
| 设备明细表格 | InfluxDB cabinet_raw | group(device_id) + last() |
这套看板用 Grafana 搭,一个下午就能搞定。如果用 ECharts 自研,大概需要2~3天,但可以和你的业务后台深度整合。
数据可视化不是"锦上添花",而是数据落地的最后一公里。采得到、存得好、查得快,最后还得"看得见"——运营人员看到的不是数字,是设备健康度、是业务趋势、是需要立即处理的异常。这条链路通了,你的时序数据平台才真正有价值。