采集网关的离线缓存与断点续传——当网络不可靠时,数据一条都不能丢

1. 从"演示完美"到"上线丢数据"的 30 分钟

前面 6 篇文章都假设了一个前提:网络是可靠的。但在真实工业现场,这个假设不成立。

2023 年夏天,我一个做光伏电站的朋友给我打了一个电话。他们部署了 200 台智能逆变器采集系统,每台通过 4G DTU 上报数据到云端。实验室测试一切完美------每 10 秒一条数据,延迟稳定在 50ms 以内。上线第一周也正常。

真正的问题发生在一次雷雨天后:4G 基站断电 6 小时,网络恢复后发现损失了约 30% 的数据

排查后发现:MQTT 客户端虽然有自动重连,但离线期间的数据全部被丢弃了------因为程序只在内存中缓存了最近 100 条数据。6 小时的断网产生了约 2000 条数据,内存缓冲区溢出后,旧数据被覆盖。雪上加霜的是,网关的系统时钟在离线期间漂移了 4 秒,导致恢复后补传的数据出现时间戳紊乱,时序数据库拒收了部分数据。

这个案例揭示了一个工程事实:不处理离线缓存的采集网关,最多算一个玩具

2. 离线缓存架构全景

解决上述问题需要三层缓存协作:

云平台 Cloud

边缘网关 Edge Gateway

数据源 Data Source

缓存层级 Cache Layers

采集

  1. 实时写入

  2. 异步落盘

  3. 实时投递

网络正常

网络中断

  1. 断线后补传

Sparkplug 订阅端

时序数据库

采集主线程

MQTT 发布线程

MQTT Broker

内存环形缓冲区Ring Bufferµs 级写入

SQLite 持久存储WAL 模式断电安全

PLC / Modbus RTU

三层缓存各司其职:

层级 存储介质 写入延迟 容量 断电保护 用途
L1 环形缓冲区(内存) ~0.1 µs 数万条 突发流量消峰
L2 SQLite(磁盘) ~100 µs 数百万条 完整(WAL) 持久化缓存、断网补传
L3 MQTT Broker ~10 ms 有限(受配额限制) 传输过程中的暂存

数据流路径:

  • 正常时:PLC → 环形缓冲区 → MQTT → 云端(SQLite 异步写入做备份)
  • 断网时:PLC → 环形缓冲区 → SQLite(MQTT 发布失败时自动走 L2)
  • 恢复时:SQLite 补传 → MQTT → 云端 → 确认后从 SQLite 删除已确认数据

3. 环形缓冲区------第一道防线

环形缓冲区是离线缓存的"快路径"。它解决的问题是:采集线程不能因为 MQTT 发布慢或者磁盘写入慢而被阻塞。

3.1 基本原理

复制代码

python

体验AI代码助手

代码解读

复制代码

""" ring_buffer.py --- 线程安全的内存环形缓冲区 """ import threading from typing import Optional, List from dataclasses import dataclass import time @dataclass class DataRecord: """一条采集数据记录""" seq: int # 递增序列号 ts: float # 采集时间戳(Unix 毫秒) device_id: str # 设备 ID payload: bytes # 数据载荷(已序列化) published: bool # 是否已发布 class RingBuffer: """ 线程安全的环形缓冲区 特性: - 固定容量,预先分配内存(无动态分配) - 读写指针分离,支持并发读写 - 满时策略:覆盖最旧数据(用于实时监控场景) """ def __init__(self, capacity: int = 65536): self.capacity = capacity self.buffer: List[Optional[DataRecord]] = [None] * capacity self.write_pos = 0 # 写指针 self.read_pos = 0 # 读指针(用于未发布数据的回溯) self._lock = threading.Lock() def write(self, record: DataRecord) -> bool: """ 写入一条记录。 返回 True 表示写入成功,False 表示覆盖了未读数据。 """ with self._lock: self.buffer[self.write_pos] = record self.write_pos = (self.write_pos + 1) % self.capacity # 如果写指针追上了读指针,读指针向前移(数据被覆盖) overwritten = False if self.write_pos == self.read_pos: self.read_pos = (self.read_pos + 1) % self.capacity overwritten = True return not overwritten def read_unpublished(self, max_count: int = 100) -> List[DataRecord]: """ 读取所有未发布的数据 用于 MQTT 断线重连后补传 """ result = [] with self._lock: pos = self.read_pos while pos != self.write_pos and len(result) < max_count: record = self.buffer[pos] if record and not record.published: result.append(record) pos = (pos + 1) % self.capacity return result def mark_published(self, seq: int): """标记指定 SEQ 的数据为已发布""" with self._lock: pos = self.read_pos while pos != self.write_pos: record = self.buffer[pos] if record and record.seq == seq: record.published = True break pos = (pos + 1) % self.capacity @property def usage(self) -> float: """当前缓冲区使用率""" with self._lock: used = (self.write_pos - self.read_pos) % self.capacity return used / self.capacity

设计决策说明:

为什么不用 list.append()?因为预分配固定容量的数组可以避免 GC 压力和动态扩容带来的延迟抖动。在采集间隔 100ms 的场景下,每次采集触发的内存分配/释放累积起来会显著影响采集周期的稳定性。环形缓冲区预先分配好所有槽位,写入只是指针移动------延迟是确定的。

3.2 覆盖策略选择

策略 实现方式 适用场景
覆盖最旧(Overwrite) 写指针追上读指针时自动覆盖 实时监控,数据高频更新
丢弃最新(Discard) 缓冲区满时拒绝新写入 报警信号等关键数据
阻塞等待(Block) 缓冲区满时写线程等待 慢消费 + 不能丢数据的场景

经验建议:工业网关中通常组合使用------实时数据(温度、压力)用覆盖策略,报警事件用独立的小容量阻塞缓冲区。这样保证实时数据永远有最新的值可用,同时报警信号不会丢失。

4. SQLite 持久存储------把数据放进保险箱

环形缓冲区只能应对秒级的网络抖动。对于分钟级甚至小时级的断网,数据必须持久化到磁盘。

4.1 WAL 模式------写入性能提升 10 倍的关键

SQLite 有两种事务日志模式:

模式 写入原理 读性能 写性能 断电安全 数据库大小
回滚日志(DELETE) 每次事务写日志 → 修改主数据库 → 删除日志 快(直接读) 慢(每写一次 fsync) 安全(可回滚)
WAL 先追加写 WAL → 异步合并到主库 快(读 WAL + 主库) 快 5-10 倍(顺序追加) 安全(WAL 可恢复) 略大(有 WAL 文件)
复制代码

python

体验AI代码助手

代码解读

复制代码

""" sqlite_cache.py --- SQLite 离线缓存 """ import sqlite3 import threading import json import time import os from typing import Optional, List, Tuple class SQLiteCache: """ SQLite 持久化缓存 使用 WAL 模式解决并发写入性能问题。 每条记录包含 SEQ、时间戳和设备数据。 """ def __init__(self, db_path: str, max_retention: int = 100000): """ Args: db_path: 数据库文件路径(建议放在非系统盘或 SD 卡) max_retention: 最大保留记录数(防止磁盘写爆) """ self.db_path = db_path self.max_retention = max_retention self._lock = threading.Lock() self._init_db() def _init_db(self): """初始化数据库和 WAL 模式""" self.conn = sqlite3.connect(self.db_path, check_same_thread=False) # === WAL 模式的关键配置 === self.conn.execute("PRAGMA journal_mode=WAL;") # WAL 日志 self.conn.execute("PRAGMA synchronous=NORMAL;") # 平衡性能与安全 self.conn.execute("PRAGMA busy_timeout=5000;") # 等待 5s 而不是报错 self.conn.execute("PRAGMA cache_size=-16000;") # 16MB 页面缓存 # WAL 模式下,checkpoint 自动触发 self.conn.execute(""" CREATE TABLE IF NOT EXISTS data_cache ( seq INTEGER PRIMARY KEY, -- 全局序列号 ts_ms INTEGER NOT NULL, -- 时间戳(毫秒) device_id TEXT NOT NULL, -- 设备 ID payload BLOB NOT NULL, -- 数据载荷 created_at REAL DEFAULT (julianday('now')), published INTEGER DEFAULT 0 -- 0=未发布, 1=已发布 ); """) # 建立索引:按发布状态 + 时间戳查询 self.conn.execute(""" CREATE INDEX IF NOT EXISTS idx_published_ts ON data_cache(published, ts_ms); """) self.conn.commit() def store(self, seq: int, ts_ms: int, device_id: str, payload: bytes) -> bool: """ 存储一条数据到缓存 注意:这里使用 INSERT OR REPLACE 而不是普通 INSERT, 因为 SEQ 作为主键,断线重连后可能出现 SEQ 冲突。 """ with self._lock: try: self.conn.execute( """INSERT OR REPLACE INTO data_cache (seq, ts_ms, device_id, payload, published) VALUES (?, ?, ?, ?, 0)""", (seq, ts_ms, device_id, payload) ) # 控制总记录数(删除最旧的超出部分) self._trim_if_needed() self.conn.commit() return True except sqlite3.Error as e: return False def store_batch(self, records: List[Tuple]) -> int: """ 批量存储(比逐条 insert 快 10-50 倍) records: [(seq, ts_ms, device_id, payload), ...] """ with self._lock: try: self.conn.executemany( """INSERT OR REPLACE INTO data_cache (seq, ts_ms, device_id, payload, published) VALUES (?, ?, ?, ?, 0)""", records ) self._trim_if_needed() self.conn.commit() return len(records) except sqlite3.Error: return 0 def _trim_if_needed(self): """控制缓存大小""" count = self.conn.execute( "SELECT COUNT(*) FROM data_cache" ).fetchone()[0] if count > self.max_retention: # 删除最旧的记录,保留 max_retention 条 self.conn.execute(f""" DELETE FROM data_cache WHERE seq IN ( SELECT seq FROM data_cache ORDER BY seq ASC LIMIT {count - self.max_retention} ) """) def get_unpublished(self, limit: int = 1000, before_seq: Optional[int] = None) -> List[Tuple]: """ 获取未发布的数据(用于补传) Args: limit: 最大返回条数 before_seq: 只返回 SEQ <= before_seq 的数据(防止补传过快) """ with self._lock: if before_seq: rows = self.conn.execute( """SELECT seq, ts_ms, device_id, payload FROM data_cache WHERE published=0 AND seq <= ? ORDER BY seq ASC LIMIT ?""", (before_seq, limit) ).fetchall() else: rows = self.conn.execute( """SELECT seq, ts_ms, device_id, payload FROM data_cache WHERE published=0 ORDER BY seq ASC LIMIT ?""", (limit,) ).fetchall() return rows def mark_published(self, seq: int): """标记单条为已发布""" with self._lock: self.conn.execute( "UPDATE data_cache SET published=1 WHERE seq=?", (seq,) ) self.conn.commit() def mark_batch_published(self, seqs: List[int]): """批量标记已发布""" with self._lock: self.conn.executemany( "UPDATE data_cache SET published=1 WHERE seq=?", [(s,) for s in seqs] ) self.conn.commit() def get_cache_stats(self) -> dict: """缓存统计信息""" with self._lock: total = self.conn.execute( "SELECT COUNT(*) FROM data_cache" ).fetchone()[0] unpublished = self.conn.execute( "SELECT COUNT(*) FROM data_cache WHERE published=0" ).fetchone()[0] min_seq = self.conn.execute( "SELECT MIN(seq) FROM data_cache" ).fetchone()[0] or 0 max_seq = self.conn.execute( "SELECT MAX(seq) FROM data_cache" ).fetchone()[0] or 0 return { "total": total, "unpublished": unpublished, "published": total - unpublished, "seq_range": (min_seq, max_seq), "db_size_mb": os.path.getsize(self.db_path) / 1e6 } def close(self): """关闭数据库(强制 checkpoint)""" # 关闭前做一次 checkpoint,把 WAL 合并到主库 self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE);") self.conn.close()

4.2 PRAGMA 参数的工程含义

复制代码

sql

体验AI代码助手

代码解读

复制代码

-- 逐个解释前面的 PRAGMA -- 1. journal_mode=WAL -- 使用 Write-Ahead Logging,写入时只追加 WAL 文件, -- 读取时同时读主库和 WAL。写入性能比 DELETE 模式快 5-10 倍。 -- 代价:WAL 文件需要定期 checkpoint 以避免无限增长。 -- 2. synchronous=NORMAL -- FULL 模式:每次事务提交都调用 fsync(最安全,最慢,约 10ms/次) -- NORMAL 模式:WAL 模式下每 checkpoint 才 fsync(足够安全) -- OFF 模式:不调用 fsync(更快,但断电可能损坏数据库) -- 工业网关建议用 NORMAL------兼顾安全与性能。 -- 3. busy_timeout=5000 -- 默认情况下,SQLite 在遇到锁时会立即返回 SQLITE_BUSY。 -- 设置 5000ms 意味着:遇到锁时等待 5 秒而不是报错。 -- 这对多线程采集非常重要------采集线程不会因为写入冲突而丢数据。 -- 4. cache_size=-16000 -- 设置页面缓存为 16MB(负值表示 KB,正值表示页数)。 -- 适度的缓存可以减少磁盘 I/O。

5. 时间戳仲裁------谁的时间才是真的?

离线缓存最隐蔽的坑不是丢数据,而是数据的时间戳是乱的

边缘网关云平台网关时钟漂移-3秒(RTC 晶振偏差)云平台收到 A时间戳 < 已处理的数据最终结果:数据乱序、时序异常数据 A | ts=1000 (采集时刻)时序数据库判定 A 为&"过期数据&"拒绝写入或按首到达时间写入边缘网关云平台

5.1 时钟漂移的来源

漂移来源 幅度 说明
RTC 晶振温漂 ±20 ppm/°C 温度变化 10°C,24 小时漂移约 17 秒
主板 RTC 精度 ±50-100 ppm 廉价网关一天漂移 4-8 秒
NTP 同步间隔 同步前误差积累 设定 24h 同步一次,最差误差在同步前达到峰值
GPS 授时 <1 ms 成本高,部分场景不可用

5.2 四种仲裁策略

策略 实现 优点 缺点 推荐场景
完全信任边缘 直接使用采集时间戳 简单、原始时间真实 时钟漂移导致乱序 有稳定 GPS/NTP 同步
完全信任云端 以云端到达时间为准 一致、单调递增 丢失真实的采集间隔 数据间隔不重要,仅需趋势
混合校正 边缘时间 + NTP 偏移补偿 保留原始时间、修正漂移 需维护 NTP 偏移量 大多数工业场景
Lamport 逻辑时钟 单调递增的 SEQ 为准 严格有序、不受漂移影响 无实际时间含义 分布式一致性关键

推荐方案------混合校正:

复制代码

python

体验AI代码助手

代码解读

复制代码

""" timestamp_arbiter.py --- 混合时间戳仲裁器 """ import time import ntplib from threading import Thread, Lock from typing import Optional class TimeArbiter: """ 时间戳仲裁器 核心思路:维护边缘时钟相对于 NTP 参考的偏移量, 用这个偏移量修正采集时间戳,同时保留原始采集时间。 """ def __init__(self, ntp_server: str = "pool.ntp.org", sync_interval: float = 3600.0): """ Args: ntp_server: NTP 服务器 sync_interval: NTP 同步间隔(秒) """ self.ntp_server = ntp_server self.sync_interval = sync_interval self._offset = 0.0 # 时钟偏移(秒) self._last_sync = 0.0 self._lock = Lock() def sync(self) -> Optional[float]: """ 向 NTP 服务器同步时间 Returns: 同步后的偏移量(秒),失败返回 None """ try: client = ntplib.NTPClient() response = client.request(self.ntp_server, timeout=5) with self._lock: # offset = 服务器时间 - 客户端时间 # 正值表示客户端时钟慢了 self._offset = response.offset self._last_sync = time.time() return self._offset except Exception: return None def correct(self, raw_timestamp_ms: int) -> dict: """ 校正原始时间戳 Args: raw_timestamp_ms: 边缘设备采集时的毫秒时间戳 Returns: { "raw_ts": 原始时间戳, "corrected_ts": 校正后的时间戳, "offset_ms": 偏移量(毫秒), "arrival_ts": 云端到达时间戳(由发布端填充) } """ with self._lock: offset_ms = int(self._offset * 1000) # 如果长时间未同步,记录警告标志 stale = (time.time() - self._last_sync) > self.sync_interval * 2 return { "raw_ts": raw_timestamp_ms, "corrected_ts": raw_timestamp_ms - offset_ms, "offset_ms": offset_ms, "stale_sync": stale, "arrival_ts": int(time.time() * 1000) # 由接收端填充 } def start_auto_sync(self): """启动后台自动同步线程""" def _sync_loop(): while True: result = self.sync() if result is not None: pass time.sleep(self.sync_interval) t = Thread(target=_sync_loop, daemon=True) t.start()

云平台侧的校验逻辑:

复制代码

python

体验AI代码助手

代码解读

复制代码

class TimestampValidator: """ 云平台时间戳校验器 根据边缘节点上报的 corrected_ts + offset_ms, 判断该时间戳是否在可接受范围内。 """ def __init__(self, max_skew_ms: int = 5000): """ Args: max_skew_ms: 允许的最大偏差(毫秒) """ self.max_skew_ms = max_skew_ms self.last_valid_ts = {} # device_id -> last_valid_timestamp def validate(self, device_id: str, record: dict) -> dict: """ 校验并返回最终时间戳 决策逻辑: 1. 如果 corrected_ts 单调递增 → 使用 corrected_ts 2. 如果 corrected_ts 回退但幅度 < max_skew → 使用 arrival_ts 3. 如果 corrected_ts 回退幅度 > max_skew → 报警 + 丢弃 """ corrected = record["corrected_ts"] arrival = record["arrival_ts"] last_ts = self.last_valid_ts.get(device_id, 0) if corrected > last_ts: # 正常情况:时间戳递增 final_ts = corrected status = "OK" elif abs(corrected - last_ts) <= self.max_skew_ms: # 轻微回退:用到达时间代替 final_ts = arrival status = "CORRECTED_BY_ARRIVAL" else: # 严重回退:数据异常 final_ts = arrival status = "ALARM_LARGE_SKEW" self.last_valid_ts[device_id] = final_ts return { "timestamp": final_ts, "corrected_ts": corrected, "arrival_ts": arrival, "status": status, "offset_ms": record.get("offset_ms", 0), "stale_sync": record.get("stale_sync", False) }

6. 断点续传------Gap Detection 与补传窗口

当网络恢复时,边缘网关需要把离线期间缓存的数据补发到云端。这里有两个工程问题:

6.1 补传顺序的选择

策略 补传顺序 优点 缺点
先到先补(FIFO) 按 SEQ 从小到大 保持原始时序 大量数据时云端下游处理压力大
最新优先(LIFO) 按 SEQ 从大到小 实时数据快速可用 历史数据到达延迟大
分窗口补传 分多个窗口并行 兼顾实时和历史 实现复杂

推荐:分窗口补传------将历史数据分成多个窗口并行补发,同时保持实时数据流的优先级最高。

6.2 Gap Detection

云端需要检测数据缺失并通知边缘补传:

边缘网关云平台网络中断 10 分钟生成 SEQ 1001-1500实时流恢复:SEQ 1501-1510(最新数据)检测 gap:收到 1510,但上一个收到的是 1000记录缺失范围:1001-1500请求补传:REQUEST_RETRANSMIT 1001-1500补传窗口 1: SEQ 1001-1100补传窗口 2: SEQ 1101-1200(并行)实时数据继续:SEQ 1511-1520确认收到全部边缘网关云平台

6.3 反压控制------防止恢复时的"雷鸣问题"

当网关离线数小时,积压的数据可能达到数十万条。如果网络恢复后立即全力补传,会出现三个问题:

  1. 云平台入口带宽被打满,影响其他设备的实时数据
  2. Broker 队列暴涨,OOM 风险
  3. 时序数据库写入压力陡增,查询性能下降
复制代码

python

体验AI代码助手

代码解读

复制代码

""" backpressure.py --- 反压控制 """ class BackpressureController: """ 补传反压控制器 核心思想:动态调整补传速率,保证补传不占用超过 50% 的带宽预算。 """ def __init__(self, max_rate_hz: float = 100.0, realtime_ratio: float = 0.5): """ Args: max_rate_hz: 最大发送速率(条/秒) realtime_ratio: 实时数据保留的带宽比例 """ self.max_rate_hz = max_rate_hz self.realtime_ratio = realtime_ratio # 补传可用带宽 self.retransmit_rate = max_rate_hz * (1 - realtime_ratio) # 滑动窗口统计 self.sent_timestamps = [] # 最近的发送时间戳 def can_send_retransmit(self) -> bool: """ 判断当前是否可以发送一条补传数据 基于令牌桶:统计最近 1 秒内的发送量, 不超过 retransmit_rate 则允许发送。 """ now = time.time() # 清除 1 秒前的记录 cutoff = now - 1.0 self.sent_timestamps = [t for t in self.sent_timestamps if t > cutoff] if len(self.sent_timestamps) < self.retransmit_rate: self.sent_timestamps.append(now) return True return False def adapt_rate(self, cloud_latency_ms: float): """ 根据云端响应延迟动态调整补传速率 如果延迟上升了,说明云端处理压力大,降速。 """ if cloud_latency_ms > 500: # 延迟超过 500ms,降速到一半 self.retransmit_rate *= 0.5 elif cloud_latency_ms < 100: # 延迟低,可以适当提速 self.retransmit_rate = min( self.retransmit_rate * 1.1, self.max_rate_hz * 0.5 )

7. 完整网关实现

将前面所有组件组合为一个完整的离线缓存网关:

复制代码

python

体验AI代码助手

代码解读

复制代码

""" offline_cache_gateway.py --- 完整的离线缓存与断点续传网关 依赖: pip install paho-mqtt """ import time import json import logging import threading from typing import Optional, List, Tuple import paho.mqtt.client as mqtt # 复用之前的组件 # from ring_buffer import RingBuffer, DataRecord # from sqlite_cache import SQLiteCache # from timestamp_arbiter import TimeArbiter # from backpressure import BackpressureController # 为独立运行,将所有组件合并于此 logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') logger = logging.getLogger("OfflineCacheGateway") class OfflineCacheGateway: """ 完整的离线缓存断点续传网关 组件: - RingBuffer: 内存缓存(快路径) - SQLiteCache: 持久缓存(慢路径 + 断网补传) - TimeArbiter: 时间戳校正 - BackpressureController: 补传反压 - MQTT 发布器 """ def __init__(self, mqtt_host: str = "localhost", mqtt_port: int = 1883, topic_prefix: str = "spBv1.0/plant/NDATA", db_path: str = "gateway_cache.db", ring_size: int = 65536, max_cache: int = 500000, ntp_server: str = "pool.ntp.org"): self.mqtt_host = mqtt_host self.mqtt_port = mqtt_port self.topic_prefix = topic_prefix # 缓存层级 from collections import deque self.ring = RingBuffer(ring_size) if 'RingBuffer' in dir() else None self.sqlite = SQLiteCache(db_path, max_cache) if 'SQLiteCache' in dir() else None self.time_arb = TimeArbiter(ntp_server) self.backpressure = BackpressureController() # MQTT self.client = mqtt.Client(client_id="offline_gw_01") self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect self.client.on_publish = self._on_publish # 状态 self.connected = False self.seq = 0 self.active_retransmit = False # 是否正在补传 self._lock = threading.Lock() # ===== MQTT 回调 ===== def _on_connect(self, client, userdata, flags, rc): if rc == 0: self.connected = True logger.info("MQTT 已连接") # 连接恢复后:先发实时数据,同时启动补传 self._trigger_retransmit() else: logger.error(f"MQTT 连接失败: rc={rc}") def _on_disconnect(self, client, userdata, rc): self.connected = False if rc != 0: logger.warning("MQTT 非正常断开,数据将缓存到本地") def _on_publish(self, client, userdata, mid): """MQTT 发布确认回调""" pass # ===== 数据采集与发布 ===== def ingest(self, device_id: str, values: dict): """ 采集一条数据(由采集线程调用) 流程:写入 RingBuffer → 异步写入 SQLite → 尝试发布 无论网络是否正常,都不阻塞采集线程。 """ raw_ts = int(time.time() * 1000) with self._lock: self.seq += 1 seq = self.seq # 矫正时间戳 ts_info = self.time_arb.correct(raw_ts) # 构造载荷 record = { "seq": seq, "ts": ts_info, "device_id": device_id, "values": values } payload = json.dumps(record).encode() # 1. 写入 Ring Buffer(内存快路径) if self.ring: self.ring.write(DataRecord( seq=seq, ts=raw_ts, device_id=device_id, payload=payload, published=False )) # 2. 写入 SQLite(持久化,异步线程) if self.sqlite: threading.Thread( target=self._persist_record, args=(seq, raw_ts, device_id, payload), daemon=True ).start() # 3. 尝试实时发布 if self.connected: self._publish(seq, device_id, payload) def _persist_record(self, seq, ts_ms, device_id, payload): """持久化到 SQLite(异步线程执行)""" try: self.sqlite.store(seq, ts_ms, device_id, payload) except Exception as e: logger.error(f"持久化失败 seq={seq}: {e}") def _publish(self, seq: int, device_id: str, payload: bytes, is_retransmit: bool = False): """发布一条数据到 MQTT""" topic = f"{self.topic_prefix}/{device_id}" try: info = self.client.publish(topic, payload, qos=1) # 标记为已发布 if self.ring: self.ring.mark_published(seq) if self.sqlite and not is_retransmit: # 实时数据的标记在确认回调中处理 pass except Exception as e: logger.error(f"发布失败 seq={seq}: {e}") # ===== 断点续传 ===== def _trigger_retransmit(self): """触发补传(在新线程中执行,避免阻塞实时采集)""" if self.active_retransmit: return self.active_retransmit = True t = threading.Thread(target=self._retransmit_loop, daemon=True) t.start() def _retransmit_loop(self): """ 补传循环 策略: 1. 从 SQLite 获取未发布数据 2. 用反压控制器限制速率 3. 分批补传 4. 收到确认后标记已发布 """ if not self.sqlite: self.active_retransmit = False return logger.info("开始断点续传...") batch_size = 50 # 每批补传 50 条 retransmitted = 0 while True: if not self.connected: logger.info("补传中断:网络已断开") break # 获取一批未发布数据 records = self.sqlite.get_unpublished(limit=batch_size) if not records: logger.info(f"补传完成:共补传 {retransmitted} 条") break # 应用反压:等待可用令牌 for seq, ts_ms, device_id, payload in records: while not self.backpressure.can_send_retransmit(): time.sleep(0.01) if not self.connected: break if not self.connected: break # 补传时不走反压的实时数据配额 self._publish(seq, device_id, payload, is_retransmit=True) self.sqlite.mark_published(seq) retransmitted += 1 # 批次间隔------给云端处理时间 time.sleep(0.5) self.active_retransmit = False # ===== 生命周期 ===== def start(self): """启动网关""" # 启动 MQTT self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=30) self.client.loop_start() # 启动 NTP 同步 self.time_arb.start_auto_sync() logger.info("离线缓存网关已启动") def stop(self): """停止网关""" if self.sqlite: self.sqlite.close() self.client.loop_stop() self.client.disconnect() logger.info("离线缓存网关已停止") # ===== 使用示例 ===== if __name__ == "__main__": import random def simulate_modbus_read(): """模拟 Modbus 采集""" return { "temperature": round(random.uniform(20, 80), 1), "pressure": round(random.uniform(0.5, 5.0), 2), "flow": round(random.uniform(10, 100), 1) } gateway = OfflineCacheGateway( mqtt_host="localhost", mqtt_port=1883, db_path="gateway_cache.db" ) gateway.start() # 模拟采集循环:每 1 秒采集一次 try: for i in range(3600): # 模拟 1 小时 values = simulate_modbus_read() gateway.ingest(f"pump_01", values) if i % 10 == 0: # 每 10 秒打印缓存统计 if gateway.sqlite: stats = gateway.sqlite.get_cache_stats() logger.info(f"缓存统计: {stats}") time.sleep(1) except KeyboardInterrupt: pass finally: gateway.stop()

运行和验证

为了验证崩溃恢复能力,可以编写一个测试脚本:

复制代码

python

体验AI代码助手

代码解读

复制代码

""" test_crash_recovery.py --- 验证离线缓存与崩溃恢复 """ import subprocess import time import signal import os def test_crash_recovery(): """ 测试流程: 1. 启动网关 2. 写入 100 条数据(模拟正常采集) 3. 断开网络(模拟 4G 断连) 4. 再写入 200 条数据(验证本地缓存) 5. 恢复网络 6. 强制 kill 进程(模拟崩溃) 7. 重启网关 8. 验证补传数据完整性 """ print("=== 测试 1: 正常采集与发布 ===") proc = subprocess.Popen( ["python", "offline_cache_gateway.py"], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) time.sleep(2) # 这里通过向网关发送信号来测试 # 完整自动化测试请集成 pytest print("=== 测试 2: 模拟崩溃后数据完整性 ===") # 记录当前数据库中的 SEQ 范围 import sqlite3 conn = sqlite3.connect("gateway_cache.db") count_before = conn.execute( "SELECT COUNT(*) FROM data_cache" ).fetchone()[0] print(f"崩溃前缓存记录数: {count_before}") # 模拟崩溃:kill 进程 os.kill(proc.pid, signal.SIGKILL) time.sleep(1) # 重新启动 proc2 = subprocess.Popen( ["python", "offline_cache_gateway.py"], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) time.sleep(2) # 检查数据库完整性 conn2 = sqlite3.connect("gateway_cache.db") integrity = conn2.execute("PRAGMA integrity_check").fetchone()[0] print(f"数据库完整性: {integrity}") count_after = conn2.execute( "SELECT COUNT(*) FROM data_cache" ).fetchone()[0] print(f"重启后缓存记录数: {count_after}") proc2.kill() print("=== 测试完成 ===")

8. 常见深坑与根本原因分析

深坑 1:SQLite 写入速度满足不了采集频率

现象:以 10ms 间隔采集数据时,数据写入开始出现延迟积累,最终导致采集线程阻塞。

根因:SQLite 即使配置了 WAL 模式,单个事务的提交延迟也在 50-200µs 左右。如果每个数据点都独立提交,10ms 采集间隔下累积开销会占满 CPU。

解决

复制代码

python

体验AI代码助手

代码解读

复制代码

# 错误:逐条写入、逐条提交 for data in stream: sqlite.store(data) # 每次一个事务 sqlite.conn.commit() # 一次 fsync # 正确:批量写入、定时提交 buffer = [] last_commit = time.time() for data in stream: buffer.append(data) if len(buffer) >= 100 or time.time() - last_commit > 1.0: sqlite.store_batch(buffer) sqlite.conn.commit() # 100 条一次 fsync buffer = [] last_commit = time.time()

批量写入 100 条数据只需一次 fsync,写入吞吐量提升 50-100 倍。

深坑 2:SQLite WAL 文件的无限增长

现象 :网关运行数月后,发现磁盘空间被 .db-wal 文件占满(可达主库的 10 倍大小)。

根因 :WAL 文件在 checkpoint 之前不断增长。如果 checkpoint 触发不及时(比如 synchronous=OFF 模式下),WAL 文件可能膨胀到几百 MB。

解决

复制代码

python

体验AI代码助手

代码解读

复制代码

# 方案 1:定期主动 checkpoint def periodic_checkpoint(conn, interval_sec=300): """每 5 分钟做一次 checkpoint""" while True: time.sleep(interval_sec) conn.execute("PRAGMA wal_checkpoint(TRUNCATE);") # 方案 2:设置 WAL 自动 checkpoint 阈值 conn.execute("PRAGMA wal_autocheckpoint=1000;") # 每 1000 页做一次 checkpoint

相关推荐
网络研究院2 小时前
2026 终极攻防变局:深度拆解 MITRE ATT&CK ER8 企业安全评估路线图与微观技术实战
网络·安全·网络研究观
羽翼安全2 小时前
多摄像头接入检测 + 文件加密:监控室防拍照系统的两道设备与数据防线
运维·网络·人工智能
运维行者_2 小时前
如何为您的企业选择最佳网络监控工具
大数据·运维·服务器·网络·数据库
liulilittle10 小时前
关于拥塞控制的几点思考
网络·c++·tcp/ip·计算机网络·信息与通信·tcp·通信
AOwhisky11 小时前
MySQL 学习笔记(第四期):SQL 语言之多表查询
linux·运维·网络·数据库·笔记·学习·mysql
Phantom Void11 小时前
服务器处理客户端请求的设计方法
linux·运维·网络
王码码203511 小时前
办了500M宽带看视频还是卡?我用NAS搭了个测速服务器,宽带有没有缩水一测便知
网络·接口·nas
ylscode11 小时前
Anthropic Claude Oceanus意外泄露:Mythos系列AI红队测试遭遇API代理滥用危机
网络·人工智能·安全·web安全·安全威胁分析
Trouvaille ~11 小时前
【Redis篇】Redis 哨兵(Sentinel):高可用自动故障转移
数据库·redis·缓存·中间件·sentinel·高可用·哨兵