MQTT+时序数据库:海量农业传感数据存储、趋势报表实现

MQTT+时序数据库:海量农业传感数据存储、趋势报表实现

大家好,我是「黒漂技术佬」。前两期聊了消息幂等和设备监控,这期聊一个"量变引起质变"的话题------当传感器数据多到MySQL扛不住的时候,怎么办?

一个中型智慧农场,50个大棚,每个大棚5种传感器(温度、湿度、土壤湿度、光照、CO2),每10秒上报一次。来,算笔账:

ini 复制代码
单条数据 ≈ 100字节
每秒写入 = 50 × 5 = 250条
每天写入 = 250 × 86400 = 2160万条 ≈ 2GB
每年写入 ≈ 80亿条 ≈ 700GB

MySQL单表过亿行之后,连COUNT(*)都能让你怀疑人生。 这不是优化能解决的,是选型问题。你需要时序数据库。


一、为什么是MQTT + InfluxDB?

这不是拍脑袋的组合,是天然匹配:

技术 职责
数据接入 MQTT (EMQX) 海量设备并发连接、消息路由、Topic过滤
数据处理 SpringBoot消费者 数据清洗、格式转换、幂等去重、业务逻辑
数据存储 InfluxDB 高性能时序写入、时间窗口聚合、降采样
数据可视化 Grafana 仪表盘、趋势图、告警面板

MQTT负责"接得住",InfluxDB负责"存得下",Grafana负责"看得清"------一条龙。


二、完整数据流架构

scss 复制代码
┌──────────┐   MQTT    ┌──────────┐  消费写入  ┌──────────┐  查询展示  ┌──────────┐
│ 传感器设备 │ ───────→ │ EMQX集群  │ ───────→ │ SpringBoot│ ───────→ │ InfluxDB  │ ←───── │ Grafana   │
│  (50大棚)  │  QoS 1  │ (Broker)  │  Shared  │ (消费者)  │ 批量写入  │ (时序库)  │  Flux   │ (可视化)  │
└──────────┘          └──────────┘  Sub     └──────────┘          └──────────┘          └──────────┘
                                          │
                                          ▼
                                    ┌──────────┐
                                    │  Redis    │ (去重缓存)
                                    └──────────┘

核心链路:传感器 → MQTT Broker → SpringBoot消费 → 批量写入InfluxDB → Grafana出图。MySQL只存设备台账、用户配置等关系型数据,传感器数据归时序库管


三、InfluxDB存储方案设计

3.1 核心概念快速入门

InfluxDB和MySQL的组织方式完全不同,你得换个脑子:

MySQL概念 InfluxDB概念 说明
Database Bucket 数据桶,相当于一个数据库
Table Measurement 测量,相当于一张表
Column Field 字段,存具体数值(不带索引)
Index Column Tag 标签,存元数据(带索引,用于WHERE过滤)
Row Point 数据点,一行记录
- Timestamp 时间戳,每一条数据必须有

3.2 Measurement设计

推荐方案:统一表结构。所有传感器数据存在同一个Measurement,用tag区分:

java 复制代码
// 数据点结构
Point point = Point.measurement("sensor_data")
    .addTag("farm_id", "farm_01")           // 农场
    .addTag("greenhouse_id", "gh_03")       // 大棚
    .addTag("device_id", "dev_001")         // 设备
    .addTag("sensor_type", "temperature")   // 传感器类型
    .addField("value", 28.5)                // 数值
    .addField("unit", "celsius")            // 单位(可选)
    .time(Instant.now(), WritePrecision.MS);

为什么不按传感器类型建多张表? 统一表方案的好处:

  • 管理简单,一个Bucket一张Measurement搞定
  • 跨传感器查询方便(比如"同一大棚的温度和湿度")
  • Grafana配置统一,变更tag值即可切换传感器类型

分开表的优势是查询时自然隔离,但带来的管理成本远大于收益。

3.3 Tag vs Field------避坑指南

新手最容易犯的错误是把高基数字段放到Tag里:

java 复制代码
// ❌ 错误:value是连续变化的,放到Tag会导致索引爆炸
.addTag("value", "28.5")

// ✅ 正确:value放到Field,只有枚举/低基数字段放Tag
.addTag("sensor_type", "temperature")
.addField("value", 28.5)

Tag的黄金法则:Tag用于WHERE过滤和GROUP BY分组,Field用于存储实际测量值。Tag基数越低越好(几十到几百种),Field可以是任意变化值。如果把每个不同的温度值都存成Tag,InfluxDB的索引能把你内存吃干抹净。


四、写入优化

4.1 批量积攒写入

传感器数据不要来一条写一条------网络开销和系统调用比你想象的贵:

java 复制代码
@Service
public class SensorDataWriter {

    @Autowired
    private WriteApiBlocking writeApi;
    
    private final BlockingQueue<Point> buffer = new LinkedBlockingQueue<>(10000);
    
    @PostConstruct
    public void init() {
        // 后台线程批量刷盘
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(this::flushBuffer, 10, 10, TimeUnit.SECONDS);
    }
    
    /**
     * 接收传感器数据,先放缓冲区
     */
    public void append(Point point) {
        buffer.offer(point);
    }
    
    /**
     * 积攒1000条或满10秒后批量写入
     */
    private void flushBuffer() {
        List<Point> batch = new ArrayList<>(1000);
        buffer.drainTo(batch, 1000);
        
        if (!batch.isEmpty()) {
            try {
                writeApi.writePoints(batch);
                log.debug("批量写入InfluxDB成功, 数量: {}", batch.size());
            } catch (Exception e) {
                log.error("批量写入InfluxDB失败", e);
                // 失败重试或写死信队列
            }
        }
    }
}

4.2 异步非阻塞写入

生产环境推荐异步写入API------不阻塞MQTT消费线程:

java 复制代码
// 初始化异步WriteApi
WriteApi asyncWriteApi = influxDBClient.makeWriteApi(
    WriteOptions.builder()
        .batchSize(5000)           // 批量大小
        .flushInterval(5000)       // 刷新间隔(毫秒)
        .retryInterval(1000)       // 失败重试间隔
        .maxRetries(3)             // 最大重试次数
        .bufferLimit(100000)       // 缓冲区上限
        .build()
);

InfluxDB单节点写入性能轻松达到10万点/秒以上,应对中大型农场绰绰有余。


五、数据降采样和保留策略

原始数据全量永久保存既不现实也没必要。温度十秒一条存三年------谁来查?真需要查的时候多半也是看趋势。

5.1 分层存储策略

arduino 复制代码
原始数据 (10s间隔) → 保留30天 → 聚合到1分钟
1分钟聚合 (avg/max/min) → 保留1年 → 聚合到1小时
1小时聚合 → 保留3年 → 聚合到1天
1天聚合 → 永久保留

5.2 InfluxDB Task自动降采样

javascript 复制代码
// Flux脚本:每10分钟执行一次,将原始数据聚合为1分钟粒度
option task = {
    name: "sensor_downsample_1m",
    every: 10m,
}

from(bucket: "agriculture_sensor")
    |> range(start: -20m)                    // 处理最近20分钟的数据
    |> filter(fn: (r) => r._measurement == "sensor_data")
    |> aggregateWindow(every: 1m, fn: mean)  // 按1分钟窗口取均值
    |> set(key: "_measurement", value: "sensor_data_1m")  // 写入降采样桶
    |> to(bucket: "agriculture_sensor_1m")

同理配置1小时和1天的降采样Task。一个完整的保留策略建好后,存储成本大幅下降,查询速度反而更快。


六、趋势报表实现

6.1 日报:过去24小时温湿度变化

javascript 复制代码
// Flux查询:大棚A3的24小时温度趋势
from(bucket: "agriculture_sensor")
    |> range(start: -24h)
    |> filter(fn: (r) => r._measurement == "sensor_data")
    |> filter(fn: (r) => r.greenhouse_id == "gh_03")
    |> filter(fn: (r) => r.sensor_type == "temperature")
    |> filter(fn: (r) => r._field == "value")
    |> aggregateWindow(every: 10m, fn: mean)
    |> yield(name: "temperature_24h")

6.2 周报:每日最高/最低/平均温

javascript 复制代码
from(bucket: "agriculture_sensor")
    |> range(start: -7d)
    |> filter(fn: (r) => r._measurement == "sensor_data")
    |> filter(fn: (r) => r.sensor_type == "temperature" and r._field == "value")
    |> aggregateWindow(every: 1d, fn: max, createEmpty: false)
    |> yield(name: "daily_max")

// 同理把max换成min和mean各跑一次,或者一次query多次yield

6.3 月度极值分析

javascript 复制代码
from(bucket: "agriculture_sensor_1h")  // 直接用小时级聚合数据,超快
    |> range(start: -30d)
    |> filter(fn: (r) => r.sensor_type == "temperature" and r._field == "value")
    |> max()   // 全月最高温
    |> yield(name: "monthly_max")

// 配合 group() 还可以按大棚分组:
// |> group(columns: ["greenhouse_id"])
// |> max()

6.4 SpringBoot端报表接口

java 复制代码
@RestController
@RequestMapping("/api/report")
public class SensorReportController {

    @Autowired
    private InfluxDBClient influxDBClient;
    
    @GetMapping("/daily")
    public List<DataPoint> dailyReport(
            @RequestParam String greenhouseId,
            @RequestParam String sensorType,
            @RequestParam(defaultValue = "24") int hours) {
        
        String flux = String.format(
            "from(bucket: \"agriculture_sensor\") " +
            "|> range(start: -%dh) " +
            "|> filter(fn: (r) => r.greenhouse_id == \"%s\" and r.sensor_type == \"%s\" and r._field == \"value\") " +
            "|> aggregateWindow(every: 10m, fn: mean)",
            hours, greenhouseId, sensorType);
        
        QueryApi queryApi = influxDBClient.getQueryApi();
        List<DataPoint> result = new ArrayList<>();
        
        queryApi.query(flux, (record) -> {
            DataPoint dp = new DataPoint();
            dp.setTime(record.getTime());
            dp.setValue((Double) record.getValue());
            result.add(dp);
        });
        
        return result;
    }
}

七、大数据量查询优化

7.1 时间范围分区查询

不要一次查一整年的数据作折线图------Grafana前端撑不住,后端也慢:

javascript 复制代码
// ❌ 查一年10秒粒度的原始数据------找死
range(start: -365d) + aggregateWindow(every: 10s)

// ✅ 根据查询范围自适应聚合粒度
// < 6小时 → 10s粒度
// 6-24小时 → 1m粒度
// 1-7天 → 10m粒度
// > 7天 → 1h粒度

7.2 预聚合是王道

日报周报不要实时从原始数据算------建降采样Task提前算好,报表接口直接查聚合结果,查询延迟从秒级降到毫秒级。


智慧农业的数据量会随着规模扩大而暴涨,但InfluxDB的设计哲学就是"让你存得下、查得快"。MQTT负责数据接入,InfluxDB负责数据归宿,Grafana负责可视化------三者配合,你的农业物联网平台就有了坚实的数据底座。

------ 黒漂技术佬

相关推荐
阿拉斯攀登16 分钟前
MQTT消息幂等处理:避免重复控设备、重复数据入库问题
人工智能
长谷深风11117 分钟前
Agent 的 Context 里,到底应该放什么?
大数据·人工智能·prompt工程·ai agent·智能体·context工程·systemprompt
dragonimp18 分钟前
别把业务拍扁成语义网:被低估的“元模型“中间层
人工智能
xierui12312318 分钟前
Anthropic安全复盘:会操作电脑的 Agent 如何分阶段上岗
人工智能·网络安全·架构·系统架构
LearnYard22 分钟前
在线IT教育平台中的AI智能体系统架构分析——以职坐标为例
人工智能·系统架构
极客猴子23 分钟前
iPhone免费版支持思维导图导出的会议APP推荐:导出能力对照
人工智能·智能手机·音视频·机器翻译
Mr数据杨26 分钟前
【Codex】用智能助手问题反馈模块收集AI使用问题
人工智能·django·codex·项目开发
2601_9563198828 分钟前
先把交易想法说清,再让 Python 承接
人工智能·python