断网不丢数据:工控网关中的Outbox模式实战
"断网不丢数据:工控网关里的 Outbox 模式"
干工控这一行,最怕听到的一句话就是"刚才网络卡了一下,数据少了十分钟"。MES那边要产量报表、能源那边要电表曲线,结果网关一断网,再重连的时候历史数据对不上,两边扯皮,最后锅全扣在现场网关头上。这个问题我踩过太多次了,所以今天想认真聊聊一个解决思路:Outbox 模式。
这词最早出自微服务架构,用来解决分布式系统里"写数据库"和"发消息"的一致性问题。但你把它搬进工控网关里,简直像是为工业现场量身定做的——因为现场网络就是不稳定,但数据必须不丢。本文将围绕工控网关断网场景,拆解 Outbox 模式的核心思路、表结构设计、发送补偿机制和踩坑记录,适合正在做边缘采集、设备上云、SCADA 对接的朋友参考,也适合被数据缺失问题折磨的运维同学拿来当排查手册。
1. 工控网关的数据上云,本质上是个可靠性问题
1.1 断网不是异常,是常态
先认清一个现实:工业现场的通讯链路,真没有大家想象得那么稳。车间里大电机一启动,电压瞬降可能导致工业路由器和 PLC 之间的连接闪断;跨厂区的光纤被铲车挖断;偏远泵站走 4G,信号经常漂。更常见的是,上位机或云平台半夜升级,主动断开连接几分钟。
网关在这种环境里干活,它的核心任务其实就一句话:把设备层的数据完整、有序地搬到平台层。"完整"和"有序"这两个词,在断网叠加高并发采集的场景下,变得非常难。
因为网关不止接一台设备。我做过一个项目,一台边缘网关接了 32 台 PLC,每台 PLC 里又有几十个寄存器变量,采集周期 1 秒,算下来每秒要处理上千个数据点。这些数据点如果只是发到内存缓冲区后就不管了,一旦网断开,缓冲区被写满,新数据挤掉旧数据,丢数据几乎是必然的。
1.2 为什么简单的"缓存+重发"解决不了问题
很多人第一反应是:断网时把数据先存下来,网络恢复再补发,这不就行了?没错,思路是对的。但关键是存在哪里、怎么存、怎么补。
如果你是存进程内存里,网关一重启或者程序一崩溃,数据全没了。如果直接写本地文件,每个采集周期写一个文件,文件多了之后清理逻辑、命名规则、读写锁都是麻烦事,而且你很难搞清楚哪些文件发了、哪些没发,哪些发失败了需要重试。如果直接往 SQLite 里裸写、另开一个线程扫表发送,写库和发送之间没有事务保证,可能会出现"数据入库了但发送线程没看到,程序却崩溃了"的情况,那条数据最终还是丢。
我在一个早期项目里就吃过这种亏。当时采集程序把数据写 SQLite,发送程序每隔 5 秒查一次表,发送后删记录。后来网关断电重启,发现丢了 2 分钟数据。查了半天原因:采集线程刚写完数据还没提交事务,断电重启,数据回滚了。这就是典型的"写入与发送未解耦"导致的一致性问题。
1.3 Outbox 模式正是为此而生
Outbox 模式的核心思想其实特别朴素:把"业务动作"和"发送动作"放在同一个本地事务里,业务动作成功,则发送记录必须成功落库;再由一个独立的发布组件异步扫描这张表,把记录发出去,发成功的才删除或标记。
放在工控网关的场景里,"业务动作"就是"采集到一条设备数据","发送动作"就是"上云/上报"。传统写法是采集到数据后直接调用 MQTT 客户端 publish,而 Outbox 的写法是先把数据写进一张本地的 outbox 表,提交事务后发送程序再去读表。这样采集程序永远不需要关心网络通不通,它只关心"我有没有把数据安全落盘",网络的问题全部由发送器异步去处理。
这个模式最妙的点是:它把"时间"和"空间"都解耦了。时间上,采集和发送不再绑定在同一个时刻;空间上,数据先落在本地存储,不再依赖远端链路。
2. 网关本地 Outbox 的核心设计
2.1 三张核心数据表的分工
我在实际项目中,把整个机制拆成了三个角色:数据采集表、Outbox 表、元数据表。简洁说明一下,以 SQLite 为例:
-
数据采集表(telemetry):存储最终要上云的时序数据,比如
device_id, ts, payload。这张表是留给业务查询用的,也可以直接不留,但建议留着,方便排查数据内容与发送记录对账。 -
Outbox 表(outbox_events):存储待发送的事件记录,字段上我会重点设计几个:
id、topic、payload、status、retry_count、next_retry_time、created_at。 -
发送游标表(send_cursor):记录每个主题的发送进度,用于乱序恢复和幂等判断。
关键点在 Outbox 表。status 字段我习惯用 pending(待发送)、sending(发送中)、sent(已确认)、dead(终态死信)。不要把发送中的状态省掉,没有它,你在重试时无法区分"没发过"和"发了但网络断在中间"。
2.2 为什么必须强调"事务性写入"
这里要划重点。Outbox 模式最大的优势不是"持久化",而是事务性。什么叫事务性?就是采集数据入库和写 outbox 记录这两件事必须同时成功、同时失败,不允许只成功其中一个。
实现上很简单,把两个 INSERT 包在同一个事务里提交:
如果不用事务,你先写 telemetry 再写 outbox,第二步失败了,就会出现"业务数据有,发送记录无"的情况,这条数据永远不会被发送,这就是静默丢失。反过来先写 outbox 再写 telemetry,数据有记录但内容缺失,发送器会把残缺数据发出去,对端解析大概率报错。
所以我后来在代码评审里立了一条规矩:所有需要上云的数据,必须在一个事务里同时写入数据表和 outbox 表,谁拆开写谁负责。
2.3 发送器:一个专注扫表的独立模块
有了 outbox 表之后,真正的发送逻辑就变成了三个动作:扫描、发送、确认。发送器独立于采集线程运行,它只需要定期执行下面这个循环:
- 查询
status='pending'且next_retry_time <= 当前时间的记录,限制条数比如一次取 100 条。 - 把这些记录的状态改成
sending,并记录last_try_time。 - 按
created_at顺序逐条发送(或者按主题聚合批量发送)。 - 发送成功后,将状态改为
sent,随后按保留策略清理。 - 发送失败(网络异常、超时),保留
pending状态,把next_retry_time往后推到退避时间点。
这个模块一定要独立,不能塞进采集线程里。我见过一个项目把发送逻辑写在采集回调里,断网时发送函数阻塞了 10 秒,整个采集循环卡死,PLC 数据堆积在驱动缓冲区里,最后连通讯都超时了。采集和发送必须各干各的活。
3. 实操落地:从建表到补传细节
3.1 Outbox 表结构设计示例
直接给一份我常用的建表语句,大家可以根据自己的数据库选型调整(SQLite 版)。
索引很关键,没有 (status, next_retry_time) 的组合索引,数据量过万之后扫表会变慢。我实测过,5 万条 pending 数据,无索引扫描要 300ms 左右,加了索引之后降到 10ms 以内。工控网关 CPU 本来就弱,这种不必要的开销能省则省。
字段 retry_count 不要省,它是死信判断的依据。比如连续重试 10 次都失败,再继续重试已经没意义了,还会拖慢整个队列,这时候应该把状态改成 dead,由运维介入处理。这个阈值没有标准答案,我一般设定为 10 次,配合指数退避,大约是 5-10 分钟。
3.2 重试与退避:用指数退避防雪崩
断网恢复后最容易出现的状况是:积压了大量数据,发送器疯狂重发,结果网络刚恢复,又被冲垮。所以重试必须讲策略。
我用的是经典的指数退避:下次重试时间 = 上次尝试时间 + 基础间隔 × 2^retry_count。基础间隔设 5 秒,重试一次后等待 10 秒,第二次后 20 秒,第四次后 80 秒,封顶 300 秒。伪代码如下:
在更新 next_retry_time 时注意一个细节:不要直接在 UPDATE 语句里写死 retry_count + 1,而要基于当前值递增。因为发送器可能是多线程的,如果两个线程同时拿到同一条 pending 记录,各自更新,就可能造成重复发送。最简单的防御是用一个发送线程,或者加条件更新:
这条语句只有一条记录真正被执行,另一个线程更新 0 行,自然跳过。
3.3 数据过期策略:不是所有断网数据都要补
这一点是整个模式里最容易做过头的地方。很多工程师脑子里只有"完整不丢",不管断网多久,一律补发。但工业数据是有时效性的。温度传感器断网 2 小时,恢复后补发 7200 条历史数据,对实时监控界面毫无意义,反而会挤占实时数据的带宽,可能把实时链路再次拖垮。
所以我在 Outbox 发送器里加了一个判断:超过保留窗口(比如 15 分钟)的数据,不再补发,直接标记为 expired 并清除。这个窗口值需要按业务场景调整,我一般在配置文件里区分变量类型:连续型模拟量(温度、压力)补发窗口 10 分钟,事件型报警(开关量变化、故障码)补发窗口 24 小时。因为报警事件如果丢了,可能漏掉一次停机原因分析,必须尽量补。
实现上很直接,扫表时把条件带上:
然后每天夜里执行一次清理任务,把 created_at < cutoff 的 pending 记录直接删除或转存历史日志。
3.4 结合 MQTT 的发送实现示例
工控网关最常用的上报协议就是 MQTT,QoS 级别选 1(至少一次)比较匹配 Outbox 模式。QoS 0 可能丢,QoS 2 流程太重,网关场景大部分选 1。发送器代码大概长这样:
注意 wait_for_publish(timeout=5)。如果 broker 在断网时不可达,publish 的 wait 不设超时,会把发送线程卡很久。设 5 秒超时,失败后立刻进重试流程。
还有一个细节:mark_sending 要在发送真正开始前调用,而且要在事务里和 retry_count + 1 一起完成。这样即使进程在发送过程中崩溃,重启后记录状态是 sending,但重试计数已经增加,不会无限制重发。
4. 常见问题与排查技巧实录
4.1 数据重复:Outbox 模式解决不了幂等,得靠对端
先说清楚一个容易误解的点:Outbox 模式能保证"不丢",但它本身并不能保证"不重"。MQTT QoS 1 的语义就是"至少一次",broker 或客户端故障时可能重复投递。所以必须在平台端做幂等处理。
我通常在 payload 里带一个全局唯一的 event_id,就是 outbox 表里的 id 字段(或生成的 UUID)。平台端收到数据后按 event_id 去重,相同 ID 直接丢弃。之前有一个项目忽略了这个点,断网恢复后平台统计的产量多了 3%,后来查出来就是重复投递造成的。工业系统对数据准确性极敏感,event_id 一定要有。
4.2 长时间断网,本地存储满了怎么办
这是工控网关绕不开的物理限制。网关存储通常很小,工业级 SD 卡 32GB 就算大的。如果断网 3 天,1 秒采集 100 个点位,3 天数据量粗略算一下可能达到 2GB-10GB,很可能把存储塞满。
我目前的应对策略是双管齐下:
- 分级降采样:断网超过一定时长后,对新采集的数据进行降采样存储。比如断网前 5 分钟原始数据 1s 存一条,断网 10 分钟后改成 5s 一条,断网 1 小时后只存分钟级平均。这样积压数据量能缩小一个数量级。
- 环形覆盖:Outbox 表设定最大行数(我常用 50 万条),达到上限后自动删除最旧的 pending 记录,并把删除事件记入本地日志文件。这是最后的手段,保证网关不会因为存储满而整体宕机。日志文件单独存,不放进 outbox 表。
注意这个策略和业务侧一定要提前对齐。有的客户说"一条都不能丢",那你就要把存储扩容、把降采样逻辑做得更保守;如果客户能接受"超过 2 小时的历史数据不要了",那环形覆盖就够用。
4.3 SQLite 写入锁导致的采集阻塞
网关本身资源有限,很多项目直接在本地用 SQLite。但 SQLite 在并发写多的时候会返回"database is locked",如果采集线程在写入时遇到锁重试,等待时间过长,可能影响实时采集。
解决办法有几个,按推荐程度排:
- 所有写操作走单一线程。采集线程把数据通过内存队列交给专门的写库线程,避免多个线程并发写 SQLite。
- 使用 WAL 模式。
PRAGMA journal_mode=WAL;读写并发能力好很多,读不阻塞写,写不阻塞读。 - 设置合理的 busy_timeout。
PRAGMA busy_timeout=5000;写冲突时等待最多 5 秒而不是立即报错。
我实测下来,单写线程 + WAL 模式 + busy_timeout 5000,网关 1 秒采集 500 个点完全没有压力。如果采集规模更大,上时序数据库(如 InfluxDB 或 TDengine)是更合适的选择,但 Outbox 的设计逻辑不变。
还有一个小坑分享给大家:SQLite 在做大批量删除 sent 记录时,建议分批删。一条 DELETE FROM outbox_events WHERE status='sent' AND created_at < ... 删几十万行,不仅时间长,还会撑大数据库文件。用 DELETE ... LIMIT 1000 循环删,每次删除之后顺便 PRAGMA wal_checkpoint;,数据库文件体积能控制得比较好。
4.4 数据乱序:跨主题和单主题发送的排序策略
有时候平台侧要求同一台设备的数据严格按时间顺序到达。MQTT 本身不保证顺序,但单线程发送器按 created_at 升序发送,能保证单设备数据的发送顺序。前提是发送器只有单线程。
但是多设备并行发送怎么处理?我的建议是维持一个发送线程,按 topic 分组,组内排序,逐组发送。比如 PLC_01 的所有数据先发完,再发 PLC_02 的。这样单个设备不会乱序,但设备之间可能会有先后,对大多数工控场景完全够用。
如果多个 PLC 的数据需要合并后在平台端做时间对齐,那建议在 payload 里带上采集时间戳,平台端按时间戳排序,不要依赖到达时间。到达时间受网络波动影响,可能出现后采集的先到,先采集的后到,所以平台端排序必须以设备时间为准。
4.5 现场排障手册:快速定位"数据丢了"的问题
最后整理一个我实际排查问题时的步骤清单,遇到"数据少了"的投诉,按照这个顺序查:
先查 outbox 表概况。SELECT status, COUNT(*) FROM outbox_events GROUP BY status; 如果 pending 非常多,说明网络断了或者发送器卡了;如果 dead 很多,说明有数据一直发不出去;如果表基本为空但平台说缺数,那问题可能出在源头采集或平台端解析。
再查发送日志。重点看有没有超时报错、broker 连接断开重连的日志。很多问题是 broker 连接假死导致的,TCP 连接还在,但实际收不到数据。这时候要检查设备的 keepalive 设置,MQTT 的心跳包间隔不能太长,我常用 30 秒,配合 broker 端 60 秒断连判定。
最后查对端。平台收到了数据但解析失败、入错表,这种情况 outbox 里已经标记 sent,只能从平台侧日志排查。所以建议大家一定要在 outbox 表里保留最近 N 条已发送记录的样例,别急着全删,方便对账。我现在保留 7 天的 sent 记录,定期清理。
写在最后的个人体会
Outbox 模式乍看像是个数据库设计技巧,但在工控网关上,它解决的其实是"现场网络不靠谱"这个根深蒂固的问题。它用一次本地事务,把可靠的边界从云端拉回到了网关本身——只要数据落进了 outbox 表,它迟早会到达平台,除非存储坏了或者硬件断电在事务提交前的极小窗口内。
实践过程中,我最深的体会是:技术方案并不复杂,复杂的是你在面对"断网 3 小时要不要补数据""存储满了先保采集还是先保补发"这些真实业务选择时,能否把机制设计得足够灵活。把重试次数、补发窗口、降采样策略这些参数全部配置化,不要写死在代码里——因为你永远不知道现场下一秒会出什么幺蛾子。
如果让我给刚接触这个模式的人一个建议,我会说:先把发送器的日志做完善。每条记录从 pending 到 sent 的每次尝试,都要有时间戳和结果。数据丢了不可怕,可怕的是你连它是在哪一步丢的都不知道。有了完整的 Outbox 生命周期日志,至少一半的扯皮问题都能靠查日志解决。