1. 从"演示完美"到"上线丢数据"的 30 分钟
前面 6 篇文章都假设了一个前提:网络是可靠的。但在真实工业现场,这个假设不成立。
2023 年夏天,我一个做光伏电站的朋友给我打了一个电话。他们部署了 200 台智能逆变器采集系统,每台通过 4G DTU 上报数据到云端。实验室测试一切完美------每 10 秒一条数据,延迟稳定在 50ms 以内。上线第一周也正常。
真正的问题发生在一次雷雨天后:4G 基站断电 6 小时,网络恢复后发现损失了约 30% 的数据。
排查后发现:MQTT 客户端虽然有自动重连,但离线期间的数据全部被丢弃了------因为程序只在内存中缓存了最近 100 条数据。6 小时的断网产生了约 2000 条数据,内存缓冲区溢出后,旧数据被覆盖。雪上加霜的是,网关的系统时钟在离线期间漂移了 4 秒,导致恢复后补传的数据出现时间戳紊乱,时序数据库拒收了部分数据。
这个案例揭示了一个工程事实:不处理离线缓存的采集网关,最多算一个玩具。
2. 离线缓存架构全景
解决上述问题需要三层缓存协作:
三层缓存各司其职:
| 层级 | 存储介质 | 写入延迟 | 容量 | 断电保护 | 用途 |
|---|---|---|---|---|---|
| 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. 时间戳仲裁------谁的时间才是真的?
离线缓存最隐蔽的坑不是丢数据,而是数据的时间戳是乱的。
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
云端需要检测数据缺失并通知边缘补传:
6.3 反压控制------防止恢复时的"雷鸣问题"
当网关离线数小时,积压的数据可能达到数十万条。如果网络恢复后立即全力补传,会出现三个问题:
- 云平台入口带宽被打满,影响其他设备的实时数据
- Broker 队列暴涨,OOM 风险
- 时序数据库写入压力陡增,查询性能下降
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,操作系统仍然会在内存中缓存文件写入。断电时,缓存中的数据不会落盘。
解决:
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 网络中一个真实的攻击面分析------不是教你做安全合规文档,而是让你看明白数据采集链路中哪些环节最容易成为突破口。