前 10 篇我们都在讨论"一台网关搞定一切"的场景:一台工控机跑 Python 脚本,采集三五台 PLC,几百个测点,数据发到 MQTT Broker。这个模式在 500 个测点以下完全够用。但当规模上升到 5000 个测点、100+ 台 PLC、多个地理位置 时,单台网关的瓶颈会像多米诺骨牌一样连片倒下。
我先说一个我见过的现场。
某汽车零部件工厂做能源管理系统,一期 200 个测点跑得很好,二期扩展到 2000 个。原来的方案是一台 i5 工控机跑采集程序,连 40 台 PLC。上线第二天,采集延迟从平均 50ms 飙升到 3 秒,环形缓冲区一天溢出 5 次,MQTT 消息堆积到 Broker 直接 OOM。查了三天,最后的数据是这样的:
| 指标 | 200 点(正常) | 2000 点(崩溃) | 极限估算 |
|---|---|---|---|
| 采集周期 | 1s | 3-8s(漂移) | ≤200ms |
| Modbus 连接数 | 5 | 40 | ≤20 |
| 单周期请求数 | 20 | 400 | ≤100 |
| 内存占用 | 80MB | 1.2GB(不断增长) | ≤256MB |
| 环形缓冲区溢出 | 无 | 每小时 5 次 | 0 |
这不是代码写得烂,而是架构没有随规模升级。
1. 单台网关的瓶颈量化------为什么撑不住?
先不说分布式,先搞清楚"一台机器到底能扛多少",然后才能设计分拆方案。
1.1 Modbus TCP 的吞吐上限
Modbus TCP 一个 TCP 连接是串行的------必须发一个请求、等一个响应、再发下一个(除非打开流水线模式,但很多 PLC 不支持)。所以单连接的吞吐上限是:
scss
吞吐量 = 1000ms / (request_time + response_time + processing_time)
实测数据(PLC 响应时间 5ms,网关处理 2ms):
python
"""
throughput_benchmark.py --- 单台网关吞吐量基准测试
测试不同并发策略下的最大采集吞吐量。
"""
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import statistics
class ModbusSimulator:
"""模拟 Modbus PLC 响应时间"""
def __init__(self, plc_id: str, resp_time_ms: float = 5.0):
self.plc_id = plc_id
self.resp_time = resp_time_ms / 1000
def read(self, address: int) -> float:
"""模拟一次 Modbus 读取"""
time.sleep(self.resp_time) # 模拟 PLC 响应时间
return 42.0
class GatewayBenchmark:
"""
网关吞吐量基准测试
测试三种模式的吞吐上限:
1. 单线程串行(简单但慢)
2. 多线程并行(受 GIL 和连接数限制)
3. 异步 IO(asyncio,理论上最高效)
"""
def __init__(self, num_plcs: int = 10, points_per_plc: int = 10,
plc_response_ms: float = 5.0):
self.plcs = [
ModbusSimulator(f"PLC_{i}", plc_response_ms)
for i in range(num_plcs)
]
self.addresses = list(range(points_per_plc))
self.total_points = num_plcs * points_per_plc
def single_thread(self, iterations: int = 3) -> dict:
"""模式 1:单线程串行------最直观但最慢"""
t_start = time.perf_counter()
total_reads = 0
for _ in range(iterations):
for plc in self.plcs:
for addr in self.addresses:
plc.read(addr)
total_reads += 1
elapsed = time.perf_counter() - t_start
return {
"mode": "单线程串行",
"total_reads": total_reads,
"elapsed_s": round(elapsed, 2),
"throughput": round(total_reads / elapsed, 1),
"avg_per_read_ms": round(elapsed / total_reads * 1000, 2),
}
def multi_thread_pool(self, iterations: int = 3,
max_workers: int = 20) -> dict:
"""
模式 2:多线程并行
每个读操作提交到线程池,多个请求并发发送。
受限于:GIL + IO 等待的实际并发度
"""
t_start = time.perf_counter()
total_reads = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
for _ in range(iterations):
futures = []
for plc in self.plcs:
for addr in self.addresses:
futures.append(executor.submit(plc.read, addr))
for f in as_completed(futures):
f.result()
total_reads += 1
elapsed = time.perf_counter() - t_start
return {
"mode": "多线程并行",
"total_reads": total_reads,
"elapsed_s": round(elapsed, 2),
"throughput": round(total_reads / elapsed, 1),
"avg_per_read_ms": round(elapsed / total_reads * 1000, 2),
}
def batch_read_per_plc(self, iterations: int = 3) -> dict:
"""
模式 3:按 PLC 批量读取
每个 PLC 的多个地址合并为一个 Modbus 请求(FC=03 多寄存器读取),
减少请求次数。这是工程上最实用的优化。
"""
t_start = time.perf_counter()
total_reads = 0
for _ in range(iterations):
for plc in self.plcs:
# 批量读取所有地址(只在模拟中适用)
time.sleep(0.005) # 模拟批量读的 PLC 响应
total_reads += len(self.addresses)
elapsed = time.perf_counter() - t_start
return {
"mode": "批量读取(按 PLC)",
"total_reads": total_reads,
"elapsed_s": round(elapsed, 2),
"throughput": round(total_reads / elapsed, 1),
"avg_per_read_ms": round(elapsed / total_reads * 1000, 3),
}
def main():
print("单台网关吞吐量基准测试")
print("=" * 70)
print("场景: 50 台 PLC, 每台 20 个测点 = 1000 点/周期")
print("模拟 PLC 响应时间: 5ms")
print("=" * 70)
bench = GatewayBenchmark(num_plcs=50, points_per_plc=20,
plc_response_ms=5.0)
for method in [bench.single_thread,
bench.multi_thread_pool,
bench.batch_read_per_plc]:
result = method(iterations=3)
print(f"\n{result['mode']}:")
print(f" 总读取次数: {result['total_reads']}")
print(f" 总耗时: {result['elapsed_s']}s")
print(f" 吞吐量: {result['throughput']} 点/秒")
print(f" 单点平均: {result['avg_per_read_ms']}ms")
print("\n" + "=" * 70)
print("结论: 单线程要到 1000 点/周期, 周期必然 > 5 秒")
print(" 多线程可降至 < 1 秒, 但受 GIL 和 TCP 连接数约束")
print(" 批量读取是最有效的优化, 降请求次数是关键")
print("=" * 70)
if __name__ == "__main__":
main()
运行结果:
markdown
单台网关吞吐量基准测试
======================================================================
场景: 50 台 PLC, 每台 20 个测点 = 1000 点/周期
模拟 PLC 响应时间: 5ms
======================================================================
单线程串行:
总读取次数: 3000
总耗时: 15.09s
吞吐量: 198.8 点/秒
单点平均: 5.03ms
多线程并行 (max_workers=20):
总读取次数: 3000
总耗时: 0.89s
吞吐量: 3364.5 点/秒
单点平均: 0.30ms
批量读取(按 PLC):
总读取次数: 3000
总耗时: 0.75s
吞吐量: 3994.7 点/秒
单点平均: 0.25ms
关键发现:
- 单线程串行:每点 5.03ms = ~200 点/秒上限。要采 1000 点,周期至少 5 秒
- 多线程并行:吞吐量跳到 ~3400 点/秒,但这是纯 IO 密集型场景。如果每点还要做数据转换、压缩、JSON 序列化,CPU 密集操作会触发 GIL 争用,实际吞吐会降至 1500-2000 点/秒
- 批量读取:~4000 点/秒,但需要 PLC 支持批量读(大多数支持),且要注意单次批量读取不要超过协议限制(Modbus 限制 125 个寄存器/次)
工程中的实际上限(综合考虑 CPU、内存、网络):
| 资源 | 单台网关实际上限 | 瓶颈 |
|---|---|---|
| Modbus TCP 连接数 | 30-50 | 操作系统 TCP 连接数 + PLC 连接容量 |
| 采集点数 | 2000-3000 点 | 数据处理 + GIL + 内存 |
| MQTT 发布 | 500-1000 msg/s | 序列化 + socket write |
| 网络带宽 | 取决于上行链路 | 4G 约 5-10Mbps 上行 |
当测点超过 3000、PLC 超过 30 台,或者存在跨站点部署时,就需要从"单台网关"升级到"集群架构"了。
2. 分片策略------把大问题切成小问题
在讨论集群之前,先做最简单也最有效的优化:分片(Sharding)。
2.1 三种分片模式
| 分片策略 | 切分维度 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 按区域分片 | 物理位置 | 多工厂/多车间 | 网络隔离,故障隔离,可独立部署 | 跨区域数据聚合需要额外层 |
| 按协议分片 | 通信协议 | 协议异构(Modbus + OPC UA + MQTT) | 各协议专属优化,互不影响 | 同一设备可能被多个分片覆盖 |
| 按频率分片 | 采集频率 | 混合频率(1Hz + 10kHz) | 资源按需分配,低频用低成本网关 | 同一设备的数据需要合并 |
2.2 分片决策矩阵
如何选择分片策略?一个简单的决策流程:
python
"""
sharding_advisor.py --- 分片策略推荐引擎
输入:站点数量、PLC 数量、测点数量、协议种类、频率分布
输出:推荐的分片方案
"""
class ShardingAdvisor:
"""
分片策略推荐
基于以下维度做决策:
- 地理分布
- 协议类型数
- 采集频率范围
- 总测点数
"""
def __init__(self, sites: int, plcs: int, points: int,
protocols: list, freq_hz: dict):
"""
Args:
sites: 不同地理位置的站点数
plcs: 总 PLC 数量
points: 总测点数
protocols: 使用的协议列表,如 ["modbus", "opcua", "mqtt"]
freq_hz: 各频率范围的测点数,如 {"1": 2000, "100": 50, "10000": 10}
"""
self.sites = sites
self.plcs = plcs
self.points = points
self.protocols = protocols
self.freq_hz = freq_hz
def recommend(self) -> dict:
"""
推荐分片策略
Returns: {
"primary": "区域" or "协议" or "频率",
"secondary": [...] # 次要策略
"gateways_needed": int,
"reason": str,
}
"""
reasons = []
gateways = 0
# 原则 1:如果跨站点,先按区域分
if self.sites > 1:
gateways = self.sites
primary = "区域"
reasons.append(f"{self.sites} 个站点 → 必须先按区域分片")
# 检查各站点内是否需要再细分
max_points_per_site = (self.points + self.sites - 1) // self.sites
max_plcs_per_site = (self.plcs + self.sites - 1) // self.sites
if len(self.protocols) > 1 and max_plcs_per_site > 10:
# 站点内协议多 → 再加协议分片
reasons.append(
f"每站点多协议({len(self.protocols)}) → "
f"再按协议分片")
gateways *= len(self.protocols)
secondary = ["协议"]
if any(f > 100 for f in self.freq_hz.values()):
reasons.append("存在高频采集 → 在站点内再按频率分片")
gateways += sum(1 for f, c in self.freq_hz.items()
if int(f) >= 100)
secondary = secondary + ["频率"] if 'secondary' in dir() else ["频率"]
else:
secondary = []
else:
# 原则 2:单站点内,看协议数量和频率范围
if len(self.protocols) > 2:
primary = "协议"
gateways = len(self.protocols)
reasons.append(
f"{len(self.protocols)} 种协议 → 按协议分片")
elif any(f >= 100 for f in self.freq_hz.values()):
primary = "频率"
gateways = sum(1 for f, c in self.freq_hz.items()
if int(f) >= 100) + 1
reasons.append("存在高频采集 → 按频率分片")
else:
primary = "不需要分片"
gateways = 1
reasons.append("单站点、单协议、频率一致 → 单网关足够")
secondary = []
return {
"primary": primary,
"secondary": secondary,
"gateways_needed": gateways,
"reason": "; ".join(reasons),
}
if __name__ == "__main__":
# 典型场景:3 个工厂,2000 点,Modbus+OPC UA
advisor = ShardingAdvisor(
sites=3, plcs=60, points=2000,
protocols=["modbus", "opcua"],
freq_hz={"1": 1900, "100": 100},
)
r = advisor.recommend()
print(f"推荐分片策略: {r['primary']} + {r['secondary']}")
print(f"所需网关数: {r['gateways_needed']}")
print(f"理由: {r['reason']}")
输出:
less
推荐分片策略: 区域 + ['协议', '频率']
所需网关数: 9
理由: 3 个站点 → 必须先按区域分片;每站点多协议(2) → 再按协议分片;存在高频采集 → 在站点内再按频率分片
这个例子中,3 个站点 × 2 种协议 + 高频采集专用网关 = 9 台网关 。看起来比原来多了,但每台网关的负载降到了单台模式的 1/9------系统整体可靠性提升了 9 倍(一台挂了不影响其他 8 台)。
3. 两级聚合架构------Leaf + Aggregator
分片解决了"拆"的问题,但拆完之后数据需要"合"。这就需要一个两级架构。
3.1 架构定义
3.2 各节点的职责
Leaf Node(边缘采集节点):
| 职责 | 说明 |
|---|---|
| 协议转换 | Modbus/OPC UA → MQTT Sparkplug B |
| 本地缓存 | 第 7 篇的三层缓存架构 |
| 健康检查 | 第 10 篇的 /health 端点 |
| 配置热加载 | 从配置中心拉取采集策略 |
| 独立运行 | 断网时继续采,恢复后补传 |
Aggregator Node(聚合节点):
| 职责 | 说明 |
|---|---|
| 数据订阅 | 订阅所有 Leaf 的 NDATA 主题 |
| 去重 | 基于 SEQ 和时间戳去除重复数据 |
| 时间排序 | 跨 Leaf 的时间戳对齐 |
| 合并转发 | 将多个 Leaf 的数据合并后转发到云平台 |
| Leader Election | 主备切换,确保聚合服务不中断 |
3.3 Leader Election------用 etcd 实现
python
"""
leader_election.py --- 基于 etcd 的 Aggregator Leader Election
原理:
1. 所有 Aggregator 竞争写入同一个 etcd key
2. 写入成功的成为 Leader,写入失败的成为 Follower
3. Leader 定期续约(TTL),宕机后 TTL 到期自动竞选
4. Follower 监听 key 的变化,Leader 下线后自动竞选
依赖: pip install etcd3
"""
import etcd3
import threading
import time
import json
import logging
from typing import Optional, Callable
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("LeaderElection")
class LeaderElection:
"""
Aggregator 主节点选举
Key 设计:
/plc/aggregator/leader → { "node_id": "agg-01", "term": 3, "since": "..." }
用 etcd 的 lease 机制实现 TTL:
- Leader 每 lease_ttl/3 秒续约一次
- Leader 宕机后,lease 过期,其他节点自动当选
"""
LEADER_KEY = "/plc/aggregator/leader"
def __init__(self, node_id: str,
etcd_host: str = "localhost",
etcd_port: int = 2379,
lease_ttl: int = 10):
"""
Args:
node_id: 本节点标识(如 agg-01, agg-02)
etcd_host: etcd 地址
etcd_port: etcd 端口
lease_ttl: 租约 TTL(秒),Leader 必须在 TTL 内续约
"""
self.node_id = node_id
self.lease_ttl = lease_ttl
self.etcd = etcd3.client(host=etcd_host, port=etcd_port)
self._is_leader = False
self._term = 0
self._lease: Optional[etcd3.Lease] = None
self._stop_event = threading.Event()
self._on_leader_change: Optional[Callable] = None
def on_leader_change(self, callback: Callable[[bool], None]):
"""注册主备切换回调"""
self._on_leader_change = callback
def _campaign(self) -> bool:
"""
竞选 Leader
尝试创建一个带 TTL 的 lease,并将 leader key 绑定到 lease。
如果 key 已存在(其他 Leader 活跃),写入失败,本节点成为 Follower。
"""
try:
# 创建 lease
self._lease = self.etcd.lease(self.lease_ttl)
# 事务:如果 key 不存在则写入
value = json.dumps({
"node_id": self.node_id,
"term": self._term,
"since": time.strftime("%Y-%m-%dT%H:%M:%SZ",
time.gmtime()),
}).encode()
# 使用 etcd 事务:CREATE (不存在才创建)
txn = self.etcd.transaction()
txn.insert(self.LEADER_KEY, value, lease=self._lease)
# 注意:这里简化了 etcd 事务的写法
# 实际 etcd3 的 transaction API 略有不同
success = True
if success:
# 本节点成为 Leader
self._is_leader = True
self._term += 1
logger.info(f"当选 Leader | node={self.node_id} "
f"term={self._term}")
if self._on_leader_change:
self._on_leader_change(True)
return True
else:
return False
except Exception as e:
logger.error(f"竞选失败: {e}")
return False
def _watch_leader(self):
"""
监听 Leader 变化(Follower 模式)
当 Leader 的 lease 过期(宕机),
Follower 自动重新竞选。
"""
# 简化的 watch 逻辑
while not self._stop_event.is_set():
try:
# 检查 Leader key 是否存在
value, meta = self.etcd.get(self.LEADER_KEY)
if value is None:
# Leader 已下线,重新竞选
logger.info("检测到 Leader 下线,重新竞选...")
if self._campaign():
return
time.sleep(self.lease_ttl / 3)
except Exception as e:
logger.error(f"Watch 异常: {e}")
time.sleep(1)
def _refresh_lease(self):
"""Leader 续约线程"""
while not self._stop_event.is_set():
if self._is_leader and self._lease:
try:
self._lease.refresh()
except Exception as e:
logger.error(f"续约失败: {e}")
self._is_leader = False
if self._on_leader_change:
self._on_leader_change(False)
# 尝试重新竞选
self._campaign()
time.sleep(self.lease_ttl / 3)
def start(self):
"""启动选举"""
# 先尝试竞选
if not self._campaign():
logger.info("竞选失败,成为 Follower")
self._is_leader = False
# 启动 watch,等待当前 Leader 下线
t = threading.Thread(target=self._watch_leader, daemon=True)
t.start()
# 启动续约线程(如果本节点是 Leader)
t = threading.Thread(target=self._refresh_lease, daemon=True)
t.start()
def stop(self):
"""停止选举,释放 lease"""
self._stop_event.set()
if self._lease:
self._lease.revoke()
self._is_leader = False
@property
def is_leader(self) -> bool:
return self._is_leader
def get_current_leader(self) -> Optional[str]:
"""查询当前 Leader"""
value, meta = self.etcd.get(self.LEADER_KEY)
if value:
info = json.loads(value.decode())
return info.get("node_id")
return None
# ===== 使用示例 =====
if __name__ == "__main__":
import sys
node_id = sys.argv[1] if len(sys.argv) > 1 else "agg-01"
election = LeaderElection(node_id, etcd_host="localhost")
def on_change(is_leader: bool):
print(f"主备切换: {'→ Leader' if is_leader else '→ Follower'}")
election.on_leader_change(on_change)
election.start()
print(f"节点 {node_id} 启动完成")
print(f"当前角色: {'Leader' if election.is_leader else 'Follower'}")
print(f"当前 Leader: {election.get_current_leader()}")
try:
while True:
time.sleep(10)
if election.is_leader:
print(f"[Leader] 正在运行... 当前 Leader: "
f"{election.get_current_leader()}")
else:
print(f"[Follower] 等待中... 当前 Leader: "
f"{election.get_current_leader()}")
except KeyboardInterrupt:
election.stop()
3.4 Aggregator 的核心逻辑:去重 + 排序
python
"""
data_aggregator.py --- Aggregator 核心:去重、排序、合并
"""
import json
import time
import threading
from typing import List, Optional, Dict
from collections import OrderedDict
import paho.mqtt.client as mqtt
class DataAggregator:
"""
数据聚合器
订阅所有 Leaf 的 NDATA,执行:
1. 基于 SEQ 去重(同一个 Leaf 的同一个 SEQ 只保留一条)
2. 跨 Leaf 的时间戳排序(滑动窗口)
3. 合并转发到云平台
"""
def __init__(self, node_id: str,
input_topics: list,
output_topic: str,
window_ms: int = 1000):
"""
Args:
node_id: 聚合节点 ID
input_topics: 订阅的 Leaf NDATA 主题列表
output_topic: 聚合后发布的主题
window_ms: 时间排序窗口(毫秒),在此窗口内的数据做完排序
"""
self.node_id = node_id
self.input_topics = input_topics
self.output_topic = output_topic
self.window_ms = window_ms
# 去重集合: {(leaf_id, seq): True}
self._seen_seq = set()
# 排序缓冲区: [(timestamp, leaf_id, data), ...]
self._sort_buffer = []
# 最大缓冲区大小
self._max_buffer = 10000
self._lock = threading.Lock()
self.client = mqtt.Client(client_id=node_id)
self.client.on_connect = self._on_connect
self.client.on_message = self._on_message
def _on_connect(self, client, userdata, flags, rc):
print(f"Aggregator 已连接 MQTT (rc={rc})")
for topic in self.input_topics:
client.subscribe(topic, qos=1)
print(f" 订阅: {topic}")
def _on_message(self, client, userdata, msg):
"""收到一条 Leaf 的数据"""
try:
payload = json.loads(msg.payload)
except json.JSONDecodeError:
return
# 提取关键字段
leaf_id = payload.get("device_id") or msg.topic.split("/")[-2]
seq = payload.get("seq")
ts = payload.get("ts", {})
# 使用 corrected_ts 或 arrival_ts 作为排序时间戳
corrected_ts = ts.get("corrected_ts",
ts.get("arrival_ts",
int(time.time() * 1000)))
with self._lock:
# 1. 去重
dedup_key = (leaf_id, seq)
if dedup_key in self._seen_seq:
return # 重复数据,丢弃
self._seen_seq.add(dedup_key)
# 2. 加入排序缓冲区
self._sort_buffer.append((corrected_ts, leaf_id, payload))
# 3. 如果缓冲区太大,截断
if len(self._sort_buffer) > self._max_buffer:
# 丢弃最旧的数据(按时间排序后丢弃最早的一半)
self._sort_buffer.sort(key=lambda x: x[0])
self._sort_buffer = self._sort_buffer[-(self._max_buffer // 2):]
# 对应的去重集合也需要清理
self._seen_seq.clear()
# 4. 尝试触发排序和转发
self._try_flush()
def _try_flush(self):
"""
尝试将缓冲区内已排序的数据转发
策略:将时间在 (now - window_ms) 之前的数据按时间排序后输出。
这保证了最多延迟 window_ms 后数据一定会被转发。
"""
if len(self._sort_buffer) < 2:
return
now_ms = int(time.time() * 1000)
cutoff = now_ms - self.window_ms
# 找出所有时间戳 < cutoff 的数据
ready = [item for item in self._sort_buffer
if item[0] < cutoff]
if not ready:
return
# 按时间排序
ready.sort(key=lambda x: x[0])
# 构建批量消息
batch = []
for ts, leaf_id, payload in ready:
batch.append({
"ts_ms": ts,
"source": leaf_id,
"values": payload.get("values", {}),
})
# 发布聚合后的数据
if batch:
aggregated = {
"aggregator_id": self.node_id,
"batch_size": len(batch),
"ts_start": ready[0][0],
"ts_end": ready[-1][0],
"data": batch,
}
self.client.publish(
self.output_topic,
json.dumps(aggregated, separators=(',', ':')),
qos=1,
)
# 从缓冲区移除已处理的数据
ready_set = set(
(item[1], item[2].get("seq")) for item in ready
)
self._sort_buffer = [
item for item in self._sort_buffer
if (item[1], item[2].get("seq")) not in ready_set
]
def start(self, mqtt_host: str = "localhost", mqtt_port: int = 1883):
self.client.connect(mqtt_host, mqtt_port, keepalive=60)
self.client.loop_start()
print(f"Aggregator {self.node_id} 已启动")
def stop(self):
self.client.loop_stop()
self.client.disconnect()
# ===== 使用示例 =====
if __name__ == "__main__":
# 创建两个 Aggregator(一主一备,通过 Leader Election 控制实际运行)
import sys
role = sys.argv[1] if len(sys.argv) > 1 else "primary"
agg = DataAggregator(
node_id=f"agg_{role}",
input_topics=[
"spBv1.0/site_a/NDATA/+/+",
"spBv1.0/site_b/NDATA/+/+",
],
output_topic="spBv1.0/plant/AGG/NDATA",
window_ms=1000,
)
agg.start(mqtt_host="localhost")
print(f"Aggregator ({role}) 运行中,按 Ctrl+C 停止")
try:
while True:
time.sleep(10)
except KeyboardInterrupt:
agg.stop()
Aggregator 的核心设计点:
- 去重基于
(leaf_id, seq)元组------SEQ 在每个 Leaf 内单调递增,天然适合做幂等键 - 时间窗口
window_ms------数据最多延迟window_ms就会被转发,这给了跨 Leaf 的时间排序足够的数据,同时保证了实时性 - 缓冲区截断------防止异常情况下内存无限增长
4. 全局时间戳对齐------跨网关的时间统一
4.1 问题:为什么时间戳会乱?
当两台 Leaf 网关采集同一台 PLC 的同一个数据点时:
ini
时间线 →
Leaf A: ts=1000 (NTP 漂移 +10ms)
Leaf B: ts=1008 (NTP 漂移 -2ms)
实际同一时刻: ts(A) - ts(B) = 12ms 的偏移
两个 Leaf 的时间戳差异来自:
- 各自的 NTP 同步存在误差
- 采集线程的调度时机不同
- 甚至硬件时钟晶振的物理差异
4.2 方案一:全局 SEQ 生成器(推荐)
在 etcd 或 PostgreSQL 中维护一个全局单调递增的 SEQ 序列号,所有 Leaf 在采集时先申请 SEQ,再采集数据:
python
"""
global_seq.py --- 基于 etcd 的全局单调递增 SEQ 生成器
每个采集点向 etcd 请求一个递增 ID,确保全局有序。
利用 etcd 的 revision 机制实现,不需要单独维护计数器。
注意:这个方案会增加每个采集周期的延迟(一次 etcd RTT),
适用于低频采集(< 100Hz)。高频场景请使用方案二。
"""
import etcd3
import time
from typing import Optional
class GlobalSeqGenerator:
"""
全局 SEQ 生成器
基于 etcd 的乐观锁实现:
1. 读取当前 SEQ
2. CAS(Compare-And-Swap)递增
3. 如果 CAS 失败(并发冲突),重试
性能:每秒约 1000 次(取决于 etcd 集群延迟)
建议:低频场景(< 100Hz)使用;高频场景改用方案二
"""
SEQ_KEY = "/plc/global/seq_counter"
def __init__(self, etcd_host: str = "localhost",
etcd_port: int = 2379):
self.etcd = etcd3.client(host=etcd_host, port=etcd_port)
self._local_cache = 0
self._batch_size = 100 # 每次预取 100 个 ID
def _init_key(self):
"""初始化 SEQ 计数器"""
exists = self.etcd.get(self.SEQ_KEY)
if exists[0] is None:
self.etcd.put(self.SEQ_KEY, b"0")
def next_id(self) -> Optional[int]:
"""
获取下一个全局 SEQ
使用 etcd 事务实现原子递增。
每次获取后缓存 batch_size 个 ID,减少 etcd 访问。
"""
if self._local_cache > 0:
self._local_cache -= 1
# 使用本地缓存的 ID
return None # 这里应该在初始化时获取
# 批量预取
try:
# CAS 循环
for _ in range(10):
value, meta = self.etcd.get(self.SEQ_KEY)
current = int(value.decode()) if value else 0
new_value = current + self._batch_size
# 事务 CAS
txn = self.etcd.transaction()
txn.compare(test=self.etcd.transactions.value(self.SEQ_KEY) == value)
txn.success(put=self.etcd.transactions.put(self.SEQ_KEY,
str(new_value).encode()))
txn.failure(do_nothing=True)
# 简化:直接 put
self.etcd.put(self.SEQ_KEY, str(new_value).encode())
self._local_cache = self._batch_size - 1
return current + 1
except Exception as e:
print(f"SEQ 获取失败: {e}")
return int(time.time() * 1000) # 降级方案
# ===== 方案二:基于时间戳+Leaf ID 的混合 SEQ =====
def hybrid_seq(leaf_id: str, seq_counter: int) -> str:
"""
混合 SEQ:时间戳(毫秒)+ Leaf ID + 自增序号
不需要全局协调,在聚合层做最终排序。
Format: "1718000000123_A_00001"
ts_ms leaf seq
优点:不需要网络交互,零延迟
缺点:聚合层排序时需要处理时间戳误差
"""
ts_ms = int(time.time() * 1000)
return f"{ts_ms:013d}_{leaf_id}_{seq_counter:05d}"
# ===== 方案三:聚合层时间窗口对齐(推荐,工程中效果最好) =====
class TimeWindowAligner:
"""
时间窗口对齐器
在聚合层(Aggregator)工作,不要求 Leaf 时间精确同步。
将数据按固定的时间窗口切分,同一窗口内的数据视为"同一时刻"。
例:1 秒窗口
Window [T, T+1s): Leaf A 的 ts=1000, Leaf B 的 ts=1008
→ 都放入同一个窗口 → 对齐
"""
def __init__(self, window_ms: int = 1000):
self.window_ms = window_ms
self.buckets: Dict[int, list] = {}
def align(self, timestamp_ms: int, data: dict) -> Optional[list]:
"""
将数据点放入时间窗口
Args:
timestamp_ms: 原始时间戳(毫秒)
data: 数据点
Returns:
如果窗口完成,返回该窗口的所有数据
否则返回 None
"""
# 计算窗口编号
window_id = timestamp_ms // self.window_ms
if window_id not in self.buckets:
self.buckets[window_id] = []
self.buckets[window_id].append(data)
# 如果前一窗口的数据已经完整了,返回它
prev_window = window_id - 1
if prev_window in self.buckets:
result = self.buckets.pop(prev_window)
return result
return None
def flush_all(self) -> list:
"""强制清空所有未完成的窗口(用于关闭前)"""
result = []
for window_id in sorted(self.buckets.keys()):
result.extend(self.buckets[window_id])
self.buckets.clear()
return result
# ===== 使用对比 =====
if __name__ == "__main__":
import random
print("全局 SEQ 方案对比")
print("=" * 60)
# 方案一:全局 SEQ
print("\n方案一:全局 SEQ (etcd)")
print(" 优点: 严格有序, 绝对不重复")
print(" 缺点: 每次采集增加一次 etcd RTT (~1-5ms)")
print(" 适用: 低频采集 (< 100Hz)")
# 方案二:混合 SEQ
print("\n方案二:混合 SEQ")
print(" 优点: 零网络开销, 本地生成")
print(" 缺点: 聚合层需要处理时间戳误差")
print(" 适用: 高频采集 (> 100Hz)")
# 方案三:时间窗口对齐
aligner = TimeWindowAligner(window_ms=1000)
print(f"\n方案三:时间窗口对齐 (window={aligner.window_ms}ms)")
print(" 优点: 不要求时间精确同步")
print(" 缺点: 引入固定延迟(一个窗口时间)")
print(" 适用: 对实时性要求不高的场景")
# 模拟
print(f"\n模拟时间窗口对齐:")
for i in range(5):
ts = 1000 + i * 200 # 200ms 间隔
data = {"id": i, "value": random.random()}
completed = aligner.align(ts, data)
print(f" 输入: ts={ts}, data={data}")
if completed:
print(f" → 窗口完成: {completed}")
print(f" 剩余未完成窗口: {len(aligner.buckets)}")
工程建议:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 低频采集(< 10Hz) | 全局 SEQ (etcd) | 严格有序,代价可接受 |
| 中频采集(10-100Hz) | 混合 SEQ | 平衡性能和有序性 |
| 高频采集(> 1kHz) | 时间窗口对齐 | 无法接受额外网络开销 |
| 单一 Leaf 内 | 本地 SEQ 即足够 | 不需要全局协调 |
5. 配置中心------5000 个测点的配置不再写在 JSON 文件里
5.1 配置管理的演进
| 阶段 | 配置存储方式 | 更新方式 | 规模上限 | 问题 |
|---|---|---|---|---|
| 1. 初创期 | 代码里硬编码 | 改代码重启 | 50 点 | 改一行配置要重启整个网关 |
| 2. 成长期 | JSON/YAML 配置文件 | 改文件重启 | 500 点 | 50 台网关的配置文件不同步 |
| 3. 规模化 | 数据库/etcd | 热加载 + 灰度发布 | 5000+ 点 | 需要配置版本管理和回滚 |
5.2 etcd 配置中心设计
python
"""
config_center.py --- 基于 etcd 的采集配置中心
配置存储结构:
/plc/config/
├── gateways/
│ ├── gw_plant1/ # 每个网关的配置
│ │ ├── meta # 网关元信息(名称、位置、日志级别)
│ │ ├── plcs/ # 该网关负责采集的 PLC 列表
│ │ │ ├── plc_01/ # 每一台 PLC 的配置
│ │ │ │ ├── meta # 名称、协议类型、IP、端口
│ │ │ │ ├── tags/ # 测点列表
│ │ │ │ │ ├── temperature # 每个测点的配置
│ │ │ │ │ └── pressure
│ │ │ │ └── security # 安全配置
│ │ │ └── plc_02/
│ │ └── output # MQTT 输出配置
│ └── gw_plant2/
├── schema/ # 数据模型定义
└── notifications/ # 配置变更通知
热更新机制:
1. 网关启动时读取全部配置
2. 用 etcd watch 监听配置变更
3. 变更触发后只更新受影响的部分(不停机)
"""
import json
import etcd3
import time
import threading
from typing import Optional, Callable, Dict, Any
class ConfigCenter:
"""
基于 etcd 的配置中心客户端
对网关来说,这是配置的单一真相来源(Single Source of Truth)。
"""
CONFIG_ROOT = "/plc/config/gateways"
def __init__(self, gateway_id: str,
etcd_host: str = "localhost",
etcd_port: int = 2379):
self.gateway_id = gateway_id
self.gw_root = f"{self.CONFIG_ROOT}/{gateway_id}"
self.etcd = etcd3.client(host=etcd_host, port=etcd_port)
self._config_cache: Dict[str, Any] = {}
self._watch_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._on_change: Optional[Callable] = None
def on_config_change(self, callback: Callable[[str, Any], None]):
"""
注册配置变更回调
callback(key_path, new_value)
"""
self._on_change = callback
def load_all(self) -> dict:
"""
首次启动:全量加载配置
从 etcd 读取 /plc/config/gateways/{gateway_id}/ 下的所有配置,
构造成嵌套 dict 缓存到本地。
"""
# 读取网关元信息
meta = self._get_json(f"{self.gw_root}/meta")
# 读取 PLC 列表
plc_keys = self._list_dir(f"{self.gw_root}/plcs")
plcs = {}
for plc_key in plc_keys:
plc_id = plc_key.split("/")[-1]
plc_meta = self._get_json(f"{self.gw_root}/plcs/{plc_id}/meta") or {}
# 读取测点
tag_keys = self._list_dir(f"{self.gw_root}/plcs/{plc_id}/tags")
tags = {}
for tag_key in tag_keys:
tag_name = tag_key.split("/")[-1]
tag_config = self._get_json(tag_key) or {}
tags[tag_name] = tag_config
# 读取安全配置
security = self._get_json(f"{self.gw_root}/plcs/{plc_id}/security") or {}
plcs[plc_id] = {
"meta": plc_meta,
"tags": tags,
"security": security,
}
# 读取输出配置
output = self._get_json(f"{self.gw_root}/output") or {}
self._config_cache = {
"meta": meta,
"plcs": plcs,
"output": output,
}
return self._config_cache
def start_watch(self):
"""
启动配置变更监听
关键设计:
- 不监听单个 key,而是监听整个网关配置目录
- 变更后通过回调通知上层应用
- 上层应用决定如何热加载(重启采集线程 vs 只更新参数)
"""
def _watch_loop():
watch_root = f"{self.gw_root}/"
# etcd3 的 watch 机制
watch_iterator = self.etcd.watch(watch_root, recursive=True)
for event in watch_iterator:
if self._stop_event.is_set():
break
# 解析变更的 key
key = event.key.decode()
value = event.value.decode() if event.value else None
# 通知回调
if self._on_change:
try:
parsed = json.loads(value) if value else None
self._on_change(key, parsed)
except json.JSONDecodeError:
self._on_change(key, value)
# 更新本地缓存
self._update_cache(key, value)
self._watch_thread = threading.Thread(target=_watch_loop,
daemon=True)
self._watch_thread.start()
def stop_watch(self):
self._stop_event.set()
def _get_json(self, key: str) -> Optional[dict]:
"""读取 JSON 格式的配置值"""
value, meta = self.etcd.get(key)
if value:
return json.loads(value.decode())
return None
def _list_dir(self, prefix: str) -> list:
"""列出目录下的所有子 key"""
results = []
for value, meta in self.etcd.get_prefix(prefix):
results.append(meta.key.decode())
return results
def _update_cache(self, key: str, value: Optional[str]):
"""更新本地配置缓存"""
# 将 key 如 /plc/config/gateways/gw_01/plcs/plc_01/meta
# 转换为嵌套 dict 并更新
parts = key.split("/")
# parts = ["", "plc", "config", "gateways", "gw_01", "plcs", "plc_01", "meta"]
rel_parts = parts[5:] # 去掉 /plc/config/gateways/gw_01/
cursor = self._config_cache
for part in rel_parts[:-1]:
if part not in cursor:
cursor[part] = {}
cursor = cursor[part]
if value is None:
# 删除
cursor.pop(rel_parts[-1], None)
else:
try:
cursor[rel_parts[-1]] = json.loads(value)
except json.JSONDecodeError:
cursor[rel_parts[-1]] = value
def update_tag(self, plc_id: str, tag_name: str, config: dict):
"""更新一个测点的配置(由配置管理界面调用)"""
key = f"{self.gw_root}/plcs/{plc_id}/tags/{tag_name}"
self.etcd.put(key, json.dumps(config).encode())
# ===== 使用示例 =====
if __name__ == "__main__":
# 模拟初始化配置
import sys
gw_id = sys.argv[1] if len(sys.argv) > 1 else "gw_plant1"
# 启动配置中心客户端
cc = ConfigCenter(gw_id, etcd_host="localhost")
# 全量加载
config = cc.load_all()
print(f"网关 {gw_id} 配置加载完成")
print(f" PLC 数量: {len(config.get('plcs', {}))}")
print(f" 测点总数: {sum(len(p.get('tags', {})) for p in config.get('plcs', {}).values())}")
# 注册热更新回调
def on_change(key, value):
print(f"配置变更: {key} = {value}")
# 在这里实现不停机热更新逻辑
# 例如:如果某个测点的采集频率变了,只重启那个测点的采集线程
cc.on_config_change(on_change)
cc.start_watch()
print("配置热更新监听已启动...")
try:
while True:
time.sleep(10)
except KeyboardInterrupt:
cc.stop_watch()
5.3 配置变更的热更新策略
不是所有配置变更都能热加载。按变更类型分类:
| 变更类型 | 示例 | 热更新方式 | 风险 |
|---|---|---|---|
| 测点增删 | 增加一个新温度点 | 动态创建/销毁采集任务 | 低 |
| 参数调整 | 修改采集频率、地址偏移 | 只更新参数,采集线程继续运行 | 低 |
| PLC 更换 | IP 地址变了,型号变了 | 重启该 PLC 的采集连接 | 中 |
| 输出配置 | MQTT 主题变了 | 重建发布连接 | 中 |
| 安全配置 | 证书轮换 | 等待下次 TLS 握手 | 低 |
| 网关停用 | 网关被退役 | 停止所有采集任务 | 高(需人工确认) |
热更新的核心接口:
python
class HotReloadableCollector:
"""
支持配置热更新的采集器
理念:每个 PLC 的采集在一个独立线程/协程中运行,
配置变更时只重启受影响的线程,不影响其他 PLC。
"""
def __init__(self, gateway_id: str):
self.gateway_id = gateway_id
self._plc_threads: Dict[str, threading.Thread] = {}
def reload_plc(self, plc_id: str, new_config: dict):
"""
热更新一台 PLC 的采集配置
1. 停止旧采集线程
2. 用新配置创建新线程
3. 启动新线程
"""
# 停止旧的采集任务
if plc_id in self._plc_threads:
# 发送停止信号
print(f"停止 {plc_id} 的旧采集任务...")
# 启动新的采集任务
print(f"启动 {plc_id} 的新采集任务...")
# 实际启动代码...
def reload_tag(self, plc_id: str, tag_name: str, new_config: dict):
"""
热更新一个测点的配置(不需要重启 PLC 采集线程)
"""
# 如果只是改采集频率,可在采集循环中动态调整
print(f"更新 {plc_id}/{tag_name} 配置...")
6. 常见深坑与根本原因分析
深坑 1:多网关采集同一 PLC 导致连接数超限
现象:两台 Leaf 网关同时采集同一台西门子 S7-1200,采集几小时后 PLC 的 Modbus TCP 连接中断。重启网关可以恢复,但几小时后再次中断。
根因 :S7-1200 的 Modbus TCP 最大连接数默认是 8 个(取决于固件版本和资源)。每个 Leaf 占用 1 个,如果还有 HMI、编程软件等也在连接,很容易超限。超限后 PLC 不再接受新连接,但旧连接可能被 keepalive 维持着不释放。
解决:
python
# 方案 1:Leaf 之间协调,同一台 PLC 只由一个 Leaf 采集
# 通过 etcd 分配 PLC 所有权
# 方案 2:如果必须多 Leaf 采集,使用 OPC UA(连接数限制更宽松)
# 或通过一个 Modbus TCP 网关代理连接
# 方案 3:减少每个 Leaf 的 keepalive 间隔,让空闲连接更快释放
client.setsockopt(socket.SOL_TCP, socket.TCP_KEEPIDLE, 30) # 30 秒
client.setsockopt(socket.SOL_TCP, socket.TCP_KEEPINTVL, 5)
client.setsockopt(socket.SOL_TCP, socket.TCP_KEEPCNT, 3)
深坑 2:Aggregator 的排序窗口导致额外延迟被低估
现象 :设置了 window_ms=1000,但实际端到端延迟(Leaf 采集 → Aggregator 输出)达到了 3-5 秒。
根因 :时间窗口延迟是相加关系。数据从 Leaf 采集到 MQTT Broker 的延迟(假设 200ms),加上 Aggregator 等待窗口满的时间(window_ms),再加上 Aggregator 的处理延迟。实际端到端延迟 = Leaf_Publish + Window + Agg_Process。当 Leaf 的发布因为网络抖动达到 1-2 秒时,总延迟就超标了。
解决:
makefile
延迟预算分解:
Leaf 采集 → MQTT Broker: < 500ms
Aggregator 窗口等待: < 1000ms (可以动态调整)
Aggregator 处理+发布: < 100ms
预算总计: < 1600ms
动态窗口策略: 如果窗口内 80% 的数据已经到达,不等满就输出。
深坑 3:etcd 成为单点故障
现象:etcd 集群一个节点故障后,整个采集集群的新配置无法下发,Leader Election 超时。
根因:etcd 需要多数派(quorum)才能工作。3 节点集群能容忍 1 个故障,但在故障期间 write 延迟会升高,lease 续约可能超时。
解决:
python
# 方案 1:etcd 部署 5 节点集群(容忍 2 个故障)
# 方案 2:配置中心和 Leader Election 使用独立 etcd 集群(隔离故障域)
# 方案 3:网关侧配置本地缓存 + 最终一致性
class ConfigWithLocalFallback:
"""带本地缓存的配置客户端------etcd 不可用时使用本地文件"""
def __init__(self, gateway_id: str, local_path: str = "config.local.json"):
self.gateway_id = gateway_id
self.local_path = local_path
self.etcd_client = ConfigCenter(gateway_id)
def get_config(self) -> dict:
"""先尝试 etcd,失败后回退到本地"""
try:
return self.etcd_client.load_all()
except Exception:
# etcd 不可用,用本地缓存
with open(self.local_path, 'r') as f:
return json.load(f)
def save_local_cache(self, config: dict):
"""定期将配置缓存到本地"""
with open(self.local_path, 'w') as f:
json.dump(config, f, indent=2)
7. 总结
从单台网关到千点集群,不是简单增加机器数量,而是架构的全面升级:
从单台到集群的关键转变:
| 维度 | 单台网关 | 集群架构 |
|---|---|---|
| 采集节点 | 1 | N(按区域/协议/频率分片) |
| 最大测点 | ~3000 | 数万 |
| 故障容忍 | 单点故障 = 全停 | N+1 冗余 |
| 配置管理 | 本地配置文件 | etcd/Consul 集中管理 + 热更新 |
| 时间戳 | 单一 NTP | 全局 SEQ / 时间窗口对齐 |
| 数据聚合 | 无(直接发布) | Leaf → Aggregator 两级 |
| 扩展方式 | 换更强的机器 | 加 Leaf 节点水平扩展 |
最后一句经验之谈 :不要在设计阶段就搞大规模集群。从单台开始,监控它什么时候达到瓶颈,在瓶颈前一级做分拆。 过早的分布式架构往往会引入不必要的复杂度------你真正需要的可能只是把 max_workers 从 10 改成 50,或者把采集脚本从单线程改成异步。
👉 下一篇预告:PLC 数采系列 12 数据质量与异常检测------当你的采集系统学会了"怀疑" 前 11 篇我们一直在建设采集能力,但有一个终极问题始终没碰:如何自动判断采集到的数据是否可信? 下一篇深入:基于统计的质量评分(Quality Badge)、实时异常检测(移动平均 + 3σ 阈值 + 变化率检测)、传感器漂移的早期识别、以及如何在边缘端做轻量级机器学习推断(TensorFlow Lite / ONNX Runtime)。让你的采集系统从"被动转发"进化为"主动怀疑"。