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

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

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

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

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

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

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

2. 离线缓存架构全景

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

graph TB subgraph 数据源 Data Source PLC[PLC / Modbus RTU] end subgraph 边缘网关 Edge Gateway PLC -->|采集| APP[采集主线程] APP -->|1. 实时写入| RB[内存环形缓冲区Ring Bufferµs 级写入] APP -->|2. 异步落盘| SQL[SQLite 持久存储WAL 模式断电安全] RB -->|3. 实时投递| MQTT[MQTT 发布线程] subgraph 缓存层级 Cache Layers RB SQL end MQTT -->|网络正常| BROKER[(MQTT Broker)] BROKER -->|网络中断| MQTT SQL -->|4. 断线后补传| MQTT end subgraph 云平台 Cloud BROKER --> SUB[Sparkplug 订阅端] SUB --> TSDB[时序数据库] end

三层缓存各司其职:

层级 存储介质 写入延迟 容量 断电保护 用途
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 复制代码
"""
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 复制代码
"""
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 复制代码
-- 逐个解释前面的 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. 时间戳仲裁------谁的时间才是真的?

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

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

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 复制代码
"""
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 复制代码
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

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

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

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

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

  1. 云平台入口带宽被打满,影响其他设备的实时数据
  2. Broker 队列暴涨,OOM 风险
  3. 时序数据库写入压力陡增,查询性能下降
python 复制代码
"""
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 复制代码
"""
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 复制代码
"""
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 复制代码
# 错误:逐条写入、逐条提交
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 复制代码
# 方案 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

深坑 3:文件系统缓存与断电数据丢失

现象:网关意外断电后重启,发现最后 30 秒的数据丢失了。

根因 :即使设置了 PRAGMA synchronous=NORMAL,操作系统仍然会在内存中缓存文件写入。断电时,缓存中的数据不会落盘。

sequenceDiagram participant APP as 采集程序 participant OS as 操作系统缓存 participant DISK as 磁盘 APP->>OS: write() --- 返回成功 Note over APP: 程序以为数据已写入 Note over OS: 数据在缓存中,尚未写入磁盘 OS-->>DISK: pdflush / fsync --- 异步刷盘 Note over DISK: 如果断电发生在刷盘前 → 数据丢失

解决

python 复制代码
# 方案 1:关键数据用 synchronous=FULL(性能损失约 10 倍)
conn.execute("PRAGMA synchronous=FULL;")

# 方案 2:在关键操作后显式调用 fsync(平衡方案)
import os

def critical_write(conn):
    """写关键数据后强制刷盘"""
    conn.execute("INSERT INTO ...")
    conn.commit()
    # 获取数据库文件描述符并 fsync
    db_fd = conn.execute("PRAGMA database_list").fetchone()
    # 实际上 SQLite 的 FULL 模式已经做了这个
    # 不建议在应用层再调 fsync,可能 double-fsync

# 方案 3(推荐):使用带电容的 UPS 保护网关
# 或使用工业级 SD 卡(带断电保护)

最根本的解决方案:在网关硬件上用带电容保护(Power-Loss Protection)的存储介质。消费级 microSD 卡在工控场景下,因为频繁写入出现坏块的概率极高------我见过某项目用 SanDisk 普通卡,6 个月坏了 3 张。换用工业级 SLC SSD 后,再没出过问题。

深坑 4:补传风暴------网络恢复后的级联失败

现象:多台网关同时断线后同时恢复,补传数据把 MQTT Broker 和云平台打挂。

根因:分布式系统中的"雷鸣群问题"(Thundering Herd)。200 台网关在基站恢复后的 10 秒内同时重连、同时开始补传。

解决

python 复制代码
import random

class JitterRetransmit:
    """
    带随机抖动的补传启动

    每台网关在重连后随机等待 0-T_max 秒再开始补传。
    T_max 随着离线时长增加而增加。
    """

    def __init__(self, offline_duration: float):
        """
        Args:
            offline_duration: 离线时长(秒)
        """
        # 离线越长,抖动窗口越大
        self.max_delay = min(
            offline_duration * 0.1,  # 离线时间的 10%
            300.0  # 最多 5 分钟
        )

    def wait(self):
        delay = random.uniform(0, self.max_delay)
        time.sleep(delay)

9. 总结

离线缓存不是"有就好"的功能,而是一个需要精心设计的系统:

组件 解决的核心问题 设计关键
环形缓冲区 采集线程不被阻塞 固定容量、预分配、读写指针分离
SQLite(WAL 模式) 持久化存储、断电保护 PRAGMA 参数调优、批量写入
时间戳仲裁 时钟漂移导致的数据乱序 NTP 偏移校正 + 云端校验
反压控制 防止补传风暴 动态速率限制、雷鸣群抖动
断点续传 数据不丢不重 SEQ 追踪、Gap Detection、分窗口补传

核心原则:采集线程永不阻塞。无论网络是否正常、磁盘是否繁忙、MQTT 是否连接,采集线程应该只做三件事------读数据、写环形缓冲区、发信号给异步线程。任何可能阻塞的操作都要从采集线程中剥离。


👉 下一篇预告:PLC 数采系列 8 工业数据采集安全------当 OT 遇见 IT,谁对谁错? 前面 7 篇专注于"采得到",但从来没问过"该不该采"和"采得安全吗"。下一篇深入:Modbus TCP 的认证空白、OPC UA 安全配置误区和性能取舍、MQTT 的 ACL 设计、以及 OT 网络中一个真实的攻击面分析------不是教你做安全合规文档,而是让你看明白数据采集链路中哪些环节最容易成为突破口。

相关推荐
用户916842202741 小时前
Spring Boot application.yml 最全避坑与多环境配置
java·后端
fliter1 小时前
深入理解 Rust Futures:从零开始,一头扎到底
后端
前端的阶梯2 小时前
Cursor 开发 Python 项目完全指南
前端·人工智能·后端
前端的阶梯2 小时前
Conda 开发 Python 程序完全指南
前端·人工智能·后端
程序员cxuan2 小时前
AI 时代,如何超过大多数人
人工智能·后端·程序员
骄马之死2 小时前
Spring 核心知识点(IOC + AOP + 事务)
java·后端·spring
wei_shuo2 小时前
KES 高可用架构实战:主备复制、读写分离与容灾切换深度解析
后端
神奇小汤圆2 小时前
沉迷 Vibe coding 后我幡然醒悟:为什么可持续开发要回归半古法编程
后端
lichenyang4532 小时前
鸿蒙电商 Demo v2:真实商品接口 + 支付/订单闭环 + 收藏功能,外加一个 ArkUI V2 @Builder 响应式断链的硬核坑
前端·后端