SpringBoot中modbus4j实现PLC连接池与重连限频-技术详解
SpringBoot中modbus4j实现PLC连接池与重连限频-技术详解
适用场景:Modbus-TCP / 工业设备接入、任何需要对"有限、易抖动、建连昂贵"的下游资源做长连接管理的系统。
目录
- 问题背景:为什么 PLC 连接需要"池化 + 限频"
- 核心概念与知识点
- 连接池设计:结构、生命周期、线程安全
- 连接有效性检查与失效重建
- 重连限频(熔断式重连)设计
- 本项目落地实现解析
- 通用示例代码
- 设计要点与踩坑清单
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
一、问题背景:为什么 PLC 连接需要"池化 + 限频"
工业现场通过 Modbus-TCP 采集雾炮 PLC 数据,采集频率高(本项目 2 秒一轮),且设备可能有多台。若每次采集都"新建连接 → 读 → 关连接",会带来三个致命问题:
- 建连昂贵:TCP 三次握手 + Modbus 初始化握手,单次几十到数百毫秒,高频采集下开销巨大。
- 打挂 PLC:PLC 是资源极其有限的嵌入式设备,并发连接数常常只有个位数。频繁建连(尤其在网络抖动、设备离线时疯狂重试)会耗尽其连接资源,导致整机无响应。
- 雪崩效应:某台设备离线后,如果每一轮采集都对它发起建连并阻塞等待超时(如 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 的连接冲击。该模式可平移到任何"下游有限、建连昂贵、网络易抖动"的长连接场景。
更多推荐
所有评论(0)