4. OPC UA 订阅机制原理:订阅是什么、MonitoredItem、Subscription的生命周期

好,咱们今天聊聊 OPC UA 里最实用的一个机制——订阅。说实话,很多刚接触 OPC UA 的朋友,上来就写代码读写变量,觉得挺简单。但一到做数据监控、报警采集的时候,就发现轮询太慢了,CPU 也扛不住。这时候,订阅机制就派上用场了。

我个人习惯把订阅理解成「自动推送」。你不需要一遍遍去问服务器:“数据变了没?变了没?” 你只需要告诉它:“我关注这几个变量,有变化你就通知我。” 服务器会默默盯着,一旦数据变化超过你设定的阈值,它就把新值推给你。说白了,这就是一个发布-订阅模式在工业协议里的落地。

4.1 订阅是什么?

订阅(Subscription),在 OPC UA 里是一个逻辑上的通信通道。它负责把客户端感兴趣的数据变化,从服务器端主动推送到客户端。

你可以把它想象成一个消息管道

  • 客户端创建这个管道
  • 往管道里塞入你想监控的变量(这就是 MonitoredItem)
  • 设置好推送的节奏(发布间隔)
  • 然后等着收数据就行

我记得有一次做项目,现场有 2000 多个温度传感器。如果用轮询,每个传感器每秒读一次,服务器 CPU 直接飙到 90%。后来改成订阅,只有温度变化超过 0.5 度才推送,CPU 瞬间降到 15%。嗯,这就是订阅的价值。

核心要点:订阅不是轮询的替代品,而是事件驱动的数据采集方式。它更适合变化频繁、实时性要求高的场景。

4.2 MonitoredItem:你到底想监控什么?

MonitoredItem,直译过来就是“被监控的项目”。它是订阅里的最小监控单元。一个订阅可以包含多个 MonitoredItem,每个 MonitoredItem 对应一个节点(比如一个变量、一个对象)。

创建 MonitoredItem 时,你需要告诉服务器三件事:

  1. 监控哪个节点? 节点 ID 必须明确
  2. 监控什么属性? 通常是 Value,也可以是 StatusCode、Timestamp
  3. 触发条件是什么? 数据变化超过多少才推送?

这里有个坑,我踩过。你想想看,如果你监控一个模拟量,比如压力值,它可能在 10.0 和 10.1 之间来回跳。如果你把触发条件设成“任何变化都推送”,那网络带宽会被刷爆。所以,一定要设置采样间隔死区(Deadband)

我的经验:对于模拟量,死区建议设为量程的 0.5%~1%。对于开关量,直接设成“任何变化都推送”就行,因为开关量变化频率低,数据量小。

下面是一个创建 MonitoredItem 的 Python 代码示例,我用的是 opcua-asyncio 库:

import asyncio
from opcua import Client, ua

async def subscribe_temperature():
    client = Client("opc.tcp://localhost:4840")
    await client.connect()
    
    # 获取要监控的节点
    temp_node = client.get_node("ns=2;s=Temperature")
    
    # 创建订阅
    subscription = await client.create_subscription(100, handler)  # 100ms发布间隔
    
    # 创建 MonitoredItem
    # 监控 Value 属性,采样间隔 500ms,死区 0.5(绝对值)
    handle = await subscription.subscribe_data_change(
        temp_node,
        ua.AttributeIds.Value,
        sampling_interval=500,      # 采样间隔 500ms
        deadband=0.5,               # 死区 0.5
        deadband_type=ua.DeadbandType.Abs  # 绝对值死区
    )
    
    print(f"MonitoredItem 创建成功,Handle: {handle}")
    await asyncio.sleep(60)
    await client.disconnect()

asyncio.run(subscribe_temperature())

注意:采样间隔和发布间隔是两个不同的概念。采样间隔是服务器读取数据的频率,发布间隔是服务器把数据推送给客户端的频率。如果采样间隔 > 发布间隔,那每次推送的数据都是同一个采样值,浪费带宽。我建议采样间隔 ≤ 发布间隔。

4.3 Subscription 的生命周期

一个 Subscription 从创建到销毁,会经历几个阶段。理解这个生命周期,对调试和排错很有帮助。

阶段 状态 说明
1. 创建 Active 客户端调用 CreateSubscription 服务,服务器返回 Subscription ID
2. 添加监控项 Active 通过 CreateMonitoredItems 添加要监控的节点
3. 数据推送 Active 服务器按发布间隔推送变化的数据
4. 修改 Active 可以修改发布间隔、监控项等参数
5. 暂停 Paused 客户端调用 SetPublishingMode(false),暂停推送
6. 恢复 Active 客户端调用 SetPublishingMode(true),恢复推送
7. 删除 Deleted 客户端调用 DeleteSubscriptions,释放资源

这里有个细节,我曾经吃过亏。当客户端意外断开连接时,服务器不会立即删除订阅。它会保留一段时间(默认 60 秒),等待客户端重连。如果客户端在超时前重连,可以恢复订阅,继续接收数据。这个机制叫订阅的持久化

避坑指南:我曾经在调试时,客户端频繁断开重连,结果服务器上积累了大量的僵尸订阅。每个订阅都占用内存和 CPU。后来我养成了习惯:在客户端退出前,显式调用 DeleteSubscriptions 清理所有订阅。如果客户端崩溃了,也要在服务器端设置合理的订阅超时时间(比如 30 秒)。

4.4 订阅的发布间隔与采样间隔

这两个参数经常被混淆,我单独拿出来说一下。

  • 发布间隔(Publishing Interval):服务器每隔多久给客户端发一次通知。单位是毫秒。比如设成 1000,就是每秒发一次。
  • 采样间隔(Sampling Interval):服务器每隔多久读取一次被监控节点的值。单位也是毫秒。

你想想看,如果采样间隔是 2000ms,发布间隔是 1000ms。那服务器每 2 秒读一次数据,但每 1 秒就发一次通知。这意味着有一半的通知里,数据根本没变。白白浪费带宽。

我的建议是:采样间隔 ≤ 发布间隔。如果数据变化很快,可以把采样间隔设小一点,比如 100ms,发布间隔设成 500ms。这样每次推送的数据都是最新的。

小技巧:如果你只关心数据变化,不关心具体时间点,可以把采样间隔设成 0。这表示服务器会以最快速度采样(通常是每个扫描周期一次)。但要注意,这会增加服务器负载。

4.5 订阅的队列机制

订阅内部有一个通知队列。当数据变化发生时,服务器先把变化放入队列,然后按发布间隔批量推送给客户端。

队列的大小可以配置。如果队列满了,新的变化会覆盖旧的变化(默认行为),或者丢弃(取决于配置)。

我曾经遇到过一个场景:现场有 100 个报警变量,每个变量在 1 秒内变化了 10 次。如果队列大小只有 10,那很多报警就被覆盖了。后来我把队列大小调大到 1000,才把所有报警都捕获到。

警告:队列不是越大越好。队列越大,占用的内存越多。如果客户端处理速度跟不上,队列会一直堆积,最终导致内存溢出。建议根据实际数据变化频率和客户端处理能力,合理设置队列大小。

4.6 总结

好了,咱们把订阅机制的核心捋一遍:

  • 订阅是一个逻辑通道,负责把数据从服务器推送到客户端
  • MonitoredItem是订阅里的监控单元,每个监控项对应一个节点
  • 生命周期包括创建、添加监控项、数据推送、修改、暂停、恢复、删除
  • 发布间隔采样间隔要合理搭配,避免浪费带宽
  • 队列机制用于缓冲数据变化,防止丢失

下一章,我会带大家手写一个完整的订阅监控程序,从创建订阅到处理回调,一步步来。到时候你会看到,这些理论在实际代码里是怎么落地的。