首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >高并发采集:同时轮询数百台 POE 供电的以太网温湿度变送器(Modbus TCP/IP)

高并发采集:同时轮询数百台 POE 供电的以太网温湿度变送器(Modbus TCP/IP)

原创
作者头像
盛世宏博小可
发布2026-09-22 15:24:27
发布2026-09-22 15:24:27
170
举报

高并发采集:同时轮询数百台 POE 供电的以太网温湿度变送器(Modbus TCP/IP)

物联网 #Modbus #TCP/IP #UDP #POE供电 #腾讯云 #Wireshark #Python #InfluxDB #以太网温湿度传感器 #网口温湿度变送器 #机房监控

前面把双供电切换、Wireshark 排障、PoE 功率分配、浪涌防护、EMC 电磁兼容、多协议固件开发全部讲透了。这一篇回到采集端——当你面对的不是几台、几十台,而是数百台以太网温湿度变送器分布在多个库房、多个楼层、多个园区,每台都在跑 Modbus TCP,你该怎么写采集程序?

先说一个很多人踩过的坑:

用同步 pymodbus 写了一个 for 循环,依次轮询 200 台设备,每台超时 3 秒。 跑起来发现:一轮轮询下来要 10 分钟,数据还没入库,下一轮又开始了。 改成多线程,ThreadPoolExecutor(200),结果:200 个线程同时建 TCP 连接,交换机 MAC 表被打爆,一半设备 TCP 握手超时,日志里全是 ConnectionResetError。 再改成 asyncio,以为万事大吉——结果 pymodbus 的异步客户端在 3.x 版本里连接管理有坑,并发一高就报 ModbusIOException,还不容易复现。


一、为什么 asyncio 是高并发采集的正确选择

1. 三种并发模型对比

模型

并发方式

连接数上限

上下文切换

适用场景

同步串行

单线程,逐个轮询

1

设备 < 10 台

多线程

每个设备一个线程

~200(OS 限制)

高(内核调度)

设备 < 100 台,但线程切换开销大

多进程

每个进程独立

CPU 核数 × N

极高

不推荐用于 I/O 密集

asyncio​

单线程,事件循环,协作式调度

数千​

极低(用户态)

设备数百~数千台​

核心优势:asyncio 是单线程的,不存在线程切换开销,不存在 GIL 争抢,不存在锁竞争。所有 I/O 操作(TCP 读写)在等待时让出控制权,事件循环调度其他任务。一台设备等待响应的 50ms 里,可以切换去处理另外几十台设备的请求。

2. 但 pymodbus 的 asyncio 支持有坑

pymodbus 版本

asyncio 支持

问题

2.x

有 AsyncModbusTcpClient

较稳定,但已停止维护

3.0 – 3.3

重构了 asyncio 实现

连接池、重连逻辑有 bug

3.4+

逐步修复

需要仔细验证

本文基于 pymodbus 3.6+,并给出绕过坑点的写法。


二、架构设计:数百台设备的采集拓扑

1. 逻辑分层

代码语言:javascript
复制
┌─────────────────────────────────────────────────────────────┐
│                    采集服务(单进程 asyncio)                  │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              设备管理器(DeviceManager)               │    │
│  │  · 维护设备列表(IP、端口、从站地址、采集周期)        │    │
│  │  · 按区域/库房分组                                    │    │
│  │  · 健康状态跟踪(在线/离线/响应时间)                  │    │
│  └───────────────────┬─────────────────────────────────┘    │
│                      │                                      │
│  ┌───────────────────▼─────────────────────────────────┐    │
│  │              连接池(ConnectionPool)                   │    │
│  │  · 每台设备一个持久 TCP 连接(或按需创建)              │    │
│  │  · 连接健康检测(心跳/超时)                           │    │
│  │  · 自动重连(指数退避)                               │    │
│  │  · 并发限制(Semaphore 控制同时活跃请求数)             │    │
│  └───────────────────┬─────────────────────────────────┘    │
│                      │                                      │
│  ┌───────────────────▼─────────────────────────────────┐    │
│  │              采集调度器(Scheduler)                    │    │
│  │  · 按设备采集周期调度(非阻塞)                        │    │
│  │  · 错峰采集(避免同时发起所有请求)                     │    │
│  │  · 优先级(关键库房优先)                              │    │
│  └───────────────────┬─────────────────────────────────┘    │
│                      │                                      │
│  ┌───────────────────▼─────────────────────────────────┐    │
│  │              数据管道(DataPipeline)                   │    │
│  │  · 质量位标记(good/bad/timeout)                     │    │
│  │  · 变化过滤(deadband)                               │    │
│  │  · 批量写入 InfluxDB / 转发 Kafka                     │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

2. 关键设计决策

决策点

选择

理由

连接模型

每设备一个长连接

Modbus TCP 无会话开销,长连接避免握手延迟

并发度

Semaphore 限制 ~100 并发请求

避免交换机/设备 TCP 栈过载

采集调度

错峰 + 随机抖动

避免 200 台同时发起请求

超时处理

单次超时 2s,连续 3 次失败标记离线

快速失败,不阻塞其他设备

重连策略

指数退避 1s/2s/4s/8s,上限 60s

避免重连风暴

数据写入

批量异步写入 InfluxDB

减少 I/O 次数


三、核心代码实现

1. 连接池(带健康检测)

代码语言:javascript
复制
import asyncio
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ModbusIOException, ConnectionException

@dataclass
class DeviceConfig:
    host: str
    port: int = 502
    slave_id: int = 1
    poll_interval: float = 5.0
    timeout: float = 2.0
    retry_limit: int = 3

@dataclass
class DeviceState:
    client: Optional[AsyncModbusTcpClient] = None
    connected: bool = False
    last_poll: float = 0.0
    last_success: float = 0.0
    consecutive_failures: int = 0
    response_times: list = field(default_factory=list)
    quality: str = "unknown"

class ConnectionPool:
    def __init__(self, max_concurrent: int = 100):
        self.devices: Dict[str, DeviceState] = {}
        self.configs: Dict[str, DeviceConfig] = {}
        self.semaphore = asyncio.Semaphore(max_concurrent)
        self._lock = asyncio.Lock()

    def add_device(self, name: str, config: DeviceConfig):
        self.configs[name] = config
        self.devices[name] = DeviceState()

    async def get_client(self, name: str) -> Optional[AsyncModbusTcpClient]:
        """获取或创建设备的 Modbus TCP 客户端"""
        state = self.devices[name]
        config = self.configs[name]

        if state.client is not None and state.connected:
            return state.client

        # 需要新建连接
        async with self._lock:
            # 双重检查
            if state.client is not None and state.connected:
                return state.client

            # 关闭旧连接
            if state.client is not None:
                try:
                    state.client.close()
                except Exception:
                    pass

            state.client = AsyncModbusTcpClient(
                config.host,
                port=config.port,
                timeout=config.timeout,
                retries=1,
                retry_on_empty=True,
            )

            try:
                await state.client.connect()
                state.connected = state.client.connected
                if state.connected:
                    state.consecutive_failures = 0
                    return state.client
                else:
                    state.connected = False
                    return None
            except Exception as e:
                state.connected = False
                return None

    async def release(self, name: str):
        """释放信号量(在请求完成后调用)"""
        pass  # 信号量在 poll_device 中管理

    async def close_all(self):
        """关闭所有连接"""
        for name, state in self.devices.items():
            if state.client is not None:
                try:
                    state.client.close()
                except Exception:
                    pass
                state.client = None
                state.connected = False

2. 采集调度器(错峰 + 并发控制)

代码语言:javascript
复制
class Scheduler:
    def __init__(self, pool: ConnectionPool, influx_writer=None):
        self.pool = pool
        self.influx = influx_writer
        self.running = False
        self._tasks: Dict[str, asyncio.Task] = {}

    async def start(self):
        """启动所有设备的采集任务"""
        self.running = True
        for name in self.pool.configs.keys():
            self._tasks[name] = asyncio.create_task(
                self._device_loop(name)
            )
        # 等待所有任务
        await asyncio.gather(*self._tasks.values(), return_exceptions=True)

    async def stop(self):
        """停止所有采集任务"""
        self.running = False
        for task in self._tasks.values():
            task.cancel()
        await asyncio.gather(*self._tasks.values(), return_exceptions=True)
        await self.pool.close_all()

    async def _device_loop(self, name: str):
        """单个设备的采集循环"""
        config = self.pool.configs[name]
        state = self.pool.devices[name]

        # 错峰启动:随机抖动 0-5 秒
        await asyncio.sleep(hash(name) % 5)

        while self.running:
            try:
                await self._poll_device(name)
            except asyncio.CancelledError:
                break
            except Exception as e:
                # 记录异常但不退出循环
                pass

            # 等待下一个采集周期
            await asyncio.sleep(config.poll_interval)

    async def _poll_device(self, name: str):
        """执行单次采集"""
        config = self.pool.configs[name]
        state = self.pool.devices[name]

        async with self.pool.semaphore:  # 限制并发数
            client = await self.pool.get_client(name)
            if client is None or not client.connected:
                state.quality = "bad"
                state.consecutive_failures += 1
                return

            t0 = time.monotonic()
            try:
                # 读取保持寄存器 40001-40002(温度、湿度)
                resp = await asyncio.wait_for(
                    client.read_holding_registers(
                        address=0, count=2, slave=config.slave_id
                    ),
                    timeout=config.timeout
                )

                elapsed = (time.monotonic() - t0) * 1000  # ms

                if resp is None or resp.isError():
                    raise ModbusIOException(f"Bad response: {resp}")

                # 解析数据
                temp = resp.registers[0] * 0.1
                humid = resp.registers[1] * 0.1

                # 更新状态
                state.last_poll = time.time()
                state.last_success = time.time()
                state.consecutive_failures = 0
                state.response_times.append(elapsed)
                if len(state.response_times) > 100:
                    state.response_times.pop(0)
                state.quality = "good"

                # 写入 InfluxDB
                if self.influx:
                    await self.influx.write_point(
                        measurement="temperature_humidity",
                        tags={"device": name, "host": config.host},
                        fields={
                            "temperature": temp,
                            "humidity": humid,
                            "response_ms": elapsed,
                            "quality": 0,
                        }
                    )

            except asyncio.TimeoutError:
                state.consecutive_failures += 1
                state.quality = "bad"
            except (ModbusIOException, ConnectionException, OSError) as e:
                state.consecutive_failures += 1
                state.quality = "bad"
                # 连接可能已断开,标记重连
                state.connected = False
                try:
                    client.close()
                except Exception:
                    pass
                state.client = None

3. InfluxDB 异步写入

代码语言:javascript
复制
from influxdb_client.client.influxdb_client_async import InfluxDBClientAsync

class AsyncInfluxWriter:
    def __init__(self, url: str, token: str, org: str, bucket: str):
        self.client = InfluxDBClientAsync(url=url, token=token, org=org)
        self.bucket = bucket
        self._queue = asyncio.Queue(maxsize=10000)
        self._task = None

    async def start(self):
        self._task = asyncio.create_task(self._flush_loop())

    async def stop(self):
        if self._task:
            self._task.cancel()
            await asyncio.gather(self._task, return_exceptions=True)
        await self.client.close()

    async def write_point(self, measurement: str, tags: dict, fields: dict):
        """非阻塞写入队列"""
        point = {
            "measurement": measurement,
            "tags": tags,
            "fields": fields,
            "time": int(time.time() * 1e9),
        }
        try:
            self._queue.put_nowait(point)
        except asyncio.QueueFull:
            # 队列满,丢弃最旧的数据
            try:
                self._queue.get_nowait()
            except asyncio.QueueEmpty:
                pass
            self._queue.put_nowait(point)

    async def _flush_loop(self):
        """批量写入 InfluxDB"""
        batch = []
        last_flush = time.monotonic()

        while True:
            try:
                # 等待数据,超时则刷新
                point = await asyncio.wait_for(self._queue.get(), timeout=1.0)
                batch.append(point)

                # 批量条件:达到 500 条或 5 秒
                if len(batch) >= 500 or (time.monotonic() - last_flush) >= 5.0:
                    await self._flush(batch)
                    batch.clear()
                    last_flush = time.monotonic()

            except asyncio.TimeoutError:
                if batch:
                    await self._flush(batch)
                    batch.clear()
                    last_flush = time.monotonic()
            except asyncio.CancelledError:
                if batch:
                    await self._flush(batch)
                break

    async def _flush(self, points: list):
        """批量写入 InfluxDB"""
        from influxdb_client import Point
        influx_points = []
        for p in points:
            pt = Point(p["measurement"]).time(p["time"])
            for k, v in p["tags"].items():
                pt = pt.tag(k, v)
            for k, v in p["fields"].items():
                pt = pt.field(k, v)
            influx_points.append(pt)

        try:
            await self.client.write_api().write(
                bucket=self.bucket, record=influx_points
            )
        except Exception as e:
            # 写入失败,记录日志,不重试(避免阻塞)
            pass

4. 主程序

代码语言:javascript
复制
async def main():
    # 从配置文件加载设备列表
    devices = load_devices_from_config("devices.yaml")

    # 创建连接池
    pool = ConnectionPool(max_concurrent=100)

    # 添加设备
    for name, cfg in devices.items():
        pool.add_device(name, DeviceConfig(**cfg))

    # 创建 InfluxDB 写入器
    influx = AsyncInfluxWriter(
        url="http://localhost:8086",
        token="your-token",
        org="archive",
        bucket="env_monitor"
    )
    await influx.start()

    # 创建调度器
    scheduler = Scheduler(pool, influx)

    try:
        await scheduler.start()
    except KeyboardInterrupt:
        pass
    finally:
        await scheduler.stop()
        await influx.stop()

if __name__ == "__main__":
    asyncio.run(main())

四、性能优化要点

1. 并发度调优

参数

建议值

依据

max_concurrent(Semaphore)

50–150

取决于交换机 MAC 表大小、设备 TCP 栈深度

单设备采集周期

5–60s

温湿度变化慢,5s 足够

连接超时

2s

现场网络 RTT < 1ms,2s 足够区分故障

批量写入大小

500 条/批

InfluxDB 推荐批量写入

2. 错峰策略

代码语言:javascript
复制
# 方案 1:随机抖动
await asyncio.sleep(random.uniform(0, 5))

# 方案 2:按设备哈希均匀分布
offset = (hash(name) % 100) / 100 * poll_interval
await asyncio.sleep(offset)

# 方案 3:按区域分批
# 区域 A 设备:第 0-2 秒
# 区域 B 设备:第 2-4 秒
# 区域 C 设备:第 4-6 秒

3. 连接复用 vs 按需创建

策略

优点

缺点

长连接复用

无握手延迟,响应快

占用交换机端口表,设备重启后连接失效

按需创建

资源占用少

每次握手 ~1ms,高并发时累积延迟

混合(推荐)​

长连接 + 健康检查 + 自动重连

实现稍复杂


五、常见返工点

问题

后果

正确做法

同步 for 循环轮询

采集周期过长

用 asyncio 并发

无限并发(无 Semaphore)

交换机/设备过载

限制并发数

不处理连接断开

采集静默失败

检测断开,标记离线,触发重连

不限制队列大小

内存暴涨

队列满时丢弃旧数据

不批量写入 InfluxDB

I/O 瓶颈

批量异步写入

不记录响应时间

无法定位慢设备

记录 RTT,用于性能分析

不设置超时

单设备卡死阻塞全局

每次请求设超时

不处理 CancelledError

任务取消时资源泄漏

捕获并清理资源


六、一句话总结

数百台设备的并发采集,核心不是"能同时连多少台",而是如何优雅地管理连接生命周期、控制并发度、处理故障、批量写入。 asyncio 提供了正确的并发模型,但 pymodbus 的坑需要你绕过去——连接池、信号量、指数退避、批量写入,这四件事做好了,200 台设备的采集周期可以稳定在 5 秒以内。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 高并发采集:同时轮询数百台 POE 供电的以太网温湿度变送器(Modbus TCP/IP)
    • 物联网 #Modbus #TCP/IP #UDP #POE供电 #腾讯云 #Wireshark #Python #InfluxDB #以太网温湿度传感器 #网口温湿度变送器 #机房监控
    • 一、为什么 asyncio 是高并发采集的正确选择
      • 1. 三种并发模型对比
      • 2. 但 pymodbus 的 asyncio 支持有坑
    • 二、架构设计:数百台设备的采集拓扑
      • 1. 逻辑分层
      • 2. 关键设计决策
    • 三、核心代码实现
      • 1. 连接池(带健康检测)
      • 2. 采集调度器(错峰 + 并发控制)
      • 3. InfluxDB 异步写入
      • 4. 主程序
    • 四、性能优化要点
      • 1. 并发度调优
      • 2. 错峰策略
      • 3. 连接复用 vs 按需创建
    • 五、常见返工点
    • 六、一句话总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档