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

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 网络中一个真实的攻击面分析------不是教你做安全合规文档,而是让你看明白数据采集链路中哪些环节最容易成为突破口。

相关推荐
猪猪拆迁队1 小时前
虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预
后端·ai编程
字节跳动数据库1 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横1 小时前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户6757049885022 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan2 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885022 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia2 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530142 小时前
Java 实现 Word 文档加密与权限解除
java·后端
vanuan2 小时前
给你的A2A-Agent加把锁-认证鉴权实战指南
后端
Yeats_Liao3 小时前
14:Servlet中的页面跳转-Java Web
java·后端·架构