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
采集
-
实时写入
-
异步落盘
-
实时投递
网络正常
网络中断
- 断线后补传
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 反压控制------防止恢复时的"雷鸣问题"
当网关离线数小时,积压的数据可能达到数十万条。如果网络恢复后立即全力补传,会出现三个问题:
- 云平台入口带宽被打满,影响其他设备的实时数据
- Broker 队列暴涨,OOM 风险
- 时序数据库写入压力陡增,查询性能下降
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