SpringBoot中modbus4j实现PLC连接池与重连限频-技术详解

适用场景:Modbus-TCP / 工业设备接入、任何需要对"有限、易抖动、建连昂贵"的下游资源做长连接管理的系统。


目录

  1. 问题背景:为什么 PLC 连接需要"池化 + 限频"
  2. 核心概念与知识点
  3. 连接池设计:结构、生命周期、线程安全
  4. 连接有效性检查与失效重建
  5. 重连限频(熔断式重连)设计
  6. 本项目落地实现解析
  7. 通用示例代码
  8. 设计要点与踩坑清单

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

一、问题背景:为什么 PLC 连接需要"池化 + 限频"

工业现场通过 Modbus-TCP 采集雾炮 PLC 数据,采集频率高(本项目 2 秒一轮),且设备可能有多台。若每次采集都"新建连接 → 读 → 关连接",会带来三个致命问题:

  1. 建连昂贵:TCP 三次握手 + Modbus 初始化握手,单次几十到数百毫秒,高频采集下开销巨大。
  2. 打挂 PLC:PLC 是资源极其有限的嵌入式设备,并发连接数常常只有个位数。频繁建连(尤其在网络抖动、设备离线时疯狂重试)会耗尽其连接资源,导致整机无响应。
  3. 雪崩效应:某台设备离线后,如果每一轮采集都对它发起建连并阻塞等待超时(如 2 秒),会拖慢整个采集任务,甚至影响其它正常设备。

解决思路 = 连接池 + 熔断式重连:

  • 连接池:为每台设备维护一条长连接,复用而非反复建连。
  • 有效性检查 + 失效重建:连接断了能自愈。
  • 重连限频(熔断):离线设备进入"冷却期",冷却期内直接跳过,不再尝试建连,保护 PLC 也保护采集任务的整体节奏。

二、核心概念与知识点

概念说明
连接池 (Connection Pool)预先创建并缓存连接,按 key(本项目用 PLC 的 IP 地址)复用,避免重复建连。
长连接 (Keep-Alive)TCP 连接建立后保持不关闭,多次请求复用同一条连接。modbus4j 的 createTcpMaster(params, true) 第二参数 true 即启用长连接。
有效性检查 (Liveness Check)复用前判断连接是否仍然可用(是否已初始化 / 能否重新 init),不可用则重建。
失效重建 (Rebuild)销毁旧连接、创建新连接并替换池中条目。
熔断 (Circuit Breaker)下游连续失败时"跳闸",一段时间内快速失败、不再真正发起调用,给下游恢复时间。
重连限频/冷却 (Backoff / Cooldown)熔断的一种简化形式:失败后记录时间戳,在冷却窗口内跳过重连尝试。
超时与重试 (Timeout / Retries)单次请求的超时时间与失败重试次数,防止无限阻塞。

三、连接池设计:结构、生命周期、线程安全

3.1 数据结构

连接池的本质是一个"设备标识 → 连接对象"的映射。由于采集任务是多线程(本项目用 parallelStream)并发访问,必须使用线程安全的容器:

private final Map<String, ModbusMaster> masterPool = new ConcurrentHashMap<>();
  • key:设备唯一标识,本项目用 PLC 的 IP(device.getPlcAddress())。
  • value:连接对象(modbus4j 的 ModbusMaster)。

3.2 获取连接:computeIfAbsent 保证"同一 key 只建一次"

public ModbusMaster getOrCreateMaster(BusDevice device) throws ModbusInitException {
    String deviceKey = device.getPlcAddress();
    return masterPool.computeIfAbsent(deviceKey, k -> {
        try {
            return createNewMaster(device);
        } catch (ModbusInitException e) {
            throw new RuntimeException(e);
        }
    });
}

知识点:ConcurrentHashMap.computeIfAbsent 对同一 key 的计算是原子的——即使多个线程同时对同一设备请求连接,也只会创建一条连接,避免重复建连。这是"懒加载 + 线程安全"的经典写法。

注意:computeIfAbsent 的 mapping function 内不宜执行过长的阻塞操作(会持有 bin 的锁)。工业建连通常几十毫秒可接受;若建连非常慢,需改为"双检锁 + 外部建连"模式。

3.3 生命周期与优雅关闭

应用关闭时必须释放所有连接,否则会造成 PLC 端连接泄漏。用 @PreDestroy 在 Spring 容器销毁 Bean 时统一清理:

@PreDestroy
public void cleanup() {
    masterPool.values().forEach(master -> {
        try { master.destroy(); } catch (Exception e) { /* 记录日志 */ }
    });
    masterPool.clear();
}

四、连接有效性检查与失效重建

复用连接前要确认它"还活着"。本项目的检查策略是:已初始化则认为有效;否则尝试重新 init(),成功即有效,异常即失效。

public boolean checkMasterValid(ModbusMaster master) {
    try {
        if (master.isInitialized()) return true;
        master.init();
        return true;
    } catch (Exception e) {
        return false;
    }
}

失效时重建——先销毁旧的、再建新的并替换池中条目:

public ModbusMaster rebuildMaster(BusDevice device) throws ModbusInitException {
    String deviceKey = device.getPlcAddress();
    ModbusMaster oldMaster = masterPool.remove(deviceKey);
    if (oldMaster != null) oldMaster.destroy();   // 释放旧连接,防泄漏
    ModbusMaster newMaster = createNewMaster(device);
    masterPool.put(deviceKey, newMaster);
    return newMaster;
}

调用侧的标准用法(“取连接 → 校验 → 失效则重建”):

ModbusMaster master = pool.getOrCreateMaster(device);
if (!pool.checkMasterValid(master)) {
    master = pool.rebuildMaster(device);
}
// 用 master 读写...

建连参数:超时与重试(防止无限阻塞)

ModbusMaster master = modbusFactory.createTcpMaster(params, true); // true=长连接
master.setTimeout(2000);   // 单次请求超时 2s
master.setRetries(1);      // 失败重试 1 次
master.init();
  • 超时决定了单台离线设备最多阻塞多久,直接影响整轮采集耗时——不能设太大。
  • 重试在弱网下能提高成功率,但会放大耗时,需权衡。

五、重连限频(熔断式重连)设计

这是整套方案里最关键、也最容易被忽略的一环。

5.1 没有限频会怎样?

假设一台设备离线,采集任务每 2 秒跑一轮:

  • 每一轮都对它 getOrCreateMaster → 建连 → 阻塞到 2 秒超时 → 失败。
  • 结果:既疯狂骚扰(甚至打挂)PLC,又让每一轮采集都白白多花 2 秒。

5.2 限频(冷却)机制

给"重连"加一个冷却窗口:一旦某设备通信失败,记录失败时间戳;在冷却时间(本项目默认 30s,可配置)内,直接跳过该设备,不读也不重连。

状态流转:

正常读取 ──通信异常──▶ 记录 lastReconnectFail[key]=now, 销毁连接
                          │
              冷却期内(<interval) 每轮直接 return 跳过
                          │
              冷却期满(>=interval) ──▶ 再次尝试建连/读取
                          │
                成功 ──▶ 清除 lastReconnectFail[key],恢复高频读取

这正是熔断器的简化实现:失败后"跳闸冷却",冷却结束后"半开"试探一次,成功则"闭合"恢复。

5.3 冷却时间可配置

把冷却时间做成配置项(本项目读 sys_config 的 plc.reconnect.interval,单位秒,默认 30),运维可按现场网络质量在线调整,无需改代码重启。


六、本项目落地实现解析

6.1 连接池组件 ModbusMasterPool

位于 ruoyi-common(抽到公共模块,供采集任务、控制指令下发等所有模块复用)。核心方法:

方法作用
getOrCreateMaster(device)按 IP 取连接,无则原子创建
checkMasterValid(master)有效性检查
rebuildMaster(device)失效重建(销毁旧、建新、替换)
destroyMaster(deviceKey)销毁指定连接(通信失败时调用)
cleanup()@PreDestroy 应用关闭时清理全部
writeToDevice(device, bitIndex, value)复用同一池下发控制指令(读-改-写置位 40001 控制字的 bit)

亮点:读(采集)与写(控制下发)共用同一连接池,避免读写各建一套连接。

6.2 采集任务中的重连限频(GetModbusTCPDataTask)

关键字段:

// 冷却默认 30 秒(可被 sys_config 的 plc.reconnect.interval 覆盖)
private static final long RECONNECT_INTERVAL_SECONDS_DEFAULT = 30;
// key=plcAddress,记录最近一次重连失败时间,用于限频
private static final Map<String, Long> lastReconnectFail = new ConcurrentHashMap<>();

每台设备处理开头先判断是否处于冷却期:

Long lastReconnectFailAt = lastReconnectFail.get(deviceKey);
if (lastReconnectFailAt != null
        && (System.currentTimeMillis() - lastReconnectFailAt) < reconnectIntervalMs) {
    return;   // 冷却期内直接跳过,不读不重连
}

读取成功后清除标记,恢复高频:

lastReconnectFail.remove(deviceKey);

通信异常时记录失败时间并销毁无效连接(进入冷却):

} catch (Exception e) {
    lastReconnectFail.put(deviceKey, System.currentTimeMillis()); // 进入冷却
    modbusMasterPool.destroyMaster(deviceKey);                    // 销毁无效连接
}

冷却时间读取配置(带兜底与非法值保护):

long sec = RECONNECT_INTERVAL_SECONDS_DEFAULT;
String intervalStr = sysConfigService.selectConfigByKey("plc.reconnect.interval");
if (intervalStr != null && !intervalStr.trim().isEmpty()) {
    try { sec = Long.parseLong(intervalStr.trim()); }
    catch (NumberFormatException e) { sec = RECONNECT_INTERVAL_SECONDS_DEFAULT; }
}
if (sec < 1) sec = RECONNECT_INTERVAL_SECONDS_DEFAULT;
long reconnectIntervalMs = sec * 1000L;

6.3 与并行采集的配合

采集用 deviceList.parallelStream().forEach(...) 并发处理多台设备。因此:

  • 连接池必须线程安全(ConcurrentHashMap)。
  • 限频状态 lastReconnectFail 也必须线程安全(ConcurrentHashMap)。
  • 单台设备的阻塞(超时)不会永久拖累其它设备,加上限频后离线设备很快被"跳过",整轮采集节奏得以保证。

提示:parallelStream 使用 JVM 公共 ForkJoinPool,若设备数多或单次读取耗时长,建议改用自定义线程池,避免与其它并行任务争抢公共池。


七、通用示例代码

下面是一份与具体业务无关的通用"连接池 + 熔断式重连"骨架,可套用到任意长连接资源管理。

7.1 通用连接池

public class ResourcePool<C> {

    private final Map<String, C> pool = new ConcurrentHashMap<>();
    private final Function<String, C> factory;   // 建连
    private final Predicate<C> validator;        // 有效性检查
    private final Consumer<C> closer;            // 销毁

    public ResourcePool(Function<String, C> factory,
                        Predicate<C> validator,
                        Consumer<C> closer) {
        this.factory = factory;
        this.validator = validator;
        this.closer = closer;
    }

    public C getOrCreate(String key) {
        return pool.computeIfAbsent(key, factory);
    }

    /** 取一个"确保有效"的连接,失效则重建 */
    public C getValid(String key) {
        C c = getOrCreate(key);
        if (!validator.test(c)) {
            c = rebuild(key);
        }
        return c;
    }

    public C rebuild(String key) {
        C old = pool.remove(key);
        if (old != null) closer.accept(old);
        C fresh = factory.apply(key);
        pool.put(key, fresh);
        return fresh;
    }

    public void destroy(String key) {
        C c = pool.remove(key);
        if (c != null) closer.accept(c);
    }

    public void closeAll() {
        pool.values().forEach(closer);
        pool.clear();
    }
}

7.2 通用熔断式重连(冷却窗口)

public class ReconnectCircuit {

    private final Map<String, Long> lastFail = new ConcurrentHashMap<>();
    private final long cooldownMs;

    public ReconnectCircuit(long cooldownMs) {
        this.cooldownMs = cooldownMs;
    }

    /** 是否处于冷却期(true=应跳过,不要建连/调用) */
    public boolean isCoolingDown(String key) {
        Long t = lastFail.get(key);
        return t != null && (System.currentTimeMillis() - t) < cooldownMs;
    }

    /** 记录失败,进入冷却 */
    public void onFailure(String key) {
        lastFail.put(key, System.currentTimeMillis());
    }

    /** 记录成功,退出冷却 */
    public void onSuccess(String key) {
        lastFail.remove(key);
    }
}

7.3 组合使用(采集循环骨架)

ResourcePool<Conn> pool = new ResourcePool<>(
        key -> connect(key),                 // 建连
        conn -> conn.isAlive(),              // 有效性检查
        conn -> conn.close());               // 销毁
ReconnectCircuit circuit = new ReconnectCircuit(30_000); // 30s 冷却

void pollOnce(List<String> deviceKeys) {
    deviceKeys.parallelStream().forEach(key -> {
        if (circuit.isCoolingDown(key)) return;   // 冷却期跳过
        try {
            Conn conn = pool.getValid(key);
            read(conn);                            // 业务读取
            circuit.onSuccess(key);                // 成功,退出冷却
        } catch (Exception e) {
            circuit.onFailure(key);                // 失败,进入冷却
            pool.destroy(key);                     // 销毁无效连接
        }
    });
}

八、设计要点与踩坑清单

主题要点
池化 key 选择用设备稳定唯一标识(IP/序列号),不要用会变的对象引用。
线程安全池与限频状态都必须用 ConcurrentHashMap;computeIfAbsent 保证只建一次。
建连超时必须设置合理超时(本项目 2s),否则离线设备会长时间阻塞采集线程。
重试次数弱网可加 1 次重试,但会放大单次耗时,不要设太大。
失效重建重建前务必 destroy 旧连接,防止 PLC 端连接泄漏。
重连限频(核心)失败进冷却、成功清冷却;冷却期内直接跳过,保护 PLC 与采集节奏。
冷却可配置冷却时间放配置中心/数据库(本项目 plc.reconnect.interval),运维可在线调整。
配置健壮性解析配置要有默认值、异常兜底、非法值(<1)保护。
优雅关闭@PreDestroy 统一关闭所有连接。
并行池选择大量设备/慢读取时用自定义线程池,别只依赖 parallelStream 的公共 ForkJoinPool。
读写共池采集读取与控制下发复用同一连接池,减少连接总数。
半开试探冷却结束后只"试一次",成功才恢复高频——避免恢复瞬间的请求风暴。

小结

工业设备长连接管理的三板斧:池化复用(省建连)、有效性检查 + 失效重建(能自愈)、熔断式重连限频(保护下游、稳定节奏)。本项目通过 ModbusMasterPool + GetModbusTCPDataTask 中的 lastReconnectFail 冷却机制,配合可配置的 plc.reconnect.interval,在 2 秒高频采集 + 多设备并行的场景下,既保证了实时性,又避免了对脆弱 PLC 的连接冲击。该模式可平移到任何"下游有限、建连昂贵、网络易抖动"的长连接场景。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐