现场样本的温湿度变化能不能被完整记录下来,关键不在于你手上那块传感器精度标称多少,而在于从采样到上传到平台端、再到平台把这条曲线和具体某个样本对应起来的整条链路是否闭合。我做过几个冷链箱、粮仓和实验室留样的监测项目,最常见的翻车场景是这样的:设备本地存的数据漂漂亮亮,平台端的曲线却断断续续,运维看一眼就说"网络问题",换张卡、换个位置,问题还在。其实温湿度上传这件事,真正难的地方在数据传输的可靠性、时间基准的一致性,以及样本语义的绑定。这篇内容适合正在用 ESP32、STM32 这类方案做环境监测,或者准备把现场数据接进云平台、自建平台的开发者,也适合负责样本管理的业务方了解数据是怎么"可信"起来的。
1. 样本温湿度上传,卡住人的往往不是"采不到数"
很多人第一次做这个功能,目标定得很简单:传感器读出来,连上 WiFi,往平台发一条。跑通了 Demo 就以为完事了。真上现场之后才发现,读数是读到了,但平台端的曲线一会儿缺一段、一会儿又多出一段重复的,样本台账上写着"全程 2~8℃",可曲线里明明有两小时是 11℃。问题不在"采",在采完之后的三道坎。
1.1 三个断层:数据断层、语义断层、可信断层
第一个是数据断层。设备用自己内部的 RTC 打时间戳,平台用服务器时间入库,两边差几分钟甚至几小时都很常见。设备断电重启后时间归零,如果没有 NTP 校时,上报上来的时间戳可能是 1970 年或者 2000 年。这种数据混进时序库,按时间范围查询的时候就会莫名其妙地"消失",因为查询窗口根本覆盖不到它。
第二个是语义断层。平台收到的是{"temp": 5.2, "humi": 61},它并不知道这条数据属于哪个样本、哪个箱子、哪一批货。如果设备是循环复用的,今天贴在这箱试剂上,明天贴到另一箱生鲜上,那平台上如果没有明确的绑定关系,你拿到的数据就没法用来判定任何一箱货物的状态。这个断层是很多项目上线后才暴露的,因为做硬件的人默认"一台设备对一个样本",做业务的人默认"数据里应该带着样本号",谁都没写进协议。
第三个是可信断层。无线链路不是有线,丢包、重连、重复投递都是常态。MQTT 用 QoS1 的时候,同一份数据可能被投递两次以上;HTTP 上报超时后客户端重试,服务端如果没做幂等,就会入两条相同的记录。曲线上一旦出现重复点或空洞,你用来做告警的阈值判断就会误报或者漏报。
1.2 一条完整链路都包含哪些环节
把整条链路摊开,从传感器到平台可以分成五层,每层都有各自的职责和典型坑:
| 层级 | 典型器件或组件 | 核心动作 | 最常见的坑 |
|---|---|---|---|
| 感知层 | SHT30、SHT31、DHT22、DS18B20 | 周期采样、数字滤波 | 自发热、结露、安装位置贴壁 |
| 控制层 | ESP32、STM32F103、STM32L4 | 组帧、本地缓存、低功耗调度 | 断电丢数据、RTC 漂移、看门狗复位 |
| 传输层 | WiFi、4G Cat.1、NB-IoT、LoRa 网关 | 建立连接、上报、重传 | 弱网超时、DNS 失败、证书过期 |
| 接入层 | MQTT Broker、HTTP 网关、云 IoT 平台 | 鉴权、限流、消息路由 | Topic 权限、连接数上限、消息体超限 |
| 应用层 | 时序库、业务库、看板、告警服务 | 入库、聚合、判定、展示 | 重复入库、时区错乱、样本未绑定 |
这张表建议你在动手前先照着过一遍,把每一层谁负责、用什么实现先定下来。我见过太多项目是先把硬件焊好、代码写完,到对接平台的时候才发现数据模型对不上,回头改协议,硬件那边的缓存结构也得跟着动,代价比前期想清楚大得多。
1.3 先定数据契约,再画电路图
一个特别省事的做法是:在画原理图之前,先把上报报文的 JSON 结构定死,然后反推设备端需要哪些能力。比如下面这个结构,是我目前在用的一个精简版本:
{ "dev": "SN20240517001", "sample": "BATCH-A-20240517-03", "seq": 10247, "ts_dev": 1715948621, "ts_dev_ok": true, "temp": 5.2, "humi": 61.4, "batt": 3.72, "rssi": -71, "flags": 0 }几个字段值得单独说。seq是设备侧单调递增的序号,用来做去重和丢包检测——平台收到 10245 之后直接跳到 10247,中间那条大概率丢了。ts_dev_ok标记设备时间是否已经完成过 NTP 校时,平台拿到false的时候就用服务端接收时间入库,避免用 1970 年的时间戳污染时序库。flags是一个位图,可以塞传感器故障、开盖、低电量这些状态位,比每个状态单独加一个字段更省流量。
注意:把样本编号放在上报报文里,而不是只靠设备和样本的绑定关系表来查,是因为绑定表需要维护,一旦有人改了绑定而没通知平台,历史数据就乱了。报文里带样本号,入库存一份快照,绑定表只作为辅助修正手段。
2. 采集端选型:ESP32 和 STM32 到底该怎么选
这个选择被问得最多,其实没有绝对答案,取决于你的现场条件。判断标准不是"哪个芯片更强",而是"你的现场有没有稳定供电、有没有 WiFi 覆盖、电池要撑多久、样本箱是不是移动的"。把这四个问题回答清楚,选型基本就定了。
2.1 传感器选型的三个陷阱
先说传感器。SHT30/SHT31 这类数字式温湿度传感器,标称温度精度 ±0.3℃、湿度 ±3%RH,实际用起来最容易被忽略的是自发热和结露。自发热指的是传感器内部电路工作产生的热量会让读数比环境高 0.2~0.5℃,尤其是采样频率高、封装散热差的时候。我在一个试剂箱项目里就遇到过,同款传感器,贴在箱壁内侧的比悬在箱子中央的高出 0.6℃,一度导致低温告警误报。
结露的问题更隐蔽。从冷库取出的样本箱放进常温环境,传感器表面会起一层水膜,湿度读数会直接冲到 100%RH 并持续一段时间,温度读数也会因为水膜蒸发吸热而偏低。如果这时候正好触发阈值告警,你就会收到一堆无意义的告警。常规做法是在固件里加一个简易判定:湿度连续多次读到 100%RH 且温度下降速率异常,就把这组数据标记为可疑,或者干脆等待一个稳定窗口再上报。
DHT22 便宜、资料多,适合做验证,但长期漂移比 SHT3x 大,而且它对采样间隔有要求,最短两秒一次,密集采样很容易读到错误值。DS18B20 只测温,精度不错、防水封装好做,做纯温度监测(比如冷链)其实很合适,就是别忘了它需要一条上拉电阻,而且一总线挂多个探头时要注意布线长度。
2.2 ESP32 方案:验证快、联网省事,但要防"睡死"
如果你的现场有 WiFi 覆盖、供电相对稳定(哪怕是 USB 充电宝级别的移动电源),ESP32 是最省心的选择。它把 WiFi 协议栈、TCP/IP、TLS 都做在芯片里,用 Arduino 框架写起来非常快。下面这段是我常用的最小上报逻辑,核心是深睡眠 + 定时唤醒 + 上报后立刻断连,而不是一直保持长连接:
#include <WiFi.h> #include <PubSubClient.h> RTC_DATA_ATTR uint32_t seqCounter = 0; void setup() { float t = readTemp(); float h = readHumi(); WiFi.begin(SSID, PASS); unsigned long t0 = millis(); while (WiFi.status() != WL_CONNECTED && millis() - t0 < 8000) { delay(100); } if (WiFi.status() == WL_CONNECTED) { PubSubClient mqtt(client); mqtt.setServer(BROKER, 1883); if (mqtt.connect(DEV_ID)) { String payload = buildJson(++seqCounter, t, h); mqtt.publish(TOPIC_UP, payload.c_str(), false); mqtt.loop(); delay(200); } WiFi.disconnect(true); } esp_sleep_enable_timer_wakeup(60ULL * 1000000ULL); esp_deep_sleep_start(); }这里有几个细节是踩过坑才加的。delay(200)是为了让 MQTT 的 PUBLISH 报文真正发出去,因为publish()只是把数据放进发送缓冲,立刻进深睡眠会丢报文。用RTC_DATA_ATTR保存序号是因为深睡眠会清空普通内存变量,不保存的话每次醒来 seq 都从 0 开始,平台端的去重逻辑就失效了。
ESP32 最常见的问题是"睡死"或者连不上路由器。前者一般是唤醒后 WiFi 连接超时没做兜底,程序卡在while循环里直到看门狗复位,或者干脆电池耗干;所以上面的代码里我加了 8 秒超时。后者通常是路由器限制了单设备连接数,或者设备用的静态 IP 和现场网段冲突,工程部署前一定要在目标现场实测一轮,别只在办公室里调通就发货。
2.3 STM32 + 独立通信模组:适合低功耗与工业现场
STM32 本身没有网络能力,需要外挂模组。好处是控制力强、功耗可以做得很低(STM32L4 系列 STOP 模式下电流能到微安级),适合电池要撑几个月的场景,比如集装箱内的独立记录仪。坏处是你得自己处理很多事:模组的 AT 指令时序、TCP 重连、缓存管理、TLS 握手(如果要走 HTTPS)。
我的做法是把整个系统分成三层:采集任务只负责把温湿度写进环形缓冲区,通信任务负责从缓冲区取数据往外发并标记已发送,管理任务负责看门狗喂狗和低功耗调度。三层通过一个简单的状态机交互,不要在中断里做任何网络相关的操作——模组的 AT 交互耗时动不动几百毫秒,放在中断里会打乱所有时序。
工业现场还有一个绕不开的点是 RS485/Modbus。很多现成的温湿度变送器是 Modbus RTU 输出的,STM32 通过 485 芯片去轮询。轮询要注意帧间隔和超时重试,总线上设备多了以后,一次轮询周期可能超过你的采样间隔,这时候要么降低轮询频率,要么把关键探头单独挂在一条总线上。
2.4 采样周期怎么定,缓存要留多大
采样间隔不是拍脑袋定的。我的经验是:先看业务要求的最短异常持续时间,再倒推采样间隔。如果业务上认为"温度超过 8℃ 持续 10 分钟才算异常",那你的采样间隔至少要小于 5 分钟,最好 1~2 分钟,否则你连异常持续了多久都算不准。
缓存容量按最坏情况算。假设 1 分钟一条,报文 120 字节,现场可能断网 3 天,那就是 4320 条、约 520KB。ESP32 有 4MB 以上 Flash,用 LittleFS 存一个小文件完全够;STM32 如果只有 64KB RAM,就得上外部 SPI Flash,用环形队列的方式覆盖最旧的数据。要预留一个水位线告警,缓存用到 80% 的时候往平台发一条状态上报,让运维提前知道这台设备可能马上要丢数据了。
3. 上传链路的三条路:MQTT、HTTP,以及"先落地再补偿"
传输方式的选择直接决定了你能拿到什么质量的数据。这块我不想讲教科书式的对比,直接说结论:移动网络或 WiFi 相对稳定的场景用 MQTT,网络很抖或者要穿透复杂网络限制的场景用 HTTP 批量上报,绝对不稳定的场景用"本地存储 + 事后补偿"。
3.1 MQTT 的 Topic 设计和 QoS 选择
Topic 设计上,我偏好把上行和下行分开,并且把设备维度放在前面,方便做主题级权限控制:
# 设备上行 up/env/{deviceId}/telemetry up/env/{deviceId}/status # 平台下行 down/env/{deviceId}/cmd down/env/{deviceId}/config这样在 Broker 的 ACL 里可以简单写成"设备只能发布up/env/{自己的deviceId}/#,只能订阅down/env/{自己的deviceId}/#",安全边界非常清楚。如果多个设备共用一套账号密码,出问题的时候你连是哪台设备被别人顶替了都不知道。
QoS 的选择是个权衡。QoS0 最快但会丢,只适合高频非关键数据。QoS1 保证送达但可能重复,所以你的平台端一定要用seq做去重,或者在入库前用(deviceId, seq)建唯一索引。QoS2 理论上只送一次,但握手开销大,低功耗设备上不太划算——我一般不用。
还有一个容易忽略的点是保留消息和遗嘱消息。给status主题设 retained,平台新订阅的时候能立刻拿到设备最后状态,不用等下一次心跳;遗嘱消息(LWT)用来在设备异常断连时发布一条离线通知,这样平台能及时发现掉线设备。这两样配起来,运维端的"在线状态"才准。
3.2 HTTP 上报的坑:超时、重试和幂等
HTTP 的好处是通用,任何平台都支持,中间经过的代理、网关也不用特殊配置。坏处是每次请求都有完整的 TCP/TLS 握手开销,低功耗设备上很费电,而且长连接容易被中间设备掐断。
用 HTTP 时必须做三件事。第一是设置合理的超时,连接超时给 5 秒、读超时给 10 秒,别用默认的无限等待。第二是指数退避重试,失败后等 1 秒、2 秒、4 秒再试,最多三次,超过就写回本地缓存等下一轮。第三是幂等设计,请求头里带一个唯一请求 ID(可以就用deviceId + seq),服务端看到重复 ID 直接返回成功但不入库。
批量上报能显著降低开销。把 10~30 条数据打成一个数组发一次,比逐条发省很多电。但批量大小要控制,报文体太大会被网关或平台拒绝,常见的限制是 32KB 到 100KB 不等,保险起见单批控制在 8KB 以内。
3.3 弱网现场的本地缓存与断点续传
如果你的样本箱要经过隧道、地下车库、偏远地区,那"先落地再补偿"是唯一的正解。设备端维护一个环形队列,每条数据分配一个递增 seq,同时记一个"已确认发送到哪个 seq"的游标。网络恢复后,从游标位置开始批量补发,服务端返回最新确认的 seq,游标推进。
# 服务端批量接收的简化逻辑 def ingest(rows): inserted, latest = 0, None for r in rows: key = (r["dev"], r["seq"]) if redis.sadd("seen:" + r["dev"], r["seq"]): db.execute(INSERT_SQL, r) inserted += 1 latest = max(latest or 0, r["seq"]) return {"ok": True, "inserted": inserted, "ack_seq": latest}这里用 Redis 的集合做去重是个廉价有效的办法,缺点是会占内存,可以用带过期时间的键或者定期清理。如果数据量不大,直接在时序表上建(dev, seq)唯一索引更省事,冲突时忽略即可。
补发的时候要注意限速。几十台设备同时恢复网络,一起往上怼数据,平台侧的写入压力会瞬间拉高。我在设备端加了一个令牌桶,每秒最多发 5 条,或者每批之间 sleep 200 毫秒,效果很明显。
3.4 上报失败怎么定位:一份对照清单
"上传失败:网络请求错误"这种报错信息见过太多了,它几乎什么都没告诉你。下面这张表是我自己总结的定位顺序,按可能性从高到低排:
| 现象 | 可能原因 | 定位手段 |
|---|---|---|
| 一直连不上 Broker | DNS 解析失败、Broker 端口被拦 | 用 IP 直连测试、换端口试 |
| TLS 握手失败 | 设备时间不对、根证书过期 | 检查 NTP 是否成功、更新证书 |
| 连上就断,反复重连 | 客户端 ID 冲突(两台设备同 ID) | 用唯一 ID,看 Broker 日志踢人记录 |
| 单条上报成功,批量失败 | 报文体超过平台限制 | 抓包看报文大小,减小批量 |
| 平台侧收到但没入库 | Topic 权限不对、字段名不匹配 | 看 Broker 订阅日志、校验 JSON schema |
| 数据重复 | QoS1 重投、HTTP 重试未做幂等 | 按 (dev, seq) 去重 |
| 时间戳错乱 | RTC 未校时、时区处理错误 | 统一用 UTC 秒级时间戳 |
排查的时候有一条铁律:先在设备端打印原始报文,再在 Broker 或网关侧抓一次,最后看数据库。三个环节对比,问题出在哪一段一目了然。最忌讳的就是直接去改代码,改完发现还是不行,最后连最初的现象都复现不出来。
4. 平台端怎么"看懂"样本:数据模型与告警逻辑
数据传上来了,平台端的活儿才刚开头。要让平台能回答"这批样本现在还好吗",需要三样东西:一张能把设备、样本、批次串起来的关系模型,一套符合业务定义的判定规则,以及能扛住查询压力的存储结构。
4.1 样本、设备、批次的三张表
我的习惯是把业务数据和时序数据分开存。业务库(MySQL 或 PostgreSQL)存关系,时序库(TDengine、InfluxDB、TimescaleDB 都行)存温湿度点位。业务库大概长这样:
CREATE TABLE sample ( sample_id VARCHAR(32) PRIMARY KEY, batch_no VARCHAR(32) NOT NULL, name VARCHAR(128), temp_min DECIMAL(4,1), temp_max DECIMAL(4,1), start_at DATETIME, end_at DATETIME ); CREATE TABLE device ( dev_id VARCHAR(32) PRIMARY KEY, model VARCHAR(32), fw_ver VARCHAR(16), last_seen DATETIME ); CREATE TABLE binding ( id BIGINT AUTO_INCREMENT PRIMARY KEY, dev_id VARCHAR(32), sample_id VARCHAR(32), bind_at DATETIME, unbind_at DATETIME, UNIQUE KEY uk_dev_sample (dev_id, sample_id, bind_at) );关键在binding表用了时间区间,而不是简单的"设备属于样本"一条记录。因为设备会复用到不同的样本上,历史查询的时候必须按上报时间落在这个区间里去找对应样本。如果只存一条当前绑定,那查上周的数据就会全部对到今天这个样本上,这是很典型的错误。
时序表里我建议冗余存一份sample_id。虽然有点反范式,但查询"某个样本的完整温湿度曲线"的时候,一句WHERE sample_id = ?就能搞定,不用去 join 业务库,性能差别非常大。
4.2 阈值告警之外,还要看累计暴露量
简单的高低温阈值告警是最基础的,但它解决不了"样本到底还能不能用"这个问题。温度在允许范围内波动,和温度长时间贴着上限走,对样本的影响是不一样的。所以我在平台上加了两个指标:
一个是累计超限时长。规定温度上限 8℃,任何一次高于 8℃ 的持续片段都累加起来,超过比如 30 分钟就升级告警。计算方式是遍历降采样后的序列,统计连续超出区间的点数和采样间隔的乘积。
另一个是MKT(平均动力学温度),这个概念在药品和食品冷链里用得比较多,简单说就是用阿伦尼乌斯方程折算出来的等效温度,能反映"温度波动对样本的实际影响相当于在某个恒温下放了同样久"。简化后的表达式是:
MKT = (ΔH / R) / ln( ( e^(-ΔH/(R·T1)) + e^(-ΔH/(R·T2)) + ... ) / n )
其中 ΔH 是活化能(常用 83.144 kJ/mol),R 是气体常数 8.314 J/(mol·K),T 是绝对温度。这个公式看着吓人,代码里就是一个循环累加指数项,几十行就能实现。它的好处是能把"虽然没超限,但一直在偏高的位置波动"这种情况量化出来。
4.3 查询统计功能怎么做到又快又不失真
样本监测的查询有两个特点:实时看板要秒级刷新最近的曲线,历史回溯要查几个月前的完整记录,还要出统计报表(最高温、最低温、平均湿度、超限次数)。这两种需求的存储策略完全不同。
实时部分用原始精度存,保留 7 到 30 天,按天分区。历史部分做降采样,1 分钟原始数据聚成 5 分钟或 1 小时的平均/最大/最小,长期保留。降采样必须保留最大最小值,不能只留平均值——平均值会把一个瞬间的高温尖峰抹平,而那个尖峰恰恰是判定的关键。
统计报表不要每次实时算。用定时任务在每天凌晨跑一次预聚合,把结果写进一张统计表,前端查报表直接读这张表,响应时间从几秒降到几十毫秒。这个改动在数据量上来之后几乎是一定要做的,早做早省心。
5. 现场联调和长期运行中,我踩过的几个坑
代码在办公室里跑通,和现场稳定运行三个月,完全是两码事。下面这些是我在真实项目里反复遇到的问题,写出来供你避坑。
5.1 时间同步这件事,比想象中重要
设备的 RTC 一天漂几秒很正常,一个月下来就是几分钟。如果平台用设备时间戳入库,那不同设备之间的曲线就对不齐了。我的做法是双轨制:设备每次联网优先做一次 NTP,失败就用内部 RTC 并在报文里把ts_dev_ok置为 false;平台侧落库时同时存两个时间戳,一个是上报时间(服务端接收时间),一个是设备声称的时间。做判定和告警的时候统一用服务端时间,做设备行为分析的时候再看设备时间。
还有一个细节是时区。设备端和平台端一律用 UTC 秒级时间戳,展示层再转成本地时区。我见过一个项目,设备发的是本地时间,平台当作 UTC 存,看板上所有曲线整体偏了 8 小时,排查了一整天才找到。
5.2 传感器装在哪里,决定了数据准不准
这一条不属于软件问题,但对数据质量的影响比软件大。几个原则:传感器不要贴箱壁、不要靠近电池和电源模块、不要放在出风口正对的位置、尽量放在样本区域的几何中心或者代表性位置。如果是多个探头,要做一次交叉比对,把偏差超过 0.5℃ 的探头找出来做偏移校准。
偏移校准可以写进设备的配置里,平台下发一个temp_offset参数,设备读取后参与计算。这样换探头或者重新标定的时候不用重新烧固件。
5.3 上线后最常出问题的五个地方
按我这几年遇到的频率排一下:
- 电池和供电:低温环境下锂电池容量骤降,一个标称能撑 30 天的设备可能 10 天就关机了。做低温场景一定要用低温电池,或者直接上外接电源。
- SIM 卡或网络资费:流量跑超被限速、卡被运营商回收,这类问题在批量部署后非常常见,建议做流量的用量监控和告警。
- 设备重连风暴:某个区域基站维护或者路由器重启,几十台设备同时重连,把 Broker 的连接数打满。给重连加随机抖动(0~30 秒随机延迟)能有效缓解。
- 数据库写入压力:设备数上千之后,逐条 insert 会拖垮时序库,一定要用批量写入,批次大小 500~1000 条。
- 配置变更没生效:平台下发了新的采样间隔,设备因为没订阅到或者没有持久化,重启后又变回旧的。配置参数要存在设备的非易失存储里,并且在每次上报时带上当前配置的版本号,方便核对。
排查顺序我一般是:先看设备是否在线,再看最近一次上报时间,然后抓一条原始报文看内容,最后看数据库有没有入库。绝大多数问题在前两步就能定位,剩下的小部分去看平台日志。
最后分享一个我一直在用的小技巧:给每台设备加一个"心跳包"和一个"自检上报"。心跳包每 30 分钟发一次,只带电量、信号强度和缓存水位,不带业务数据,专门用于判断在线状态;自检上报在设备启动时发一次,带上固件版本、传感器型号、校准偏移量。这两类报文的数据量都很小,但能让你在排查问题的时候省掉大量猜测——你一眼就能知道这台设备是什么版本、电量还剩多少、缓存有没有堆积,不用连现场、不用派人去看。