
做工业环境监测的同行应该都有同感温湿度传感器本身的技术门槛并不高SHT30、DHT22、DS18B20这些元件几块钱到几十块钱一根I2C或者单总线就能读出来精度也够用。真正让人头疼的是把这些数据稳定、完整、及时地送到远端服务器。我这次做的项目就是一套基于以太网的温湿度采集通讯系统核心要解决两件事多协议对接和断网不丢数。说白了现场的环境是复杂的——有的客户监控系统走Modbus TCP有的平台走MQTT有的老旧系统只认HTTP上报而现场的网络又是脆弱的——交换机重启、网线松动、路由器死机随时可能让链路断掉。这套系统的设计目标很明确采集端不挑协议上位机想怎么接就怎么接链路断了不慌数据先存在本地等网络恢复了一帧不差地补传上去。这篇文章我把整个设计思路、代码结构、踩坑记录都整理出来给正在做类似项目的朋友一个参考。1. 项目定位温湿度采集的难点从来不在传感器1.1 场景拆解三个典型现场先说场景因为所有设计取舍都是围着场景转的。我接触到的温湿度采集需求大致能分成三类第一类是厂房车间。这类现场的特点是PLC已经在跑客户不想再引入一套独立系统要求传感器数据直接进PLC的Modbus TCP通道和原有设备联锁联动。比如电镀车间的温湿度超标时要触发排风这事必须走PLC不能靠云端。第二类是仓储冷链。客户要的是多仓多点统一上云几十个采集器把数据汇到一台边缘网关再由网关统一上报到物联网平台。这类场景MQTT最合适断线重连和QoS是平台自带的语义。第三类是实验室和机房。这类现场通常是“数据要进OA系统或自研平台”对外只开一个HTTP接口甚至还要走内网代理。这种情况下采集器需要以HTTP POST的方式主动上报还得处理Token失效、返回码异常这些边界。三类场景对通讯的需求完全不同如果给每类客户单独做一套固件维护成本会失控。所以这个项目从一开始就定了基调一套底层多协议插件上层统一通讯异常全部内部消化。1.2 通讯设计的三个核心矛盾在实际设计里我总结出三个绕不开的矛盾基本上决定了整个项目的走向。第一个矛盾是实时性和可靠性的矛盾。实时上报意味着数据要立刻发送但网络抖动时立刻发就是立刻丢如果为了可靠而堆缓存又会导致延迟越来越大。最后我采用的是“实时通道可靠性补偿”双轨制正常网络下数据实时发出一旦检测到链路异常数据自动转存本地缓冲等链路恢复后按序补发。第二个矛盾是协议多样性和代码统一性的矛盾。Modbus TCP是请求-应答模型MQTT是发布-订阅模型HTTP是无状态请求模型三者的数据交互方式差异太大。如果每个协议写一套独立的上报逻辑异常处理就会各写各的没法保证断线重连和断点续传的行为一致。我的解法是加一层协议抽象内部统一成“上报数据块”的语义至于走哪个协议只影响发送动作不影响业务逻辑。第三个矛盾是本地存储容量和成本之间的矛盾。工业现场不可能配一块大硬盘专门缓存温湿度数据但断网时间又不可控可能断半天可能断三天。所以存储方案必须既省空间又保证不丢帧。我用了环形缓冲加SQLite组合的方式内存里放一个最近N条的热数据队列落盘的数据按压缩格式存逐条带序号和时间戳。这三个矛盾从这个项目的立项阶段一直贯穿到联调结束后面要讲的断线重连和断点续传本质上就是围绕这三个矛盾的展开。2. 协议选型与多协议抽象层设计2.1 Modbus TCP、MQTT、HTTP的定位差异先明确一点多协议不是炫技而是对接需求逼出来的。每个协议在温湿度采集场景里都有自己的生态位理解这一点才知道协议适配的边界在哪。Modbus TCP是工业现场的老大哥。它的特点是协议栈极简基于TCP 502端口PDU结构固定功能码03读保持寄存器、04读输入寄存器一帧报文几十个字节就能搞定。温湿度数据在PLC侧就是两个寄存器值采集器作为Modbus TCP Server把温度整数和小数分两个寄存器暴露出去就行。这个协议的优点是无状态、解析简单、实时性高缺点是几乎没有应用层安全机制也没有断点续传的语义——它天生假设链路是可靠的。MQTT是物联网上云的标准答案。基于TCP的发布-订阅消息协议支持QoS 0/1/2三级投递保证有遗愿消息LWT、会话保持Clean Session、心跳保活这些机制简直就是为断线重连场景量身定做的。缺点是它依赖Broker设备本身不直接面对最终业务系统而且QoS 1和QoS 2的语义在实际落地时有不少坑后面专门讲。HTTP上报则是兼容性最好的方案。任何一台能跑Web服务的机器都能收数据POST一个JSON过去200就完事。但这个协议最明显的问题是“无状态”服务端不会主动记得你断到了哪一帧续传必须靠应用层自己设计。另外HTTP的Keep-Alive、连接池、超时重试细节非常多用不好反而比MQTT更容易丢数据。三种协议各有各的脾气做抽象层的关键不是抹平它们的差异而是找到它们共有的抽象维度。我用的维度是连接管理、数据上报、状态反馈。每个协议插件只需要实现这三个维度核心业务代码完全复用。2.2 协议抽象层与统一数据模型这里直接给代码骨架。先定义一个通讯协议的抽象基类class ProtocolAdapter(ABC): def __init__(self, config: dict, data_queue: DataQueue): self.config config self.data_queue data_queue self.reconnect_mgr ReconnectManager() abstractmethod def connect(self) - bool: 建立连接返回是否成功 pass abstractmethod def send_payload(self, payload: bytes) - SendResult: 发送数据块返回发送结果及其中的帧序号 pass abstractmethod def is_connected(self) - bool: 当前连接是否可用 pass然后每个协议各自实现。Modbus TCP的发送实现是等待上位机来读寄存器所以它的send_payload实际是“把最新数据写入保持寄存器等待查询”MQTT的发送是publish带QoSHTTP的发送是POST请求。这里有个在设计上很关键的细节抽象层不直接返回成功或失败而是返回一个SendResult对象里面带上本次发送数据块的起始序号和结束序号。这样上层断点续传逻辑才能精确知道哪些帧已经确认发出哪些帧需要保留。dataclass class SendResult: success: bool acked_seq: int # 对方确认收到的最大帧序号 last_attempt_time: float # 本次发送尝试时间统一数据模型方面所有采集数据在进入发送层之前都会被封装成一个标准帧dataclass class DataFrame: seq: int # 自增帧序号断点续传的关键 ts: int # 采集时间戳毫秒 device_id: str # 设备编号 temp: float # 温度值 hum: float # 湿度值 crc: int # 校验值帧序号是整个系统的命脉。温湿度数据本身格式简单但如果没有全局递增的帧序号断点续传根本无从谈起——你不知道断在哪里也不知道补哪些。seq从采集器上电开始递增存到掉电不丢失的Flash区域重启后继续接上。3. 断线重连机制的设计与实现3.1 为什么重连要做状态机而不是写死循环断线重连听起来简单断了就重连连不上就再试嘛。但如果真的写一个while循环里面不停connect现场会出现三种经典事故第一种风暴式重连。网络抖动时网卡可能处于半通状态connect超时30秒然后程序立刻重试再次超时CPU和日志被刷爆把本就脆弱的链路彻底打垮。第二种无节奏重连。每次重连间隔固定10秒如果服务端恰好也在重启客户端一恢复就撞上服务端未就绪反复失败错过服务端恢复后的黄金时间窗。第三种重连成功但业务不可用。TCP握手成功了但服务端还在加载配置或者认证还没完成客户端就急着发数据结果被服务端拒收然后客户端误判“链路正常”把数据帧从缓存放出去实际全丢了——这是最坑的。所以我把重连逻辑全部收拢到一个状态机里用状态来控制行为而不是用循环来控制行为。状态机的核心状态就四个IDLE空闲等待、CONNECTING正在建连、CONNECTED已连接、BACKOFF退避中。每个状态的停留时长和动作由重连策略参数决定完全可控。这里贴一下简化后的状态机核心代码class ReconnectState(Enum): IDLE 0 CONNECTING 1 CONNECTED 2 BACKOFF 3 class ReconnectManager: def __init__(self, base_time1.0, max_wait60.0, jitter_range0.2): self.state ReconnectState.IDLE self.retry_count 0 self.base_time base_time self.max_wait max_wait self.jitter_range jitter_range self.last_success_time 0 def on_connect_failed(self): if self.state ReconnectState.CONNECTING: self.state ReconnectState.BACKOFF self.retry_count 1 def get_next_retry_delay(self) - float: exp_backoff min(self.max_wait, self.base_time * (2 ** max(0, self.retry_count - 1))) jitter random.uniform(0, self.jitter_range) return exp_backoff jitter def on_connect_success(self): self.state ReconnectState.CONNECTED self.retry_count 0 self.last_success_time time.time()3.2 指数退避与抖动参数怎么定指数退避是重连机制的标配思路意思很简单连续失败时等待时间按指数增长避免风暴。公式一般是wait_time base_time * (2 ** retry_count) jitterbase_time取1秒重试次数从0开始那么第一次失败后等1秒第二次2秒第三次4秒最多封顶到60秒。封顶必须要有不然断网一天后第一次重试要等几个小时现场恢复了你还在傻等。Jitter抖动是很多人容易忽略的。如果不加随机抖动同一个网络里的几十台设备全按同样的节奏重连恢复供电的瞬间会形成“羊群效应”所有设备同时发起连接交换机和服务端瞬间被打满。加一个0到200毫秒的随机偏移能让重连请求在时间轴上自然散开。这个细节在超过50台设备的大仓项目里效果非常明显。还有一点要提醒重连的“重试次数”要区分连续失败和间歇失败。如果某次重连成功运行了10分钟又断开那重试计数应该清零因为上次断线已经过去很久链路质量可能已经变了。我在代码里用一个last_success_time字段每次连接成功后更新重试计数只统计“当前连续失败段”的次数。再附一个现场调好的参数表可以直接抄参数取值说明base_time1秒首次重连等待max_wait60秒退避封顶jitter_range0-200ms随机抖动connect_timeout5秒建连超时heartbeat_interval30秒应用层心跳heartbeat_timeout90秒无心跳判定断线3.3 多协议下的断线判定差异断线重连的第一步是“断线判定”这个在不同协议下差别很大也是联调时最容易出bug的地方。Modbus TCP这边断线判定要看TCP连接状态和请求应答。客户端如果长时间不发请求底层连接可能断了但双方都不知道我用的是周期性“静默探测”每30秒主动读一次寄存器如果连续3次超时就判定链路不可用。这里的关键是超时时间不能设太长Modbus TCP的超时重试机制本身就有讲究5秒读不到应答就可以认为链路有状况。MQTT这边断线判定靠心跳包。MQTT协议本身规定了Keep Alive机制客户端每个心跳周期发一个PINGREQBroker回PINGRESP。如果超过1.5个心跳周期没等到PINGRESP客户端就主动断开TCP并进入重连流程。这个语义是协议自带的不用自己造轮子但要注意Clean Session参数——如果设置为False如果Broker侧的会话还挂着旧连接重连时可能被服务端判定为“重复会话”产生一系列怪问题。HTTP这边最麻烦因为HTTP本身没有长连接的概念。我采用的方式是“上报失败即断线”每次POST超时或返回错误码就认为链路可疑连续N次失败则切换为离线缓存模式。这里有个经验值连续3次失败才切换单次失败可能在代理节点上只是偶然抖动。断线判定统一之后一旦判定断线上层立刻触发两件事一是停止实时发送二是启动本地缓存。这两件事的衔接要在同一毫秒级完成否则断线瞬间的那几帧数据会悬空。4. 断点续传机制的实现方案4.1 本地缓存设计环形队列加落盘断点续传的本质是链路断开期间的数据不能丢链路恢复后要按序补上。这两件事的核心是本地缓存系统。我的方案分两层内存热队列和SQLite落盘库。内存热队列用环形缓冲结构容量固定1000条。正常情况下采集到的数据帧先入队发送线程从队头取帧发送成功后删除。环形缓冲的优势是取出和插入都是O(1)操作没有任何动态内存分配在内存受限的嵌入式环境里特别友好。当检测到断线时队列停止清除新来的数据继续往里填填满之后才触发落盘。落盘是断线时间较长时的保底方案。为什么不用纯文件因为SQLite自带事务和索引天然适合按序号查询做“从第N帧开始补传”这种操作时太方便了。表结构设计得极简CREATE TABLE pending_frames ( seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, device_id TEXT NOT NULL, temp REAL NOT NULL, hum REAL NOT NULL, crc INTEGER NOT NULL, retry_count INTEGER DEFAULT 0 );seq当主键天然防重复插入。断线超过环形队列容量时新数据直接写SQLite网络恢复时先补发SQLite里的积压帧再发内存队列里的热数据。这里要强调一个设计原则发送顺序必须严格按seq递增不允许乱序补发。因为上位机侧要按时间序列做趋势分析、做报表乱序数据虽然可以靠时间戳重排但在一些按序遍历的PLC程序里会直接翻车。4.2 续传判重与幂等性设计断点续传最大的坑是什么不是传不上去而是传重了。场景是这样的发送线程把seq100到200的数据帧发出去了TCP层也确认送达了但服务端在处理时宕机还没来得及落库。客户端这边没收到应用层确认链路又断了重连后从seq100开始补发——服务端一恢复seq100到200的数据被又插了一遍。所以续传必须解决幂等性。我的做法是双保险客户端记录“服务端已确认的最大帧序号”服务端按seq做去重。客户端侧每条发送记录都要求应用层确认。Modbus TCP是上位机读走即确认MQTT是收到PUBACK才算确认HTTP是收到2xx响应才算确认。确认后的帧序号持久化到Flash作为续传起点。下次补发时直接从last_acked_seq 1开始不重不漏。服务端侧所有接收帧按seq入库时都用INSERT OR IGNOREseq重复的帧直接忽略。这样即使客户端因为异常重复发送服务端也不会产生脏数据。这样的幂等设计在分布式系统里是常识但在嵌入式温湿度采集里经常被忽略等到数据对不上账的时候才后悔。4.3 续传启动时机与传输节奏控制链路恢复后系统进入“补发模式”。这里有一个容易被忽略的问题补发不能无脑快发。假设断网8小时积压了几万帧数据链路一恢复就往死里发轻则把服务端数据库写爆重则触发服务端的防火墙限流。我的补发节奏控制是按积压量动态调整发送速率。积压少于1000帧时按正常速率发送不额外控制积压超过10000帧时每帧之间插入10毫秒延迟并按每秒500帧的速率封顶。这样在“尽快追平”和“别把链路打死”之间找到一个平衡点。另外补发期间要允许新采集的数据插队吗我的答案是不插队但给新数据单独的优先级通道。实现上补发线程和实时发送线程是分离的实时数据走独立连接或者同一连接的不同消息通道这样现场的实时监控数据不会被积压补发拖累。如果协议不支持多路复用比如HTTP那就把新数据也追加到发送队列尾部但补发模式的发送速率要保证“追平速度大于产生速度”否则积压会越积越多——这个数学关系是补发速率必须大于数据生产速率否则永远追不上。下面给一个简化版的续传调度逻辑class RetransmitScheduler: def __init__(self, db_conn, protocol_adapter, max_rate500): self.db_conn db_conn self.adapter protocol_adapter self.max_rate max_rate self.last_acked_seq self._load_last_acked() def run_retransmit_loop(self): while True: backlog self._get_backlog_count() if backlog 0: break if backlog 10000: rate self.max_rate elif backlog 1000: rate self.max_rate / 2 else: rate self.max_rate / 5 frames self._fetch_frames(self.last_acked_seq 1, min(100, backlog)) for frame in frames: result self.adapter.send_payload(frame) if result.success: self.last_acked_seq max(self.last_acked_seq, result.acked_seq) self._persist_last_acked(self.last_acked_seq) self._delete_acknowledged_frames(self.last_acked_seq) else: break time.sleep((frames_count / rate))5. 实测中的坑与排查技巧5.1 典型故障与排查对策速查表这个项目从开发到现场联调前后踩了不少坑。我把典型问题整理成一个速查表给后来者省点时间。故障现象根因排查手段解决方案断线后重连一直失败但网络已恢复重连计数未清零查看日志中退避时间是否持续增大在连接成功后立即清零重试计数MQTT重连后收不到新数据Clean Session设置不当抓包看SUBSCRIBE是否重发重连后显式重订阅主题HTTP补传数据大量重复服务端未做幂等去重核对服务端接收日志的时间戳服务端按seq做INSERT OR IGNORE断网恢复后CPU占用飙升补发速率无上限观察线程CPU和网络吞吐增加积压量分档限速环形队列数据被覆盖队列容量小于断线积压量检查日志中丢弃计数队列溢出前及时落盘SQLite设备重启后续传位置丢失确认序号未落盘检查Flash写日志每确认一批帧就持久化一次批量序号多设备同时恢复导致服务端过载重连抖动缺失查看服务端连接时间戳全部集中重连等待加入随机jitterModbus TCP偶发读不到数据请求超时设置过短抓包观察超时重发超时重试2次每次5秒5.2 现场联调后的几点经验最后说几点只有到现场才会明白的经验。第一断线重连和断点续传的联调一定要用“网络损伤”工具模拟不要等现场故障。我这里用的是给交换机加延迟、丢包率来模拟弱网环境。否则你以为断线重连很好使实际上只是你办公室的网络太稳定了测不出问题。到现场客户机房一接电磁干扰、网线质量、交换机STP震荡各种幺蛾子全来了。第二日志系统一定要设计好。工业设备不像开发机上能随便debug日志就是唯一的眼睛。我在采集器里做了一套分级日志链路事件连接、断开、重连、补发、清除全部落盘每一条都带上时间戳和帧序号范围。问题定位全靠它。现场工程师不需要懂代码只要会把日志导出来发给我我就能定位十有八九的问题。第三补发数据的时间戳要保持原始采集时间不能发送时重新打时间戳。这一点特别重要。如果断网8小时后补发发送时间已经变了重打时间戳会导致数据的时序关系错乱上位机画出的温湿度曲线会产生异常波动。原始时间戳是数据价值的核心发送时间只是传输记录两者不能混。第四所有参数必须支持远程配置。现场去过的人都知道设备安装在仓库顶、机柜里爬高上低调参数太痛苦。重连超时、心跳间隔、缓存容量这些参数我全部做进配置下发通道平台侧能远程修改。参数默认值是出厂预设现场微调才够灵活。第五要为最坏情况兜底。温湿度采集不像金融系统数据价值不高但不能因为这个就放弃兜底设计。Flash写坏、文件系统损坏、SQLite损坏这些极端情况虽然概率低但一旦发生整套系统就是哑巴。我把关键配置和确认序号的持久化做成了双备份主区域校验失败自动切换到备份区最大限度避免设备变砖。我个人在实际操作中的体会是这套“协议抽象层重连状态机帧序号续传”的组合拳真正跑通之后比想象中稳。最难的不是某个单独环节的实现而是把三个机制咬合在一起重连状态机什么时候触发缓存切换缓存里的积压量如何反馈给补发限速补发进度又如何更新重连状态。这三者之间是互相联动的设计时一定要理清时序关系建议先画清楚状态转移图再动手写代码。最后再分享一个小技巧设计断线重连和断点续传时强烈建议在开发阶段就引入故障注入测试把断网、重启、服务端宕机、电源抖动这些故障场景写成自动测试用例。你会发现很多在正常环境下永远不会暴露的问题在故障注入下原形毕露。做工业通讯稳定不是测出来的而是设计出来的——这句话是我在这个项目里最深的体会。