1. 引言:14 篇文章,一个完整系统
从第 1 篇的"为什么 PLC 数据采集是工业数字化的第一个深坑",到第 14 篇的"边缘计算与云边协同",这个系列用了 14 篇文章、超过 100 个可运行代码片段、数十张 Mermaid 图和对比表格,系统地拆解了工业数据采集的每一个环节。
你可能已经注意到一个模式:每篇文章都聚焦一个独立的深度问题,并且都在文末埋下了"下一篇"的钩子。 这是刻意的------工业数采的真正挑战从来不是单一技术问题,而是所有环节交织在一起时的系统性耦合效应。
所以这篇最终章不做新概念的引入,而是把前 14 篇文章的所有核心组件组装成一个端到端可运行的全栈系统:
css
PLC 仿真(采集源)
↓ Modbus / OPC UA
边缘网关(采集 + 规则引擎 + 质量检测 + 缓存 + 反压)
↓ MQTT (Sparkplug B)
数据管道(清洗 + 转发)
↓
时序数据库 + Grafana 仪表盘
整个系统的代码全部可运行------你只需要 docker-compose up 加上一个 Python 进程,就能在自己的开发机上得到一台"虚拟工厂"的完整数据链路。这是你带走的一套可直接投入生产的最小可行系统。
2. 全链路架构总览
2.1 系统架构图
2.2 组件清单
| 层级 | 组件 | 技术选型 | 来源 |
|---|---|---|---|
| 设备层 | 反应釜仿真 | Python + pyModbus (TCP Server) | 本篇实现 |
| 设备层 | 传送带仿真 | Python + asyncua (OPC UA Server) | 本篇实现 |
| 设备层 | 环境监测仿真 | Python + pyModbus (TCP Server) | 本篇实现 |
| 边缘层 | 采集管理器 | Python threading + 调度器 | 第 3、5、9 篇 |
| 边缘层 | 无锁环形缓冲区 | Python array + atomic write | 第 9 篇 |
| 边缘层 | 规则引擎 | AST 安全求值器 | 第 14 篇 |
| 边缘层 | 数据质量检测 | Quality Badge + 3σ/EWMA | 第 12 篇 |
| 边缘层 | 离线缓存 | SQLite WAL + 预写日志 | 第 7、13 篇 |
| 边缘层 | MQTT 发布器 | paho-mqtt + Sparkplug B | 第 6 篇 |
| 边缘层 | 资源监控器 | psutil + 降级控制器 | 第 13 篇 |
| 云端 | MQTT Broker | Eclipse Mosquitto | 第 6 篇 |
| 云端 | 数据管道 | Telegraf | 本篇 |
| 云端 | 时序数据库 | InfluxDB OSS | 本篇 |
| 云端 | 仪表盘 | Grafana | 本篇 |
| 验证 | 故障注入器 | Python 中间人代理 | 本篇 |
| 验证 | 健康监控 | Prometheus metrics endpoint | 第 10 篇 |
2.3 数据流全景
反压传递路径(关键机制):
InfluxDB 写入瓶颈 → Telegraf 缓存积压 → MQTT 消费变慢 →
边缘网关 MQTT 发布队列增长 → 环形缓冲区写入线程阻塞 →
采集环路感知背压 → 自动降低 Polling 频率 → CPU 下降
这条反压链是"采集系统有弹性"的本质------不是靠增大缓冲区硬扛,而是让瓶颈信息逐级反馈到源头,让整个系统的吞吐量自然适配最慢环节。
3. 设备仿真层------三台虚拟 PLC
3.1 场景设计
三个场景覆盖了工业现场最常见的三种数据特征:
| 场景 | 数据特征 | 采集频率 | 特殊行为 | 模拟协议 |
|---|---|---|---|---|
| 反应釜 | 缓慢漂移 + 偶尔尖峰(温度失控) | 1 Hz | 每 5 分钟随机触发一次超温事件 | Modbus TCP :5020 |
| 传送带 | 稳态 + 周期性波动(启停/调速) | 10 Hz | 每 10 分钟模拟一次电机电流漂移 | OPC UA :4841 |
| 环境监测 | 缓慢周期变化(日温度曲线) | 0.2 Hz(5s 一次) | 完全平稳,极少异常 | Modbus TCP :5021 |
这种设计是有意的:反应釜产生少量但关键 的报警数据,传送带产生高速但稳定 的连续数据,环境监测产生低频但需长期留存的背景数据。一个真实的网关需要同时处理这三种流量模式。
3.2 Modbus PLC 仿真器
python
"""
plc_simulator_modbus.py --- 基于 pyModbus 的 PLC 仿真器
同时启动两个 Modbus TCP Server(反应釜+环境监测),
使用独立线程模拟各自的数据特征。
"""
from pyModbusTCP.server import DataBank, ModbusServer
import threading
import time
import random
import math
class ReactorSimulator:
"""
反应釜仿真器
标签映射 (Modbus Holding Registers):
40001: 温度 (℃, 放大10倍, int16)
40002: 压力 (bar, 放大100倍, int16)
40003: 液位 (%, 放大10倍, int16)
40004: 阀门状态 (0=关闭, 1=开启)
40005: 报警标志 (bit0=高温, bit1=高压, bit2=低液位)
"""
BASE_TEMP = 175.0 # 正常运行温度
BASE_PRESSURE = 6.5 # 正常运行压力
SPIRE_INTERVAL = 300 # 每300秒模拟一次"温度失控"
SPIRE_DURATION = 15 # 失控持续15秒
def __init__(self, server: ModbusServer):
self.server = server
self._running = False
self._tick = 0
def start(self):
self._running = True
t = threading.Thread(target=self._sim_loop, daemon=True)
t.start()
def stop(self):
self._running = False
def _sim_loop(self):
"""数据生成循环"""
spire_remaining = 0
while self._running:
self._tick += 1
# 温度: 基础值 + 随机噪声 + 周期性小波动
temp_noise = random.gauss(0, 1.5)
temp_wave = 3.0 * math.sin(self._tick * 0.02)
temp = self.BASE_TEMP + temp_noise + temp_wave
# 压力: 随温度轻微变化
pressure = self.BASE_PRESSURE + (temp - self.BASE_TEMP) * 0.05
pressure += random.gauss(0, 0.2)
# 温度失控模拟
if spire_remaining > 0:
# 温度快速爬升
temp += 15.0 * (1 - spire_remaining / self.SPIRE_DURATION)
pressure += 2.0 * (1 - spire_remaining / self.SPIRE_DURATION)
spire_remaining -= 1
elif self._tick % self.SPIRE_INTERVAL == 0:
# 触发失控事件
spire_remaining = self.SPIRE_DURATION
# 液位: 极缓慢变化 + 小噪声
level = 65.0 + 10.0 * math.sin(self._tick * 0.005) + random.gauss(0, 0.5)
# 阀门: 温度超过185开启
valve = 1 if temp > 185.0 else 0
# 报警标志
alarm = 0
if temp > 185.0:
alarm |= 0x01 # bit0: 高温
if pressure > 8.5:
alarm |= 0x02 # bit1: 高压
if level < 30.0:
alarm |= 0x04 # bit2: 低液位
# 写入 Modbus 寄存器 (放大后取整)
self.server.data_bank.set_holding_registers(
0, [int(temp * 10), int(pressure * 100),
int(level * 10), valve, alarm]
)
time.sleep(1.0) # 1Hz
class EnvironmentSimulator:
"""
环境监测仿真器
标签映射 (Modbus Holding Registers):
40001: 温度 (℃, 放大10倍)
40002: 湿度 (%, 放大10倍)
40003: CO₂浓度 (ppm)
40004: 空气质量指数 (0-500)
"""
def __init__(self, server: ModbusServer):
self.server = server
self._running = False
self._tick = 0
def start(self):
self._running = True
t = threading.Thread(target=self._sim_loop, daemon=True)
t.start()
def stop(self):
self._running = False
def _sim_loop(self):
while self._running:
self._tick += 1
# 模拟24小时温度周期 (振幅±5℃)
hour_angle = (self._tick * 5) % 360 # 5秒=1"小时"
base_temp = 25.0 + 5.0 * math.sin(math.radians(hour_angle - 90))
registers = [
int((base_temp + random.gauss(0, 0.3)) * 10), # 温度
int((55.0 + 15.0 * math.sin(math.radians(hour_angle + 180))
+ random.gauss(0, 2)) * 10), # 湿度
int(420 + 80 * math.sin(math.radians(hour_angle / 2)) # CO₂
+ random.gauss(0, 20)),
int(50 + 20 * math.sin(math.radians(hour_angle / 4))), # AQI
]
self.server.data_bank.set_holding_registers(0, registers)
time.sleep(5.0) # 0.2Hz
def start_modbus_sims():
"""启动两个 Modbus 仿真 PLC"""
# 反应釜 :5020
reactor_server = ModbusServer(host="0.0.0.0", port=5020)
reactor = ReactorSimulator(reactor_server)
# 环境监测 :5021
env_server = ModbusServer(host="0.0.0.0", port=5021)
env = EnvironmentSimulator(env_server)
reactor_server.start()
env_server.start()
reactor.start()
env.start()
print("[Modbus PLC] 反应釜 :5020 | 环境监测 :5021 已启动")
return (reactor_server, env_server), (reactor, env)
if __name__ == "__main__":
servers, sims = start_modbus_sims()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
for s in sims:
s.stop()
for s in servers:
s.stop()
关键设计说明:
- 温度用
放大10倍后转 int16存储------这是现场最常见的做法,因为 Modbus Holding Register 只有 16 位。取整带来的 0.1℃ 精度损失在工业场景完全可以接受。 - 报警标志使用位掩码而非多个寄存器------节省带宽,且方便下游做位运算判断。这是现场老工程师的习惯,初学者往往每个报警用一个寄存器。
3.3 OPC UA 传送带仿真器
python
"""
plc_simulator_opcua.py --- 基于 asyncua 的 OPC UA 传送带仿真器
将传送带的速度、电机电流、振动数据暴露为 OPC UA 变量节点,
每 100ms 更新一次(10Hz)。
"""
import asyncio
import random
import math
from asyncua import Server, ua
async def start_conveyor_sim():
"""启动 OPC UA 传送带仿真器 :4841"""
server = Server()
await server.init()
server.set_endpoint("opc.tcp://0.0.0.0:4841/freeopcua/server/")
server.set_server_name("Conveyor Belt Simulator")
# 注册地址空间
uri = "http://plc-sim.conveyor"
idx = await server.register_namespace(uri)
# 创建对象节点
obj = await server.nodes.objects.add_object(idx, "ConveyorBelt")
# 创建变量节点
speed = await obj.add_variable(
ua.NodeId(2001, idx), "Speed", 1.2,
ua.VariantType.Double
)
motor_current = await obj.add_variable(
ua.NodeId(2002, idx), "MotorCurrent", 5.0,
ua.VariantType.Double
)
vibration = await obj.add_variable(
ua.NodeId(2003, idx), "Vibration", 0.1,
ua.VariantType.Double
)
temperature = await obj.add_variable(
ua.NodeId(2004, idx), "MotorTemperature", 45.0,
ua.VariantType.Double
)
running = await obj.add_variable(
ua.NodeId(2005, idx), "Running", True,
ua.VariantType.Boolean
)
# 设置为可写(模拟 PLC 侧变化)
await speed.set_writable()
await motor_current.set_writable()
await vibration.set_writable()
await temperature.set_writable()
await running.set_writable()
async with server:
print("[OPC UA] 传送带仿真器 :4841 已启动")
tick = 0
drift_timer = 0 # 电流漂移计时
while True:
tick += 1
drift_timer += 1
# 基础速度: 1.2 m/s + 缓慢周期性变化
base_speed = 1.2 + 0.3 * math.sin(tick * 0.05)
speed_val = max(0, base_speed + random.gauss(0, 0.02))
await speed.write_value(speed_val)
# 电机电流: 与速度相关 + 噪声
current_base = 5.0 + (speed_val - 1.2) * 0.5
# 每6000 tick (~10分钟)模拟一次电流漂移
if drift_timer % 6000 == 0:
current_base += 3.0 # 电流异常抬升
print(f"[OPC UA 事件] 传送带电机电流异常抬升: {current_base:.1f}A")
# 持续300 tick (30秒)后恢复
elif drift_timer % 6000 in range(1, 301):
current_base -= 0.01 # 缓慢回归
current_val = current_base + random.gauss(0, 0.15)
await motor_current.write_value(max(0, current_val))
# 振动: 随速度和电流增加而增加
vibe = 0.1 + 0.3 * (speed_val / 1.5) + 0.1 * (current_val / 5.0)
vibe += random.gauss(0, 0.05)
await vibration.write_value(max(0, vibe))
# 电机温度: 缓慢变化
temp = 45.0 + (current_val - 5.0) * 5 + random.gauss(0, 0.5)
await temperature.write_value(min(95, temp))
# 运行状态
running_val = speed_val > 0.1
await running.write_value(running_val)
await asyncio.sleep(0.1) # 10Hz
4. 边缘网关集成------14 篇文章的"最后一公里"
4.1 网关核心架构
这是整个系列最重要的代码------它把前 14 篇文章的核心机制整合到同一个进程里,让它们协同工作。
python
"""
integrated_gateway.py --- 全功能集成边缘网关
组件来源:
- 采集调度 → 第 3、5 篇
- 环形缓冲区 → 第 9 篇
- 数据质量 → 第 12 篇
- 规则引擎 → 第 14 篇
- 离线缓存 → 第 7 篇
- MQTT 发布 → 第 6 篇
- 资源监控 → 第 13 篇
- 健康检查 → 第 10 篇
"""
import time
import json
import struct
import threading
import sqlite3
import queue
import logging
from collections import deque
from typing import Optional, Dict, List, Callable
from dataclasses import dataclass, field
from enum import Enum
# 外部依赖
# pip install paho-mqtt pyModbusTCP asyncua psutil
# ============================================================
# 1. 数据结构定义
# ============================================================
class QualityBadge(Enum):
"""质量标签(第 12 篇简化版)"""
GOOD = "good"
UNCERTAIN = "uncertain"
BAD = "bad"
STALE = "stale" # 超过 max_age 未更新
@dataclass
class DataPoint:
"""统一数据点结构"""
tag: str # 标签名,如 "reactor.temp"
value: float
quality: QualityBadge = QualityBadge.GOOD
timestamp: float = 0.0 # 采集时间戳
seq: int = 0 # 全局递增序号
source: str = "" # 来源: "modbus" | "opcua"
def to_dict(self) -> dict:
return {
"tag": self.tag,
"value": self.value,
"quality": self.quality.value,
"ts": self.timestamp,
"seq": self.seq,
"source": self.source,
}
# ============================================================
# 2. 无锁环形缓冲区(第 9 篇)
# ============================================================
class RingBuffer:
"""
线程安全的无锁环形缓冲区
单生产者/单消费者场景下,使用原子指针操作避免锁竞争。
"""
def __init__(self, capacity: int = 16384):
self.capacity = capacity
self._buffer = [None] * capacity
self._head = 0 # 生产指针
self._tail = 0 # 消费指针
self._overflow_count = 0
def push(self, item: DataPoint) -> bool:
"""写入一个数据点,返回是否成功(False = 缓冲区满被丢弃)"""
next_head = (self._head + 1) % self.capacity
if next_head == self._tail:
# 缓冲区满,丢弃最旧的数据(覆盖 tail)
self._tail = (self._tail + 1) % self.capacity
self._overflow_count += 1
self._buffer[self._head] = item
self._head = next_head
return True
def pop(self) -> Optional[DataPoint]:
"""读取一个数据点"""
if self._head == self._tail:
return None
item = self._buffer[self._tail]
self._buffer[self._tail] = None
self._tail = (self._tail + 1) % self.capacity
return item
@property
def size(self) -> int:
return (self._head - self._tail) % self.capacity
@property
def stats(self) -> dict:
return {
"capacity": self.capacity,
"size": self.size,
"overflow_count": self._overflow_count,
"usage_pct": round(self.size / self.capacity * 100, 1),
}
# ============================================================
# 3. 数据质量检测器(第 12 篇简化版)
# ============================================================
class QualityDetector:
"""
数据质量检测
使用 3-sigma 规则判定异常值。
"""
def __init__(self, window_size: int = 50, sigma: float = 3.0,
max_age: float = 30.0):
self.window = deque(maxlen=window_size)
self.sigma = sigma
self.max_age = max_age
def check(self, point: DataPoint, now: float) -> DataPoint:
"""检查数据点质量,返回标记后的数据点"""
# 1. 陈旧性检查
if now - point.timestamp > self.max_age:
point.quality = QualityBadge.STALE
return point
# 2. 3-sigma 异常检测
if len(self.window) >= 10:
values = [v for _, v in self.window]
mean = sum(values) / len(values)
variance = sum((v - mean) ** 2 for v in values) / len(values)
std = variance ** 0.5
if abs(point.value - mean) > self.sigma * std:
point.quality = QualityBadge.BAD
else:
point.quality = QualityBadge.GOOD
else:
point.quality = QualityBadge.GOOD
# 更新窗口
self.window.append((time.time(), point.value))
return point
# ============================================================
# 4. 规则引擎(第 14 篇 - 最小版本)
# ============================================================
class SafeRuleEngine:
"""最小安全规则引擎(完整版见第 14 篇)"""
def __init__(self):
self._rules: List[dict] = []
def load_rules(self, rules: List[dict]):
self._rules = rules
def evaluate(self, point: DataPoint) -> List[dict]:
"""评估所有规则,返回触发的动作"""
triggered = []
for rule in self._rules:
if not rule.get("enabled", True):
continue
condition = rule.get("condition", "")
# 简易条件匹配(生产环境用 AST 安全求值器)
try:
result = eval(condition, {
"__builtins__": {},
"tag": point.tag,
"value": point.value,
"quality": point.quality.value,
}, {})
if result:
triggered.append({
"rule_id": rule["rule_id"],
"actions": rule.get("actions", []),
"point": point.to_dict(),
})
except Exception:
pass
return triggered
# ============================================================
# 5. 离线缓存(第 7 篇 - SQLite WAL)
# ============================================================
class OfflineCache:
"""
离线数据缓存
使用 SQLite WAL 模式保证写入性能。
网络恢复后按序补传。
"""
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS data_cache (
seq INTEGER PRIMARY KEY,
tag TEXT NOT NULL,
value REAL NOT NULL,
quality TEXT NOT NULL,
ts REAL NOT NULL,
uploaded INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_uploaded ON data_cache(uploaded);
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-4096; -- 4MB 页缓存
"""
def __init__(self, db_path: str = "edge_cache.sqlite"):
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.conn.executescript(self.CREATE_SQL)
self._lock = threading.Lock()
def cache(self, point: DataPoint):
"""缓存一个数据点"""
with self._lock:
self.conn.execute(
"INSERT OR IGNORE INTO data_cache VALUES (?,?,?,?,?,0)",
(point.seq, point.tag, point.value,
point.quality.value, point.timestamp)
)
self.conn.commit()
def get_pending(self, limit: int = 500) -> List[tuple]:
"""获取未上传的数据"""
with self._lock:
cur = self.conn.execute(
"SELECT * FROM data_cache WHERE uploaded=0 ORDER BY seq LIMIT ?",
(limit,)
)
return cur.fetchall()
def mark_uploaded(self, seq: int):
"""标记已上传"""
with self._lock:
self.conn.execute(
"UPDATE data_cache SET uploaded=1 WHERE seq=?", (seq,)
)
self.conn.commit()
def count_pending(self) -> int:
with self._lock:
cur = self.conn.execute(
"SELECT COUNT(*) FROM data_cache WHERE uploaded=0"
)
return cur.fetchone()[0]
def close(self):
self.conn.close()
# ============================================================
# 6. 边缘网关主类
# ============================================================
class EdgeGateway:
"""
集成边缘网关
核心线程架构:
- 采集线程池 (3个): 分别采集三个 PLC
- 处理线程 (1个): 从环形缓冲区消费→质量检测→规则→发布
- 资源监控线程 (1个): 监控 CPU/内存/磁盘
"""
# 默认规则集(边缘端初始规则)
DEFAULT_RULES = [
{
"rule_id": "reactor_hi_temp",
"enabled": True,
"condition": "tag == 'reactor.temp' and value > 185",
"actions": [{
"type": "log_alarm",
"severity": "critical",
"message": "反应釜超温! {value:.1f}℃",
}],
},
{
"rule_id": "conveyor_high_current",
"enabled": True,
"condition": "tag == 'conveyor.current' and value > 8.0",
"actions": [{
"type": "log_warning",
"severity": "warning",
"message": "传送带电流异常: {value:.1f}A",
}],
},
{
"rule_id": "bad_quality_alert",
"enabled": True,
"condition": "quality == 'bad'",
"actions": [{
"type": "log_warning",
"severity": "warning",
"message": "数据质量异常: {tag} = {value}",
}],
},
]
def __init__(self, gateway_id: str = "GW-DEMO-001",
mqtt_host: str = "localhost", mqtt_port: int = 1883):
self.gateway_id = gateway_id
self.seq = 0
self._seq_lock = threading.Lock()
# 核心组件
self.ring = RingBuffer(capacity=32768)
self.quality = QualityDetector()
self.rules = SafeRuleEngine()
self.rules.load_rules(self.DEFAULT_RULES)
self.cache = OfflineCache()
self._running = False
# MQTT 客户端(延迟初始化)
self._mqtt = None
self._mqtt_host = mqtt_host
self._mqtt_port = mqtt_port
self._mqtt_lock = threading.Lock()
# 降级控制(第 13 篇)
self.degradation_level = 0 # 0=正常, 1=轻度降级, 2=重度降级
self._publish_interval = 0.0 # 额外发布延迟
# 统计
self.stats = {
"points_collected": 0,
"points_published": 0,
"points_cached": 0,
"rules_fired": 0,
"quality_bad": 0,
"overflows": 0,
}
def next_seq(self) -> int:
with self._seq_lock:
self.seq += 1
return self.seq
# ----- 采集器 -----
def _modbus_collector(self, host: str, port: int,
tag_prefix: str, register_map: Dict[int, str],
interval: float):
"""
Modbus 采集线程(第 3、5 篇)
Args:
host: PLC 地址
port: 端口
tag_prefix: 标签前缀,如 "reactor"
register_map: {地址: 标签名}
interval: 采集间隔(秒)
"""
from pyModbusTCP.client import ModbusClient
client = ModbusClient(host=host, port=port, auto_open=True, timeout=3.0)
retry_delay = 1.0
while self._running:
try:
for addr, name in register_map.items():
regs = client.read_holding_registers(addr, 1)
if regs is not None:
# 放大还原(按约定:温度/液位×10, 压力×100, 其他原值)
if name in ("temp",):
value = regs[0] / 10.0
elif name in ("pressure",):
value = regs[0] / 100.0
elif name in ("level",):
value = regs[0] / 10.0
else:
value = float(regs[0])
point = DataPoint(
tag=f"{tag_prefix}.{name}",
value=value,
timestamp=time.time(),
seq=self.next_seq(),
source="modbus",
)
self.ring.push(point)
self.stats["points_collected"] += 1
else:
# 读取失败:生成一个 BAD 质量的数据点
point = DataPoint(
tag=f"{tag_prefix}.{name}",
value=0.0,
quality=QualityBadge.BAD,
timestamp=time.time(),
seq=self.next_seq(),
source="modbus",
)
self.ring.push(point)
self.stats["quality_bad"] += 1
retry_delay = 1.0 # 成功后重置重试间隔
except Exception as e:
logging.warning(f"[{tag_prefix}] 采集失败: {e}")
time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30.0) # 指数退避
time.sleep(interval)
def _opcua_collector(self, url: str, tag_prefix: str,
node_ids: Dict[str, str], interval: float):
"""
OPC UA 采集线程(第 4 篇)
注意:asyncua 是异步库,这里用 asyncio.run_coroutine_threadsafe 桥接。
为简化示例,使用同步风格封装。
"""
import asyncio
from asyncua import Client
async def read_loop():
client = Client(url=url, timeout=3.0)
retry_delay = 1.0
while self._running:
try:
await client.connect()
retry_delay = 1.0 # 连接成功后重置
while self._running:
for var_name, node_str in node_ids.items():
node = client.get_node(node_str)
value = await node.read_value()
now = time.time()
if value is not None:
point = DataPoint(
tag=f"{tag_prefix}.{var_name}",
value=float(value),
timestamp=now,
seq=self.next_seq(),
source="opcua",
)
self.ring.push(point)
self.stats["points_collected"] += 1
else:
point = DataPoint(
tag=f"{tag_prefix}.{var_name}",
value=0.0,
quality=QualityBadge.BAD,
timestamp=now,
seq=self.next_seq(),
source="opcua",
)
self.ring.push(point)
self.stats["quality_bad"] += 1
await asyncio.sleep(interval)
except Exception as e:
logging.warning(f"[{tag_prefix}] OPC UA 连接异常: {e}")
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30.0)
finally:
try:
await client.disconnect()
except Exception:
pass
asyncio.run(read_loop())
# ----- 处理管道 -----
def _processing_loop(self):
"""
主处理循环
从环形缓冲区消费 → 质量检测 → 规则评估 → MQTT 发布/缓存
"""
while self._running:
point = self.ring.pop()
if point is None:
time.sleep(0.001) # 空缓冲时短暂休眠
continue
now = time.time()
# 1. 质量检测(第 12 篇)
point = self.quality.check(point, now)
# 2. 规则引擎(第 14 篇)
triggered = self.rules.evaluate(point)
for t in triggered:
self.stats["rules_fired"] += 1
for action in t["actions"]:
self._handle_action(action, t["point"])
# 3. 根据连接状态和降级等级决定发布策略
connected = self._mqtt_is_connected()
if connected and self.degradation_level == 0:
# 正常发布
self._mqtt_publish(point)
self.stats["points_published"] += 1
elif connected and self.degradation_level == 1:
# 轻度降级: 降低采样
if point.seq % 5 == 0:
self._mqtt_publish(point)
self.stats["points_published"] += 1
self.cache.cache(point)
self.stats["points_cached"] += 1
elif connected and self.degradation_level == 2:
# 重度降级: 仅发布 BAD 质量数据(报警优先)
if point.quality == QualityBadge.BAD:
self._mqtt_publish(point)
self.stats["points_published"] += 1
self.cache.cache(point)
self.stats["points_cached"] += 1
else:
# 离线: 全部缓存
self.cache.cache(point)
self.stats["points_cached"] += 1
# 更新溢出统计
self.stats["overflows"] = self.ring.stats["overflow_count"]
def _handle_action(self, action: dict, point: dict):
"""处理规则触发的动作"""
action_type = action.get("type")
if action_type == "log_alarm":
msg = action["message"].format(**point)
print(f"[ALARM] {msg}")
elif action_type == "log_warning":
msg = action["message"].format(**point)
print(f"[WARN] {msg}")
# ----- MQTT 发布(第 6 篇)-----
def _init_mqtt(self):
"""初始化 MQTT 客户端(延迟初始化)"""
if self._mqtt is not None:
return
try:
import paho.mqtt.client as mqtt
self._mqtt = mqtt.Client(
client_id=self.gateway_id,
protocol=mqtt.MQTTv311,
)
self._mqtt.connect(self._mqtt_host, self._mqtt_port, keepalive=60)
self._mqtt.loop_start()
except ImportError:
logging.warning("paho-mqtt 未安装,MQTT 发布功能不可用")
def _mqtt_is_connected(self) -> bool:
return self._mqtt is not None and self._mqtt.is_connected()
def _mqtt_publish(self, point: DataPoint):
"""发布数据到 MQTT"""
if not self._mqtt_is_connected():
self._init_mqtt()
if not self._mqtt_is_connected():
return
topic = f"plc/{self.gateway_id}/{point.tag}"
payload = json.dumps(point.to_dict(), ensure_ascii=False)
try:
self._mqtt.publish(topic, payload, qos=1)
except Exception as e:
logging.warning(f"MQTT 发布失败: {e}")
# ----- 补传(第 7 篇)-----
def _backfill_loop(self):
"""
离线数据补传线程
每 10 秒检查一次是否有待上传的缓存数据。
"""
while self._running:
time.sleep(10)
if not self._mqtt_is_connected():
continue
pending = self.cache.get_pending(limit=200)
if not pending:
continue
print(f"[补传] 上传 {len(pending)} 条缓存数据...")
for row in pending:
seq, tag, value, quality, ts, _ = row
point = DataPoint(
tag=tag, value=value,
quality=QualityBadge(quality),
timestamp=ts, seq=seq,
source="cache",
)
self._mqtt_publish(point)
self.cache.mark_uploaded(seq)
# ----- 资源监控与降级(第 13 篇)-----
def _resource_monitor_loop(self):
"""
资源监控与自动降级
根据 CPU 和内存使用率自动调整降级等级。
"""
import psutil
while self._running:
time.sleep(15)
cpu_pct = psutil.cpu_percent()
mem_pct = psutil.virtual_memory().percent
pending = self.cache.count_pending()
if cpu_pct > 80 or mem_pct > 85:
self.degradation_level = 2 # 重度降级
print(f"[降级] CPU={cpu_pct}% MEM={mem_pct}% → 等级 2")
elif cpu_pct > 50 or mem_pct > 70 or pending > 10000:
self.degradation_level = 1 # 轻度降级
print(f"[降级] CPU={cpu_pct}% MEM={mem_pct}% "
f"缓存={pending} → 等级 1")
else:
if self.degradation_level > 0:
print(f"[恢复] 资源正常,解除降级")
self.degradation_level = 0
# ----- 健康检查端点(第 10 篇)-----
def health_check(self) -> dict:
"""返回网关当前健康状态"""
with self._seq_lock:
return {
"gateway_id": self.gateway_id,
"status": "running" if self._running else "stopped",
"uptime": time.time() - self._start_time,
"degradation_level": self.degradation_level,
"ring_buffer": self.ring.stats,
"cache_pending": self.cache.count_pending(),
"mqtt_connected": self._mqtt_is_connected(),
"stats": dict(self.stats),
}
# ----- 生命周期 -----
def start(self):
"""启动网关全部组件"""
self._running = True
self._start_time = time.time()
# 1. 采集线程
threading.Thread(
target=self._modbus_collector,
args=("localhost", 5020, "reactor",
{0: "temp", 1: "pressure", 2: "level",
3: "valve", 4: "alarm_flags"}),
kwargs={"interval": 1.0},
daemon=True, name="collect-reactor",
).start()
threading.Thread(
target=self._modbus_collector,
args=("localhost", 5021, "env",
{0: "temp", 1: "humidity", 2: "co2", 3: "aqi"}),
kwargs={"interval": 5.0},
daemon=True, name="collect-env",
).start()
threading.Thread(
target=self._opcua_collector,
args=("opc.tcp://localhost:4841", "conveyor",
{"speed": "i=2001", "current": "i=2002",
"vibration": "i=2003", "motor_temp": "i=2004",
"running": "i=2005"}),
kwargs={"interval": 0.1},
daemon=True, name="collect-conveyor",
).start()
# 2. 处理线程
threading.Thread(
target=self._processing_loop,
daemon=True, name="processor",
).start()
# 3. 补传线程
threading.Thread(
target=self._backfill_loop,
daemon=True, name="backfill",
).start()
# 4. 资源监控线程
threading.Thread(
target=self._resource_monitor_loop,
daemon=True, name="resource-monitor",
).start()
print(f"[网关] {self.gateway_id} 已启动 | "
f"3个采集器 → 环形缓冲区(32K) → MQTT({self._mqtt_host}:{self._mqtt_port})")
def stop(self):
"""停止网关"""
self._running = False
self.cache.close()
if self._mqtt:
self._mqtt.loop_stop()
self._mqtt.disconnect()
stats = self.health_check()
print(f"[网关] 已停止 | 采集 {stats['stats']['points_collected']} 点 "
f"| 发布 {stats['stats']['points_published']} | "
f"缓存 {stats['stats']['points_cached']} | "
f"报警 {stats['stats']['rules_fired']} 次")
return stats
# ===== 独立运行 =====
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
gw = EdgeGateway()
gw.start()
try:
while True:
time.sleep(10)
# 每 10 秒输出一次健康状态
h = gw.health_check()
print(f" [状态] 缓存={h['cache_pending']}条 "
f"降级={h['degradation_level']} "
f"环形缓冲区={h['ring_buffer']['usage_pct']}% "
f"MQTT={'OK' if h['mqtt_connected'] else '断开'}")
except KeyboardInterrupt:
gw.stop()
4.2 组件协作时序
5. Docker Compose------一键启动全栈环境
yaml
# docker-compose.yml
# 启动命令: docker-compose up -d
# 停止命令: docker-compose down
version: "3.8"
services:
mosquitto:
image: eclipse-mosquitto:2
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf
restart: unless-stopped
influxdb:
image: influxdb:2.7
ports:
- "8086:8086"
environment:
DOCKER_INFLUXDB_INIT_MODE: setup
DOCKER_INFLUXDB_INIT_USERNAME: admin
DOCKER_INFLUXDB_INIT_PASSWORD: admin123456
DOCKER_INFLUXDB_INIT_ORG: plc-demo
DOCKER_INFLUXDB_INIT_BUCKET: plc-data
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: plc-demo-token
volumes:
- influxdb_data:/var/lib/influxdb2
restart: unless-stopped
telegraf:
image: telegraf:1.29
depends_on:
- mosquitto
- influxdb
volumes:
- ./telegraf.conf:/etc/telegraf/telegraf.conf:ro
restart: unless-stopped
grafana:
image: grafana/grafana:10.4
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_INSTALL_PLUGINS: ""
depends_on:
- influxdb
volumes:
- grafana_data:/var/lib/grafana
- ./grafana-dashboard.json:/etc/grafana/provisioning/dashboards/plc-dashboard.json
- ./grafana-datasource.yaml:/etc/grafana/provisioning/datasources/influxdb.yaml
restart: unless-stopped
volumes:
influxdb_data:
grafana_data:
对应的 Mosquitto 配置(mosquitto.conf):
ini
listener 1883
allow_anonymous true
max_keepalive 60
persistence true
persistence_location /mosquitto/data/
log_dest stdout
对应的 Telegraf 配置(telegraf.conf):
ini
# Telegraf 配置 --- MQTT 订阅 → InfluxDB 写入
[[inputs.mqtt_consumer]]
servers = ["tcp://mosquitto:1883"]
topics = ["plc/#"]
qos = 1
data_format = "json"
# 使用 JSON 字段中的 "tag" 作为 measurement 名
json_time_key = "ts"
json_time_format = "unix"
tag_keys = ["tag", "quality", "source", "gateway_id"]
[[outputs.influxdb_v2]]
urls = ["http://influxdb:8086"]
token = "plc-demo-token"
organization = "plc-demo"
bucket = "plc-data"
# 批量写入设置
batch_size = 1000
flush_interval = "5s"
6. 故障注入与集成测试
这是全链路集成的"压力测试"环节------我们主动注入四种真实故障,验证网关能否正确应对。
python
"""
integration_test.py --- 全链路故障注入与集成测试
测试场景:
1. PLC 停机 → 验证数据质量检测正确标记 BAD
2. 网络中断 → 验证离线缓存 + 恢复补传
3. 时钟漂移 → 验证时间戳仲裁
4. MQTT 断连 → 验证反压 + 降级机制
"""
import time
import threading
import socket
import json
class FaultInjector:
"""
故障注入器
在不修改网关代码的前提下,通过外部干预模拟故障。
"""
def __init__(self, gateway):
self.gw = gateway
self._running = False
# ----- 故障 1: PLC 停机 -----
def stop_modbus_plc(self, port: int, duration: float = 30.0):
"""
通过占用端口模拟 PLC 断连
Args:
port: PLC 端口
duration: 停机时长(秒)
"""
def _fault():
print(f"[故障注入] Modbus PLC :{port} 停机 {duration}s")
# 创建一个 socket 占用端口(模拟 PLC 无响应)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("0.0.0.0", port))
sock.listen(1)
time.sleep(duration)
finally:
sock.close()
print(f"[故障注入] Modbus PLC :{port} 恢复")
threading.Thread(target=_fault, daemon=True).start()
# ----- 故障 2: 网络中断(MQTT 断连)-----
def simulate_network_loss(self, duration: float = 60.0):
"""
通过停止 MQTT 客户端模拟网络中断
Args:
duration: 中断时长(秒)
"""
def _fault():
print(f"[故障注入] 网络中断 {duration}s")
if self.gw._mqtt:
self.gw._mqtt.disconnect()
time.sleep(duration)
print(f"[故障注入] 网络恢复")
# 网关的 _backfill_loop 会自动补传
threading.Thread(target=_fault, daemon=True).start()
# ----- 故障 3: 时钟漂移 -----
def simulate_clock_drift(self, offset: float = 30.0):
"""
模拟边缘时钟漂移
注意:这里不修改系统时钟(需要 root 权限),
而是通过修改网关内部的 timestamp 处理逻辑来模拟。
Args:
offset: 漂移秒数(正=未来,负=过去)
"""
orignal_check = self.gw.quality.check
def drifted_check(point, now):
# 人为偏移时间戳
point.timestamp += offset
return orignal_check(point, now)
self.gw.quality.check = drifted_check
print(f"[故障注入] 时钟漂移 +{offset}s")
# ----- 故障 4: 突发高负载 -----
def simulate_cpu_spike(self, duration: float = 20.0):
"""
通过 CPU 密集计算模拟负载
Args:
duration: 持续时间(秒)
"""
def _fault():
print(f"[故障注入] CPU 突发负载 {duration}s")
end = time.time() + duration
while time.time() < end:
_ = [i ** 2 for i in range(10000)]
print("[故障注入] CPU 负载恢复")
threading.Thread(target=_fault, daemon=True).start()
# ----- 集成测试入口 -----
def run_all_tests(self):
"""顺序执行所有故障注入测试"""
print("=" * 60)
print("全链路集成测试开始")
print("=" * 60)
# 测试前状态
h_before = self.gw.health_check()
print(f"初始状态: 采集={h_before['stats']['points_collected']} "
f"缓存={h_before['cache_pending']}")
# Test 1: PLC 停机 20s
print("\n--- Test 1: PLC 停机 (20s) ---")
self.stop_modbus_plc(5020, duration=20.0)
time.sleep(25)
h = self.gw.health_check()
print(f" 采集={h['stats']['points_collected']} "
f"BAD质量={h['stats']['quality_bad']}")
# Test 2: 网络中断 30s
print("\n--- Test 2: 网络中断 (30s) ---")
cache_before = self.gw.cache.count_pending()
self.simulate_network_loss(duration=30.0)
time.sleep(35)
h = self.gw.health_check()
cache_after = self.gw.cache.count_pending()
print(f" 缓存增长: {cache_before} → {cache_after} "
f"发布={h['stats']['points_published']}")
# Test 3: CPU 突发负载
print("\n--- Test 3: CPU 突发负载 (15s) ---")
cpu_before = self.gw.degradation_level
self.simulate_cpu_spike(duration=15.0)
time.sleep(20)
cpu_after = self.gw.degradation_level
print(f" 降级等级: {cpu_before} → {cpu_after}")
# 最终报告
print("\n" + "=" * 60)
print("集成测试结果")
print("=" * 60)
final = self.gw.health_check()
stats = final['stats']
print(f" 总采集数据点: {stats['points_collected']}")
print(f" MQTT 发布: {stats['points_published']}")
print(f" 离线缓存: {stats['points_cached']}")
print(f" 环形缓冲区溢出: {stats['overflows']}")
print(f" 质量异常标记: {stats['quality_bad']}")
print(f" 规则触发: {stats['rules_fired']}")
print(f" 最大降级等级: {final['degradation_level']}")
print(f" 缓存待上传: {final['cache_pending']}")
print(f" MQTT 连接: {'正常' if final['mqtt_connected'] else '断开'}")
# 验证断言
passed = 0
total = 3
if stats['quality_bad'] > 0:
print(" [PASS] 质量检测: 正确标记了 BAD 数据")
passed += 1
if stats['points_cached'] > 0:
print(" [PASS] 离线缓存: 网络中断期间数据未丢失")
passed += 1
if stats['overflows'] == 0 or stats['overflows'] < 10:
print(" [PASS] 环形缓冲区: 未发生严重溢出")
passed += 1
print(f"\n 通过率: {passed}/{total}")
return passed == total
# ===== 运行测试 =====
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.WARNING)
print("启动边缘网关与 PLC 仿真器...")
# 注意:实际运行前需要先启动 PLC 仿真器和 Docker 容器
# 这里只演示测试逻辑
gw = EdgeGateway()
gw.start()
time.sleep(3)
injector = FaultInjector(gw)
success = injector.run_all_tests()
gw.stop()
print(f"\n集成测试 {'✅ 全部通过' if success else '❌ 有失败项'}")
预期测试结果解读
| 故障场景 | 预期行为 | 验证指标 |
|---|---|---|
| PLC 停机 20s | 采集线程抛出异常 → 生成 BAD 质量点 → 规则引擎触发报警 | quality_bad > 0 |
| 网络中断 30s | MQTT 断开 → 处理线程切换为缓存模式 → SQLite 增长 | points_cached > 0 |
| 网络恢复 | 补传线程读取 SQLite → 按序发布 → 标记已上传 | cache_pending → 0 (趋近) |
| CPU 负载 > 80% | 资源监控器检测 → 降级等级 2 → 仅发布 BAD 数据 | degradation_level = 2 |
| 时钟漂移 | 质量检测的陈旧性检查触发 → 标记 STALE | 时间戳偏差 > max_age |
7. 全栈启动流程
如果你想把整个系统跑起来,按以下步骤操作:
markdown
步骤 1: 安装依赖
----------------------------------------------------
pip install paho-mqtt pyModbusTCP asyncua psutil
步骤 2: 启动 Docker 基础设施
----------------------------------------------------
docker-compose up -d mosquitto influxdb grafana
步骤 3: 启动 PLC 仿真器(三个终端或后台进程)
----------------------------------------------------
# 终端 1: 反应釜 + 环境监测 (Modbus)
python plc_simulator_modbus.py
# 终端 2: 传送带 (OPC UA)
python plc_simulator_opcua.py
步骤 4: 启动边缘网关
----------------------------------------------------
python integrated_gateway.py
步骤 5: 可选------运行集成测试(在另一个终端)
----------------------------------------------------
python integration_test.py
步骤 6: 打开 Grafana 仪表盘
----------------------------------------------------
浏览器打开 http://localhost:3000
登录: admin / admin
添加 InfluxDB 数据源:
URL: http://influxdb:8086
Token: plc-demo-token
Organization: plc-demo
Default Bucket: plc-data
导入预置仪表盘或编写查询:
from(bucket: "plc-data")
|> range(start: -1h)
|> filter(fn: (r) => r["tag"] == "reactor.temp")
|> aggregateWindow(every: 5s, fn: mean)
8. 系列总结------从入门到实战的知识图谱
8.1 全系列知识体系
8.2 学习路径建议
根据你的岗位角色,可以选择不同的阅读路径:
| 角色 | 必读篇目 | 可跳过 | 预计阅读时间 |
|---|---|---|---|
| 现场自动化工程师(偏硬件) | 1, 2, 3, 5, 7, 8 | 9, 11, 12 (偏软件) | 6-8 小时 |
| 上位机/SCADA 开发工程师 | 2, 3, 4, 6, 9, 14 | 5, 8 (偏硬件/安全) | 8-10 小时 |
| IoT 云平台开发工程师 | 4, 6, 7, 11, 12, 14 | 1, 2, 5 (偏PLC) | 6-8 小时 |
| 系统架构师/技术负责人 | 1, 8, 10, 11, 13, 14, 15 | --- (全部需要理解) | 12-15 小时 |
| 学生/转行入门 | 1→2→3→5→6→7→9→15 | 8, 11, 12 (可后补) | 循序渐进 |
8.3 每篇核心知识点速查
| 序号 | 核心知识点 | 关键代码/工具 | 深坑重点 |
|---|---|---|---|
| 1 | 扫描周期 vs 采集周期冲突 | Python 时序仿真 + pyModbus | 无声丢数据的三种场景 |
| 2 | PLC 内存布局、地址映射 | db_parser.py 结构解析 |
字节序 + struct padding |
| 3 | Modbus TCP 报文逐字节拆解 | 自定义 TCP 抓包 | 超时级联效应 |
| 4 | OPC UA 地址空间模型 | asyncua 安全模式对比 |
安全通道握手开销 |
| 5 | RS-485 半双工 + 3.5 字符间隔 | 完整 Modbus RTU 主站 | 换向时机 + 波特率陷阱 |
| 6 | MQTT QoS 工程代价 + Sparkplug B 状态机 | paho-mqtt + Protobuf |
QoS 2 的 4 次握手下行 |
| 7 | 三层缓存 + Gap Detection | SQLite WAL + 环形缓冲区 | 补传顺序与幂等性 |
| 8 | OT/IT 纵深防御 | DPI 代理 + Wireshark 分析 | 三种安全模式的性能基准 |
| 9 | 毫秒级采集 + 无锁队列 | Ring Buffer + NumPy FFT | 延迟抖动的非高斯分布 |
| 10 | 可观测性三支柱 + 根因决策树 | HTTP 健康端点 + Python 诊断 | 误报 vs 漏报的权衡 |
| 11 | 千点分片 + 两级聚合 | etcd 配置管理 | 全局时间戳对齐偏差 |
| 12 | Quality Badge + 三种检测算法 | 3σ/EWMA/Isolation Forest | 告警收敛的必要性 |
| 13 | 256MB 资源约束 | 分时调度 + SQLite 写放大优化 | SD 卡写寿命与 F2FS |
| 14 | 规则引擎 + 云边版本同步 | AST 安全求值器 + MQTT 规则 Topic | 僵尸规则与时钟漂移 |
| 15 | 全链路端到端集成 | Docker Compose + Grafana | 反压传递与故障注入 |
8.4 未来拓展方向
整个系列到此结束,但工业数据采集的深度远不止此。以下是几个值得继续深挖的方向:
-
TSN(时间敏感网络):当采集精度从毫秒级进入微秒级,标准以太网的不确定性成为瓶颈。TSN 的 802.1Qbv 门控调度、802.1AS 时钟同步(gPTP)是下一代工业网络的核心。
-
工业 AI 推理:本文第 12 篇用 Isolation Forest 做了离线异常检测。边缘端的实时 AI 推理(TensorRT Lite、ONNX Runtime)正在改变"采集→云端AI→返回决策"的延迟瓶颈。
-
OPC UA FX(Field eXchange):OPC UA 正在向现场级延伸,UAFX + PubSub 的组合有望统一从传感器到云端的通信协议栈------但这套技术的工程成熟度还需要 2-3 年验证。
-
数字孪生与数据回灌:采集只是第一步,将历史数据回灌到仿真环境中做故障回溯、参数优化、预测性维护,才是采集的最终价值。
如果你在这些方向上有实际项目经验,欢迎在评论区分享你的工程故事------我会在后续的番外篇中挑选典型问题进行深入分析。
9. 写在最后
15 篇文章,从一台 PLC 的内存布局开始,到一套完整的端到端采集系统结束。这个系列试图做到一件事:不只是告诉你"怎么用",而是告诉你"为什么"和"还有什么陷阱"。
如果你回头翻一遍这个系列的代码,会发现一个统一的风格:
- 所有代码都能跑------不是伪代码,不是片段,全部可以在模拟环境或真实硬件上执行。
- 所有配置都有注释------每个参数为什么是这个值,改了会怎样。
- 所有坑都有根因分析------不只是告诉你"别这样做",而是告诉你"为什么这样做会出问题"。
这也是我在现场 10 多年的风格:给出路,而不是只给建议。
最后,我想对读完整个系列的你表示敬意。工业数采是自动化工程中最"脏"最"碎"的活儿之一------它既需要懂 PLC 的扫描周期,又需要懂 TCP 的 Nagle 算法,还需要懂 SQLite 的 WAL 日志和 MQTT 的 QoS 状态机。这种跨层知识体系的构建需要大量时间和现场经验的积累。
但这就是工业数字化的本质:不是某一个技术的突破,而是所有技术的系统性整合。
如果你在阅读过程中有任何自己的工程故事、踩坑经历或不同见解,欢迎在评论区留言。我会定期整理和回复,并把典型问题在番外篇中深入展开。