02 · 自研采集器设计实战
本篇是「工业设备数据采集」知识体系第 06 篇的第 2 篇,也是整个体系的压轴实战篇。
定位:把前面 01~05 篇的所有知识,落成一套能跑在生产环境里的 Java 采集器设计。
场景锁定 FastBee 物联网平台的 fastbee-collector:采集 Modbus / S7 / MC 三种 PLC 协议,MQTT 上行,TDengine 存储。
代码约定:为压缩篇幅,值对象用
public final字段、配置类用public字段(合法且可编译);多个顶层类写在同一代码块里,实际按package拆成独立文件即可编译。生产代码可换成 Lombok@Data(iot-communication 就是这么做的)。
本篇目录
| 章节 | 内容 |
|---|---|
| 1 | 总体架构 |
| 2 | 协议驱动 SPI 设计(接口 + Modbus 骨架) |
| 3 | 连接管理(状态机 / 心跳 / 退避 / 预算) |
| 4 | 采集调度器(完整实现) |
| 5 | 数据处理链与数据模型 |
| 6 | 断网续传(环形缓冲 + WAL)+ 写操作安全 |
| 7 | 配置模型(设备-通道-测点三层) |
| 8 | 与 FastBee 对接(物模型 / MQTT 主题) |
| 9 | 性能预估表与 Netty 切换点 |
| 10 | 小结 |
第 1 章 总体架构
┌────────────────────────────────────────────────────────┐
│ 配置中心 / 数据库 │
│ 产品(Product) · 设备(Device) · 物模型 · 点表 │
└───────────────────────┬────────────────────────────────┘
│ 加载 / 热更新
▼
┌──────────────────────────────────────────────────────┐
│ ① 设备管理层 DeviceConnection │
│ 状态机 CONNECTING→ONLINE→OFFLINE→RECOVERING │
│ 心跳 · 指数退避重连 · 连接数预算 · 泄漏检测 │
└───────────────────────┬──────────────────────────────┘
│ 持有 ProtocolDriver
▼
┌────────────────────────────────────────────────────┐
│ ② 调度层 CollectScheduler │
│ DelayQueue · 设备锁 · 跨设备并行/单设备串行 │
│ 超时控制 · 抖动 · 执行完再排下一轮(背压) │
└───────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ ③ 协议驱动层 ProtocolDriver(SPI) │
│ ┌─────────┐ ┌───────┐ ┌───────┐ ┌────────────┐ │
│ │ModbusTcp│ │ S7 │ │ MC │ │ 自定义协议 │ │
│ │ Driver │ │Driver │ │Driver │ │ │ │
│ └─────────┘ └───────┘ └───────┘ └────────────┘ │
│ 纯 JDK Socket / iot-communication / Hsl... │
└───────────────────────┬────────────────────────────┘
│ List<TagValue>
▼
┌────────────────────────────────────────────────────┐
│ ④ 数据上行层 MqttReporter │
│ 字节序→解码→缩放→死区→质量戳→时间戳→打包 │
│ 断网续传:环形缓冲 + 落盘 WAL(第 6 章) │
└──────────────────────────────────────────────────┘
依赖方向必须单向:
配置层 ──► 设备管理层 ──► 调度层 ──► 驱动层 ──► 上行层
✓ 上层依赖下层的接口
✗ 下层反向依赖上层
✗ 第三方类型(S7PLC / ModbusTcpNet / OperateResult ...)
只允许出现在 driver 包内部,不得泄漏到 service / controller
这一条是整个设计里最值钱的东西(呼应 06 篇 01 章 §8.3)。一旦 S7PLC 出现在业务包里,换库、授权合规、mock 测试全部升级为全平台改动。
一次采集的完整调用链:
[DelayQueue 到期] Task(deviceId)
│
▼
CollectScheduler.dispatch(task)
├─ deviceLock.tryLock(50ms) ← 拿不到就跳过本轮(记 skipped 指标)
▼
DeviceConnection.ensureConnected() ← 状态机:OFFLINE→RECOVERING→CONNECTING
▼
ProtocolDriver.batchRead(tags) ← 合并地址→组帧→两阶段读→解析
▼
DataPipeline.process(raw) ← 字节序→缩放→死区→质量戳→时间戳
▼
MqttReporter.publish(values) ← 成功 / 失败入环形缓冲 + WAL
▼
queue.put(task.next()) ← 从"本次完成时刻"再排一轮
设计选择:不用
scheduleAtFixedRate,而是执行完再排下一轮。前者在设备变慢时会任务叠加,后者天然背压。
第 2 章 协议驱动 SPI 设计
2.1 设计目标
| 目标 | 做法 |
|---|---|
| 第三方库可替换 | 接口里不出现任何第三方类型 |
| 新增协议成本低 | 主入口收敛到 5 个方法 |
| 支持批量优化 | batchRead(List) 是主入口,单点读是语法糖 |
| 线程语义明确 | 接口注释写死"实现必须串行安全" |
2.2 值对象、接口与异常
package com.fastbee.collector.driver;
import java.util.*;
// 数据质量戳,语义对齐 OPC UA StatusCode 三态
public enum DataQuality { GOOD, UNCERTAIN, BAD }
// byteSize = 单值字节宽;wordCount = 单值占用的寄存器/字数(Modbus 分帧用)
public enum DataType {
BOOL(1,1), INT16(2,1), UINT16(2,1), INT32(4,2), UINT32(4,2),
FLOAT32(4,2), FLOAT64(8,4), BCD16(2,1), BCD32(4,2), STRING(1,1);
public final int byteSize, wordCount;
DataType(int b, int w) { byteSize = b; wordCount = w; }
public boolean isBit() { return this == BOOL; }
}
// 测点配置(点表里的一行)
public class TagConfig {
public String id; // 建议 "deviceId:tagKey"
public String key; // 物模型属性 identifier,必须与平台一致
public String address; // 协议地址:40001 / DB1.0 / D100
public DataType dataType = DataType.INT16;
public int length = 1;
public double factor = 1.0; // 工程值 = raw × factor + offset
public double offset = 0.0;
public double deadband = 0.0; // 变化量小于此值不上报
public boolean readback = true; // 写后是否回读校验
public String access = "R"; // R | RW | W
public int byteLength() { return dataType.byteSize * length; }
public int wordLength() { return dataType.wordCount * length; }
}
// 采集值:处理链的产物,上行层的最小单元
public class TagValue {
public final String tagId, key;
public final DataType dataType;
public final DataQuality quality;
public final long rawValue; // 未缩放原始值(便于排查)
public final double value; // 缩放后工程值
public final long timestamp; // 采样时刻 epoch millis(本地时钟)
public final Long plcTimestamp; // PLC 时钟(若可读),否则 null
public TagValue(String tagId, String key, DataType dataType, DataQuality quality,
long rawValue, double value, long timestamp, Long plcTimestamp) {
this.tagId = tagId; this.key = key; this.dataType = dataType; this.quality = quality;
this.rawValue = rawValue; this.value = value;
this.timestamp = timestamp; this.plcTimestamp = plcTimestamp;
}
public boolean isGood() { return quality == DataQuality.GOOD; }
public static TagValue good(TagConfig t, long raw, double v, long ts) {
return new TagValue(t.id, t.key, t.dataType, DataQuality.GOOD, raw, v, ts, null);
}
public static TagValue goodWithPlcTime(TagConfig t, long raw, double v, long ts, long plcTs) {
return new TagValue(t.id, t.key, t.dataType, DataQuality.GOOD, raw, v, ts, plcTs);
}
public static TagValue bad(TagConfig t, long ts) {
return new TagValue(t.id, t.key, t.dataType, DataQuality.BAD, 0, 0, ts, null);
}
}
// 设备/通道配置
public class DeviceConfig {
public String deviceId, productId, deviceNum;
public String protocol; // s7 | mc | modbus-tcp
public String host;
public int port = 102, unitId = 1, rack = 0, slot = 1, monitorTimer = 10;
public int connectTimeoutMs = 3000, readTimeoutMs = 2000;
public long heartbeatMs = 3000;
public String plcType = "S1200", series = "Q_L",
frameType = "FRAME_3E", byteOrder = "ABCD";
}
// 驱动层统一异常:把第三方异常翻译成它,避免上层依赖第三方类型
public class DriverException extends Exception {
public enum Kind {
CONNECTION, // 连接层:refused / 超时 / 握手被拒 —— 应触发重连
PROTOCOL, // 协议层:异常码 / 结束码非 0 —— 不应重连,应告警
ENCODING, // 编码层:地址或数据无法编码 —— 配置错误
UNKNOWN
}
private final Kind kind;
public DriverException(String message) { this(Kind.UNKNOWN, message, null); }
public DriverException(Kind kind, String message) { this(kind, message, null); }
public DriverException(Kind kind, String message, Throwable cause) {
super(message, cause); this.kind = kind;
}
public Kind getKind() { return kind; }
public boolean shouldReconnect() { return kind == Kind.CONNECTION; }
}
/**
* 协议驱动 SPI。
*
* <b>线程语义(实现必须遵守)</b>:一个驱动实例 = 一条物理连接,同一时刻只能有一个
* 请求在飞。实现必须对「连接检查 → 组帧 → 发送 → 接收 → 解析」全程加锁。原因是两个
* 库的收发都是 BIO 同步阻塞 + 两阶段读,并发调用会让两个线程从同一个 InputStream 抢字节
* → 帧错位 → 偶发数据错(详见 07 篇 FAQ)。
*
* <b>生命周期</b>:connect() → [read/write/batchRead]* → close()。
*/
public interface ProtocolDriver {
/** 建立连接;失败抛异常,不抛则视为可用 */
void connect(DeviceConfig cfg) throws DriverException;
/** 连接是否仍可用 */
boolean isConnected();
/**
* 批量读取 —— 驱动主入口,实现应在此做地址合并与自动分包。
* 返回列表<b>必须与入参等长且同序</b>;单点失败时返回 DataQuality.BAD 的
* TagValue 而不是抛异常,这样单点故障不会拖垮整批。
*/
List<TagValue> batchRead(List<TagConfig> tags) throws DriverException;
/** 写入测点;value 为未做字节序转换的原始字节 */
void write(TagConfig tag, byte[] value) throws DriverException;
/** 关闭并释放;必须幂等 */
void close();
/** 单点读,默认委托给 batchRead */
default TagValue read(TagConfig tag) throws DriverException {
List<TagValue> vs = batchRead(Collections.singletonList(tag));
if (vs.isEmpty()) {
throw new DriverException("no value for tag: " + tag.id);
}
return vs.get(0);
}
}
2.3 Modbus TCP 驱动骨架(纯 JDK Socket,两阶段读)
package com.fastbee.collector.driver.modbus;
import com.fastbee.collector.driver.*;
import java.io.*;
import java.net.*;
import java.util.*;
/**
* Modbus TCP 驱动骨架:纯 JDK Socket,不依赖任何通信库。
*
* <pre>
* MBAP(7) = 事务ID(2) | 协议ID(2)=0000 | 长度(2) | 单元ID(1)
* PDU = 功能码(1) | 数据(N)
* "长度" = 本字段之后剩余的字节数 = 1(单元ID) + PDU 长度
* </pre>
*
* <b>骨架</b>:重点演示两阶段读与异常码判别。
* 地址语法解析、range→tags 偏移映射、RTU 的 CRC 需在生产实现中补全。
*/
public class ModbusTcpDriver implements ProtocolDriver {
private static final byte FC_READ_COILS = 0x01;
private static final byte FC_READ_HOLDING = 0x03;
private static final byte FC_READ_INPUT = 0x04;
private static final byte FC_WRITE_SINGLE = 0x06;
/**
* 理论上限:PDU ≤ 253 → 1(FC)+1(字节数)+2N ≤ 253 → N ≤ 125。
* 保守取 120:现场大量设备"擦边"实现,留 5 个寄存器余量
* (详见 03 篇 Modbus §12.1 / §12.4)。
*/
private static final int MAX_WORDS_PER_FRAME = 120;
/** 帧头长度:事务ID(2)+协议ID(2)+长度(2) */
private static final int HEAD_LEN = 6;
private final byte unitId;
private int txId = 0;
private Socket socket;
private InputStream in;
private OutputStream out;
private DeviceConfig cfg;
/** 锁住整个往返,禁止并发调用(见接口注释) */
private final Object roundTripLock = new Object();
public ModbusTcpDriver(int unitId) { this.unitId = (byte) unitId; }
@Override
public void connect(DeviceConfig c) throws DriverException {
synchronized (roundTripLock) {
closeQuietly();
try {
socket = new Socket();
socket.setTcpNoDelay(true); // 关 Nagle,工业小包必须
socket.setSoTimeout(c.readTimeoutMs);
socket.setKeepAlive(true);
socket.connect(new InetSocketAddress(c.host, c.port), c.connectTimeoutMs);
in = socket.getInputStream();
out = socket.getOutputStream();
this.cfg = c;
} catch (IOException e) {
closeQuietly();
throw new DriverException(DriverException.Kind.CONNECTION,
"connect " + c.host + ":" + c.port + " failed", e);
}
}
}
@Override public boolean isConnected() {
Socket s = this.socket;
return s != null && s.isConnected() && !s.isClosed();
}
@Override
public List<TagValue> batchRead(List<TagConfig> tags) throws DriverException {
synchronized (roundTripLock) {
List<TagValue> out = new ArrayList<>(tags.size());
int seg = 0;
for (byte[] req : buildReadRequests(tags)) {
parseReadResponse(transact(req), tags, out, seg++);
}
return out;
}
}
@Override
public void write(TagConfig tag, byte[] value) throws DriverException {
synchronized (roundTripLock) {
parseWriteResponse(transact(buildWriteRequest(tag, value)));
}
}
/** ===== 一次完整的发送-接收往返:粘包就在这里被解决 ===== */
protected byte[] transact(byte[] request) throws DriverException {
try {
out.write(request);
out.flush();
// 阶段一:读协议头,从头部字段解出后续还剩多少字节
byte[] head = new byte[HEAD_LEN];
readFully(head, 0, HEAD_LEN);
int bodyLen = resolveBodyLength(head);
if (bodyLen < 0) {
throw new DriverException(DriverException.Kind.PROTOCOL, "invalid response header");
}
// 阶段二:按解出的长度读满剩余部分(不读满 = 下一帧会错位)
byte[] body = new byte[bodyLen];
readFully(body, 0, bodyLen);
byte[] frame = new byte[HEAD_LEN + bodyLen];
System.arraycopy(head, 0, frame, 0, HEAD_LEN);
System.arraycopy(body, 0, frame, HEAD_LEN, bodyLen);
return frame;
} catch (SocketTimeoutException e) {
closeQuietly();
throw new DriverException(DriverException.Kind.CONNECTION, "read timeout", e);
} catch (IOException e) {
closeQuietly();
throw new DriverException(DriverException.Kind.CONNECTION, "io error", e);
}
}
/**
* 循环读满。InputStream.read 不保证一次读够,必须自己循环 ——
* 这是 BIO 编程最容易漏的一点(iot 的 SocketUtils.read 正是这么做的)。
*/
protected void readFully(byte[] buf, int off, int len) throws IOException {
int done = 0;
while (done < len) {
int n = in.read(buf, off + done, len - done);
if (n < 0) {
throw new SocketException("The end of the stream has been reached, and disconnected");
}
done += n;
}
}
/** 从头里解出"后面还有多少字节";返回负数表示头非法 */
protected int resolveBodyLength(byte[] head) {
int len = ((head[4] & 0xFF) << 8) | (head[5] & 0xFF);
return (len >= 2 && len <= 260) ? len : -1; // 下限=单元ID+功能码,上限防异常
}
// ===== 读方向 =====
protected List<byte[]> buildReadRequests(List<TagConfig> tags) throws DriverException {
List<byte[]> reqs = new ArrayList<>();
for (AddressRange r : mergeAndSplit(tags)) {
reqs.add(wrap(new byte[]{r.fc,
(byte)((r.start >> 8) & 0xFF), (byte)(r.start & 0xFF),
(byte)((r.qty >> 8) & 0xFF), (byte)(r.qty & 0xFF)}));
}
return reqs;
}
protected void parseReadResponse(byte[] resp, List<TagConfig> tags,
List<TagValue> out, int segIndex)
throws DriverException {
byte fc = resp[7];
// 异常响应判别:功能码最高位置 1(见 03 篇 Modbus §7.1)
if ((fc & 0x80) != 0) {
throw new DriverException(DriverException.Kind.PROTOCOL,
"modbus exception fc=0x" + Integer.toHexString(fc & 0x7F)
+ " code=" + (resp[8] & 0xFF) + " (" + describe(resp[8] & 0xFF) + ")");
}
int byteCount = resp[8] & 0xFF;
byte[] data = new byte[byteCount];
System.arraycopy(resp, 9, data, 0, byteCount);
// 生产实现应按 range→tags 的偏移映射表切片;此处简化为整段回填
for (TagConfig tag : tags) {
long raw = decode(data, tag.dataType, ByteOrder.ABCD);
out.add(TagValue.good(tag, raw, raw * tag.factor + tag.offset,
System.currentTimeMillis()));
}
}
// ===== 写方向 =====
protected byte[] buildWriteRequest(TagConfig tag, byte[] value) {
int addr = toZeroBased(tag.address);
return wrap(new byte[]{FC_WRITE_SINGLE,
(byte)((addr >> 8) & 0xFF), (byte)(addr & 0xFF),
value.length > 0 ? value[0] : 0, // Modbus 寄存器大端
value.length > 1 ? value[1] : 0});
}
protected void parseWriteResponse(byte[] resp) throws DriverException {
if ((resp[7] & 0x80) != 0) {
throw new DriverException(DriverException.Kind.PROTOCOL,
"write rejected, code=" + (resp[8] & 0xFF));
}
// 正常:回显 地址(2)+值(2)。回读校验见第 7 章
}
// ===== 内部:合并 → 切片 → 组帧 → 解码 =====
/** ①按功能码分组 ②组内排序 ③相邻 gap ≤ 16 合并 ④按 120 字切片 */
protected List<AddressRange> mergeAndSplit(List<TagConfig> tags) {
List<AddressRange> out = new ArrayList<>();
for (TagConfig t : tags) {
int addr = toZeroBased(t.address);
int qty = t.wordLength();
byte fc = t.dataType.isBit() ? FC_READ_COILS : FC_READ_HOLDING;
for (int off = 0; off < qty; off += MAX_WORDS_PER_FRAME) {
out.add(new AddressRange(fc, addr + off,
Math.min(MAX_WORDS_PER_FRAME, qty - off)));
}
}
return out;
}
/** 加 MBAP 头 */
private byte[] wrap(byte[] pdu) {
int tx = (txId = (txId + 1) & 0xFFFF);
int len = 1 + pdu.length;
ByteArrayOutputStream b = new ByteArrayOutputStream();
b.write((tx >> 8) & 0xFF); b.write(tx & 0xFF);
b.write(0x00); b.write(0x00);
b.write((len >> 8) & 0xFF); b.write(len & 0xFF);
b.write(unitId & 0xFF);
b.write(pdu, 0, pdu.length);
return b.toByteArray();
}
/** "40001" → 0;"40100" → 99(1 基转 0 基,见 03 篇 Modbus §3.4) */
private static int toZeroBased(String address) {
String s = address.trim();
char c = s.charAt(0);
if (s.length() > 1 && (c == '4' || c == '3' || c == '0' || c == '1')) {
return Integer.parseInt(s.substring(1)) - 1;
}
return Integer.parseInt(s);
}
private static long decode(byte[] raw, DataType t, ByteOrder o) {
switch (t) {
case BOOL: return raw[0] & 0x01;
case INT16:
case UINT16: {
byte[] w = o.swapBytesInWord()
? new byte[]{raw[1], raw[0]}
: new byte[]{raw[0], raw[1]};
int v = ((w[0] & 0xFF) << 8) | (w[1] & 0xFF);
return (t == DataType.INT16) ? (short) v : (v & 0xFFFFL);
}
case INT32:
case UINT32:
case FLOAT32: {
byte[] r = o.rearrange(new byte[]{raw[0], raw[1], raw[2], raw[3]});
long v = ((long)(r[0] & 0xFF) << 24) | ((long)(r[1] & 0xFF) << 16)
| ((long)(r[2] & 0xFF) << 8) | ((long)(r[3] & 0xFF));
return (t == DataType.INT32) ? (int) v : (v & 0xFFFFFFFFL);
}
default: throw new IllegalArgumentException("todo: " + t);
}
}
private String describe(int code) {
switch (code) {
case 0x01: return "非法功能码";
case 0x02: return "非法数据地址";
case 0x03: return "非法数据值";
case 0x04: return "从站设备故障";
case 0x06: return "从站忙";
default: return "未知错误";
}
}
@Override
public void close() {
synchronized (roundTripLock) { closeQuietly(); }
}
private void closeQuietly() {
// 先关输出流触发对端 FIN,再关 socket;顺序反了可能丢数据
try { if (out != null) out.close(); } catch (IOException ignored) { }
try { if (in != null) in.close(); } catch (IOException ignored) { }
try { if (socket != null) socket.close(); } catch (IOException ignored) { }
socket = null; in = null; out = null;
}
}
package com.fastbee.collector.driver.modbus;
/** 一个连续的读取区间 */
public final class AddressRange {
public final byte fc;
public final int start; // 0 基起始地址
public final int qty; // 字数或位数
public AddressRange(byte fc, int start, int qty) {
this.fc = fc; this.start = start; this.qty = qty;
}
}
2.4 SPI 注册(JDK ServiceLoader,零第三方)
package com.fastbee.collector.driver;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/** 驱动注册表:按协议名查找实现 */
public final class DriverRegistry {
private static final Map<String, DriverFactory> FACTORIES = new ConcurrentHashMap<>();
static {
// 扫描 META-INF/services/com.fastbee.collector.driver.DriverFactory
for (DriverFactory f : ServiceLoader.load(DriverFactory.class)) {
FACTORIES.put(f.protocol().toLowerCase(Locale.ROOT), f);
}
}
private DriverRegistry() { }
public interface DriverFactory {
String protocol();
ProtocolDriver create(DeviceConfig cfg);
}
/** 手工注册(Spring 环境下更常用,便于注入依赖) */
public static void register(String protocol, DriverFactory f) {
FACTORIES.put(protocol.toLowerCase(Locale.ROOT), f);
}
public static ProtocolDriver create(String protocol, DeviceConfig cfg)
throws DriverException {
DriverFactory f = FACTORIES.get(protocol.toLowerCase(Locale.ROOT));
if (f == null) throw new DriverException("unsupported protocol: " + protocol);
return f.create(cfg);
}
public static Set<String> protocols() {
return Collections.unmodifiableSet(FACTORIES.keySet());
}
}
package com.fastbee.collector.driver.modbus;
import com.fastbee.collector.driver.*;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
public class ModbusDriverFactory implements DriverRegistry.DriverFactory {
@PostConstruct public void init() {
DriverRegistry.register("modbus-tcp", this);
}
@Override public String protocol() { return "modbus-tcp"; }
@Override public ProtocolDriver create(DeviceConfig cfg) {
return new ModbusTcpDriver(cfg.unitId); // 连接交给 DeviceConnection 统一驱动
}
}
第 3 章 连接管理
3.1 连接状态机
┌──────────┐
│ IDLE │ 初始 / 已禁用
└────┬─────┘
│ start()
▼
┌─────────────────┐
│ CONNECTING │◄──────────────┐
│ (建连 / 握手) │ │
└────┬───────┬────┘ │
│ │ │
成功 │ │ 失败 │ 退避到期
▼ │ │
┌────────────────┐│ ┌───────┴────────┐
│ ONLINE ││ │ RECOVERING │
│ (可正常读写) ││ │ (指数退避+抖动) │
└──┬─────────┬──┘│ └───────▲────────┘
│ │ │ │
读写异常/超时│ 心跳连续 │ │
│ 失败 N 次 │ │
▼ ▼ │
┌────────────────────┐ │
│ OFFLINE ├───────────────┘
│ (已判定失效) │
└─────────┬──────────┘
│ 重试次数 > max
▼
┌────────────────────┐
│ FAILED │ 告警 / 人工介入
└────────────────────┘
| 状态 | 含义 | 可执行 | 进入条件 |
|---|---|---|---|
IDLE |
未启动 | start() | 初始化 |
CONNECTING |
建连/握手中 | 等待 | start() 或退避到期 |
ONLINE |
可用 | read / write / heartbeat | 握手成功 |
OFFLINE |
已判定失效 | 触发重连 | 读写异常 / 心跳连续失败 |
RECOVERING |
退避等待中 | 等待 → CONNECTING | OFFLINE 后 |
FAILED |
放弃 | 人工介入 | 重试超上限 |
3.2 指数退避 + 抖动
package com.fastbee.collector.connection;
import java.util.concurrent.ThreadLocalRandom;
/**
* 重连退避:指数退避 + 抖动。
* 为什么必须抖动:100 台设备一起掉电时,不抖动会在同一秒集体重连,
* 形成"重连风暴",把 PLC 本就不多的连接资源(S7-1200 仅 8 个)再次打满。
*/
public final class BackoffPolicy {
private final long baseDelayMs, maxDelayMs;
private final double jitterRatio;
public BackoffPolicy(long baseDelayMs, long maxDelayMs, double jitterRatio) {
this.baseDelayMs = baseDelayMs;
this.maxDelayMs = maxDelayMs;
this.jitterRatio = jitterRatio;
}
/** 工业现场推荐:1s 起步,60s 封顶,±30% 抖动 */
public static BackoffPolicy industrialDefaults() {
return new BackoffPolicy(1_000L, 60_000L, 0.3);
}
/** 第 attempt 次(从 1 开始)重试的等待时间 */
public long nextDelay(int attempt) {
if (attempt < 1) attempt = 1;
long v = baseDelayMs;
for (int i = 1; i < attempt; i++) {
v <<= 1;
if (v >= maxDelayMs || v <= 0) { v = maxDelayMs; break; }
}
v = Math.min(v, maxDelayMs);
double f = 1.0 + (ThreadLocalRandom.current().nextDouble() * 2.0 - 1.0) * jitterRatio;
return (long) (v * f);
}
}
| 重试次数 | 无抖动 | 带 ±30% 抖动 |
|---|---|---|
| 1 | 1s | 0.7 ~ 1.3s |
| 2 | 2s | 1.4 ~ 2.6s |
| 3 | 4s | 2.8 ~ 5.2s |
| 4 | 8s | 5.6 ~ 10.4s |
| 5 | 16s | 11.2 ~ 20.8s |
| 6 | 32s | 22.4 ~ 41.6s |
| 7+ | 60s | 42 ~ 60s |
3.3 心跳:为什么不能只靠 TCP KeepAlive
| 方案 | 默认时间 | 够用吗 |
|---|---|---|
| TCP KeepAlive(Linux 默认) | tcp_keepalive_time = 7200s(2 小时) |
❌ 完全不够 |
| TCP KeepAlive(调参后) | 可到 60s | 🟡 能检测但粒度粗 |
| 应用层心跳 | 自定义,通常 1~5s | ✅ 推荐 |
原因:PLC 掉电或网线被拔时,操作系统不会立刻发 FIN,本端 socket 会一直"看起来连着",直到下次真正发数据才暴露(可能几十秒后)。应用层心跳是唯一可靠手段。
package com.fastbee.collector.connection;
import com.fastbee.collector.driver.*;
/** 应用层心跳:周期执行最小读取;连续失败 N 次判定 OFFLINE */
public final class HeartbeatTask implements Runnable {
private final DeviceConnection connection;
private final ProtocolDriver driver;
private final int maxFail;
private int consecutiveFail = 0;
public HeartbeatTask(DeviceConnection connection, ProtocolDriver driver, int maxFail) {
this.connection = connection;
this.driver = driver;
this.maxFail = maxFail;
}
@Override public void run() {
// 不在 ONLINE 就交给重连逻辑,避免重复判定
if (connection.getState() != ConnectionState.ONLINE) return;
try {
driver.isConnected();
consecutiveFail = 0;
} catch (Exception e) {
if (++consecutiveFail >= maxFail) {
connection.markOffline(); // OFFLINE → RECOVERING
}
}
}
}
3.4 连接数预算
| 机型 | 建议单 IP 上限 | 备注 |
|---|---|---|
| S7-1200 | 2~3 | 留 5 个给 PG/HMI/Web(需实测确认) |
| S7-1500 | 4~8 | 资源较充裕 |
| S7-300/400 | 2~4 | 保守 |
| 三菱 FX5U / Q | 1~2 | 模块通常只配 1~4 路 |
| Modbus TCP 网关 | 看网关规格 | 部分网关仅支持 1~4 路 |
package com.fastbee.collector.connection;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.Semaphore;
/** 连接数预算:全局 + 单 IP 双层限流,防止打满 PLC 连接资源 */
public final class ConnectionBudget {
private final Semaphore global;
private final int perHostLimit;
private final Map<String, Semaphore> perHost = new ConcurrentHashMap<>();
public ConnectionBudget(int globalLimit, int perHostLimit) {
this.global = new Semaphore(globalLimit, true);
this.perHostLimit = perHostLimit;
}
/** 申请配额;拿不到返回 false(不要无限等待,否则线程堆积) */
public boolean acquire(String host, long timeoutMs) throws InterruptedException {
Semaphore h = perHost.computeIfAbsent(host, k -> new Semaphore(perHostLimit, true));
if (!h.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) return false;
if (!global.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
h.release();
return false;
}
return true;
}
public void release(String host) {
Semaphore s = perHost.get(host);
if (s != null) s.release();
global.release();
}
}
第 4 章 采集调度器(核心实现)
4.1 设计要点
| 要点 | 实现 |
|---|---|
| 跨设备并行 | 固定 worker 线程池,设备之间互不阻塞 |
| 单设备串行 | 每设备一个 ReentrantLock,tryLock 拿不到就跳过本轮 |
| 频率分桶 | DelayQueue 天然实现——每个任务到期时刻不同 |
| 超时控制 | pump 线程用 Future.get(timeout) 兜底 |
| 抖动控制 | 注册时随机初始延迟,打散峰值 |
4.2 任务与监听器
package com.fastbee.collector.scheduler;
import java.util.concurrent.*;
/** 一个到期的采集任务,放入 DelayQueue,执行完再排下一轮 */
public final class Task implements Delayed {
public final String deviceId;
public final long periodMs;
public final long dueTimeMs;
public Task(String deviceId, long periodMs, long dueTimeMs) {
this.deviceId = deviceId;
this.periodMs = periodMs;
this.dueTimeMs = dueTimeMs;
}
/** 从"现在"起算排下一轮,避免设备变慢后的追赶式雪崩 */
public Task next() {
return new Task(deviceId, periodMs, System.currentTimeMillis() + periodMs);
}
@Override public long getDelay(TimeUnit unit) {
return unit.convert(dueTimeMs - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override public int compareTo(Delayed o) {
return Long.compare(this.dueTimeMs, ((Task) o).dueTimeMs);
}
}
package com.fastbee.collector.scheduler;
import com.fastbee.collector.driver.*;
import java.util.List;
/** 采集结果监听器,由上行层实现 */
public interface CollectListener {
void onData(String deviceId, List<TagValue> values);
void onError(String deviceId, List<TagConfig> tags, Throwable cause);
}
package com.fastbee.collector.scheduler;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/** 带名字的线程工厂:jstack 里能一眼认出是哪个池 */
public final class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger seq = new AtomicInteger(1);
public NamedThreadFactory(String prefix) { this.prefix = prefix; }
@Override public Thread newThread(Runnable r) {
return new Thread(r, prefix + "-" + seq.getAndIncrement());
}
}
4.3 调度器完整实现
package com.fastbee.collector.scheduler;
import com.fastbee.collector.connection.*;
import com.fastbee.collector.driver.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
/**
* 采集调度器。
*
* <pre>
* DelayQueue ──► pump 线程 ──► workerPool(跨设备并行)
* └─► deviceLock(单设备串行)
* </pre>
*/
public class CollectScheduler implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(CollectScheduler.class);
private final DelayQueue<Task> queue = new DelayQueue<>();
private final ExecutorService workers;
private final Thread pumpThread;
private final Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();
private final Map<String, DeviceConnection> devices = new ConcurrentHashMap<>();
private final CollectListener listener;
private final long perDeviceTimeoutMs;
private volatile boolean running = false;
private final AtomicLong totalRounds = new AtomicLong();
private final AtomicLong skippedRounds = new AtomicLong();
private final AtomicLong failedRounds = new AtomicLong();
public CollectScheduler(int workerThreads, long perDeviceTimeoutMs,
CollectListener listener) {
this.workers = new ThreadPoolExecutor(
workerThreads, workerThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(10_000),
new NamedThreadFactory("collect-worker"),
// 队列满时由触发线程兜底执行,防止静默丢任务
new ThreadPoolExecutor.CallerRunsPolicy());
this.pumpThread = new NamedThreadFactory("collect-pump").newThread(this::pump);
this.listener = listener;
this.perDeviceTimeoutMs = perDeviceTimeoutMs;
}
public void register(DeviceConnection conn, long periodMs) {
String id = conn.getDeviceId();
devices.put(id, conn);
locks.putIfAbsent(id, new ReentrantLock());
// 抖动:首次到期时刻随机铺开在一个周期内,避免同时刻集体采集
queue.put(new Task(id, periodMs,
System.currentTimeMillis() + (long) (Math.random() * periodMs)));
log.info("device registered: {}, period={}ms", id, periodMs);
}
public void unregister(String deviceId) {
devices.remove(deviceId);
locks.remove(deviceId);
}
public void start() {
if (running) return;
running = true;
pumpThread.start();
log.info("scheduler started, devices={}", devices.size());
}
/** pump:取到期任务 → 提交 worker → 等结果 → 排下一轮 */
private void pump() {
while (running) {
try {
Task task = queue.take();
totalRounds.incrementAndGet();
Future<?> f = workers.submit(() -> collectOnce(task));
try {
f.get(perDeviceTimeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
f.cancel(true);
log.warn("collect timeout, device={}", task.deviceId);
} catch (Exception e) {
log.error("collect error, device={}", task.deviceId, e);
}
queue.put(task.next()); // 无论成败都排下一轮
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Throwable t) {
// 绝不能让 pump 抛出,否则整个调度静默停止
log.error("pump error", t);
}
}
}
private void collectOnce(Task task) {
DeviceConnection conn = devices.get(task.deviceId);
ReentrantLock lock = locks.get(task.deviceId);
if (conn == null || lock == null) return;
// 单设备串行:上一轮还没结束就跳过本轮
try {
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) {
skippedRounds.incrementAndGet();
log.debug("device {} busy, skip round", task.deviceId);
return;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
try {
List<TagConfig> tags = conn.getTags();
if (tags.isEmpty()) return;
conn.ensureConnected(); // 状态机驱动
listener.onData(task.deviceId, conn.getDriver().batchRead(tags));
} catch (Exception e) {
failedRounds.incrementAndGet();
log.warn("collect failed, device={}", task.deviceId, e);
conn.markOffline();
// 失败也要上报,让平台看到 BAD,而不是"数据不动"
listener.onError(task.deviceId, conn.getTags(), e);
} finally {
lock.unlock();
}
}
public long getTotalRounds() { return totalRounds.get(); }
public long getSkippedRounds() { return skippedRounds.get(); }
public long getFailedRounds() { return failedRounds.get(); }
@Override
public void close() {
running = false;
pumpThread.interrupt();
workers.shutdown();
try {
if (!workers.awaitTermination(10, TimeUnit.SECONDS)) workers.shutdownNow();
} catch (InterruptedException e) {
workers.shutdownNow();
Thread.currentThread().interrupt();
}
devices.values().forEach(DeviceConnection::close);
}
}
4.4 频率分桶与线程池 sizing
| 周期 | 建议桶规模 | 说明 |
|---|---|---|
| 100ms | ≤ 20 台 | 高频桶要小,否则单轮做不完 |
| 500ms | ≤ 50 台 | 常规 |
| 1s | ≤ 100 台 | |
| 5s+ | 不限 | 低速,可合批 |
workerThreads ≈ min(ceil(总设备数 / 20), 200)
或 CPU核数 × 4(I/O 密集型)
第 5 章 数据处理链与数据模型
5.1 处理链七步
raw[]
│ ① 字节序重排 ABCD / BADC / CDAB / DCBA(见 01 篇 03)
▼
② 类型解码 INT16 / UINT16 / INT32 / FLOAT32 / BOOL(位) / BCD
│ ③ 线性缩放 value = raw × factor + offset
▼
④ 死区判断 |value − last| ≥ deadband ? 上报 : 丢弃
│ ⑤ 质量戳 GOOD / UNCERTAIN / BAD
▼
⑥ 时间戳 采集时刻(本地) vs PLC 时钟(若可读)
│ ⑦ 上报模型 单点 / 批量 / 变化上报
▼
MQTT
5.2 字节序与处理链
package com.fastbee.collector.driver;
/**
* 四字节序(工控习惯:A=最高字节 … D=最低字节,原始报文顺序 A B C D)
* ABCD 大端 —— 西门子 S7
* DCBA 小端 —— 多数 32 位浮点在 Modbus 中的排列
* BADC 大端 + 字内字节交换
* CDAB 小端 + 字序交换 —— Modbus 常见"字序反了"
*/
public enum ByteOrder {
ABCD, BADC, CDAB, DCBA;
/** 重排 4 字节;入参 b 为按报文顺序读到的原始字节 */
public byte[] rearrange(byte[] b) {
if (b == null || b.length != 4) {
throw new IllegalArgumentException("need exactly 4 bytes");
}
byte a = b[0], c = b[1], d = b[2], e = b[3];
switch (this) {
case ABCD: return new byte[]{a, c, d, e};
case BADC: return new byte[]{c, a, e, d};
case CDAB: return new byte[]{d, e, a, c};
case DCBA: return new byte[]{e, d, c, a};
default: throw new IllegalStateException();
}
}
public boolean swapBytesInWord() { return this == BADC || this == DCBA; }
public boolean swapWords() { return this == CDAB || this == DCBA; }
}
package com.fastbee.collector.pipeline;
import com.fastbee.collector.driver.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 数据处理链:字节序 → 解码 → 缩放 → 死区 → 质量戳 → 时间戳。
* 死区依赖上次的值,因此本类<b>有状态</b>,每个设备一个实例。
*/
public class DataPipeline {
private final Map<String, Double> lastValues = new ConcurrentHashMap<>();
private final ByteOrder byteOrder;
public DataPipeline(ByteOrder byteOrder) {
this.byteOrder = (byteOrder == null) ? ByteOrder.ABCD : byteOrder;
}
/** @param sampledAt 采样时刻(建议在 read 返回瞬间取,不要等处理完再取) */
public List<TagValue> process(List<TagConfig> tags,
Map<String, byte[]> rawData,
long sampledAt) {
List<TagValue> out = new ArrayList<>(tags.size());
for (TagConfig tag : tags) {
byte[] raw = rawData.get(tag.id);
if (raw == null || raw.length < tag.byteLength()) {
out.add(TagValue.bad(tag, sampledAt)); // ⑤ 质量戳
continue;
}
try {
long v = decode(raw, tag.dataType, byteOrder); // ①②
double eng = v * tag.factor + tag.offset; // ③
Double last = lastValues.get(tag.id);
if (last != null && tag.deadband > 0
&& Math.abs(eng - last) < tag.deadband) {
continue; // ④ 变化太小
}
lastValues.put(tag.id, eng);
out.add(TagValue.good(tag, v, eng, sampledAt)); // ⑥
} catch (Exception e) {
out.add(TagValue.bad(tag, sampledAt));
}
}
return out;
}
/** 解码为 long;浮点返回 IEEE754 位模式,调用方再 Double.longBitsToDouble */
private static long decode(byte[] raw, DataType t, ByteOrder o) {
switch (t) {
case BOOL: return raw[0] & 0x01;
case INT16:
case UINT16: {
byte[] w = o.swapBytesInWord() ? new byte[]{raw[1], raw[0]}
: new byte[]{raw[0], raw[1]};
int v = ((w[0] & 0xFF) << 8) | (w[1] & 0xFF);
return (t == DataType.INT16) ? (short) v : (v & 0xFFFFL);
}
case INT32:
case UINT32:
case FLOAT32: {
byte[] r = o.rearrange(new byte[]{raw[0], raw[1], raw[2], raw[3]});
long v = ((long)(r[0] & 0xFF) << 24) | ((long)(r[1] & 0xFF) << 16)
| ((long)(r[2] & 0xFF) << 8) | ((long)(r[3] & 0xFF));
return (t == DataType.INT32) ? (int) v : (v & 0xFFFFFFFFL);
}
case FLOAT64: {
byte[] r = new byte[8];
if (o.swapWords()) { // 先换字序
System.arraycopy(raw, 4, r, 0, 4);
System.arraycopy(raw, 0, r, 4, 4);
} else {
System.arraycopy(raw, 0, r, 0, 8);
}
if (o.swapBytesInWord()) { // 再换字内字节序
for (int i = 0; i < 8; i += 2) {
byte tmp = r[i]; r[i] = r[i + 1]; r[i + 1] = tmp;
}
}
return Double.doubleToRawLongBits(java.nio.ByteBuffer.wrap(r).getDouble());
}
default: throw new IllegalArgumentException("unsupported: " + t);
}
}
/** 重连后清空死区缓存,避免"恢复后首包被死区吃掉" */
public void reset() { lastValues.clear(); }
}
5.3 上报模型
| 模式 | 适用 | 载荷 |
|---|---|---|
| 单点上报 | 点很少、要实时 | 一个测点一条消息 |
| 批量上报 ⭐ 推荐 | 常规 | 一台设备一轮一条消息,内含 values[] |
| 变化上报 | 点多、大部分不变 | 只发死区判定为"变化"的点 |
package com.fastbee.collector.report;
import com.fastbee.collector.driver.TagValue;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/**
* 上报载荷(批量模式)。
* ⚠️ 字段名需与 fastbee-server 物模型接收端对齐,此处为建议结构(需核实)。
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ReportPayload {
public String productId;
public String deviceNum;
public long timestamp;
public long seq; // 平台用于检测丢包
public List<TagValue> values;
public ReportPayload() { }
public ReportPayload(String productId, String deviceNum, long timestamp,
long seq, List<TagValue> values) {
this.productId = productId;
this.deviceNum = deviceNum;
this.timestamp = timestamp;
this.seq = seq;
this.values = values;
}
}
payload 示例:
{
"productId": "P1003",
"deviceNum": "DEV0003",
"timestamp": 1769558400123,
"seq": 10237,
"values": [
{ "key": "flow", "value": 12.34, "quality": "GOOD", "ts": 1769558400123 },
{ "key": "valve_open", "value": 1, "quality": "GOOD", "ts": 1769558400123 },
{ "key": "pressure", "value": null, "quality": "BAD", "ts": 1769558400123 }
]
}
第 6 章 断网续传:环形缓冲 + WAL
采集成功
│
▼
┌────────────────────────┐
│ ① 内存环形缓冲 │ 容量固定(如 4096 条)
│ RingBuffer<TagValue> │ 满了 → 覆盖最老 或 丢弃最新(可配)
└──────────┬─────────────┘
│ 异步刷盘
▼
┌────────────────────────┐
│ ② WAL 落盘 │ append-only 顺序写,无随机 IO
│ collect.wal │ 每条:len(4) | crc(4) | payload(len)
└──────────┬─────────────┘
│ MQTT 恢复后回放
▼
┌────────────────────────┐
│ ③ 上报 + 确认 + 推进 │ QoS1 确认一条,checkpoint 前进一步
└────────────────────────┘
package com.fastbee.collector.buffer;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* 固定容量环形缓冲(数组实现)。
* DROP_OLDEST:只要最新状态(遥测场景);DROP_NEWEST:历史不能断。
*/
public final class RingBuffer<E> {
public enum Overflow { DROP_OLDEST, DROP_NEWEST }
private final Object[] slots;
private final int capacity;
private final Overflow overflow;
private volatile long writeSeq = 0;
private volatile long readSeq = 0;
private final AtomicLong dropped = new AtomicLong();
public RingBuffer(int capacity, Overflow overflow) {
this.capacity = capacity;
this.overflow = overflow;
this.slots = new Object[capacity];
}
public synchronized boolean offer(E e) {
if (writeSeq - readSeq >= capacity) {
dropped.incrementAndGet();
if (overflow == Overflow.DROP_NEWEST) return false;
readSeq++; // 丢最老
}
slots[(int) (writeSeq % capacity)] = e;
writeSeq++;
return true;
}
@SuppressWarnings("unchecked")
public synchronized E poll() {
if (readSeq >= writeSeq) return null;
int i = (int) (readSeq % capacity);
E e = (E) slots[i];
slots[i] = null;
readSeq++;
return e;
}
/** 批量取出最多 max 条 */
public synchronized List<E> drain(int max) {
List<E> list = new ArrayList<>(Math.min(max, size()));
while (list.size() < max) {
E e = poll();
if (e == null) break;
list.add(e);
}
return list;
}
public synchronized int size() { return (int) (writeSeq - readSeq); }
public int capacity() { return capacity; }
public long getDroppedCount() { return dropped.get(); }
}
package com.fastbee.collector.buffer;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.util.zip.CRC32;
/**
* 落盘 WAL:append-only 顺序写,进程崩溃后可回放。
* 每条记录: len(4) | crc(4) | payload(len)
*
* <b>骨架</b>:省略了文件滚动与 checkpoint 持久化,生产实现需补全。
*/
public class WalStore implements AutoCloseable {
private static final int HEADER_SIZE = 8;
private static final long MAX_FILE_SIZE = 64L * 1024 * 1024; // 64MB 后滚动
private final FileChannel channel;
/** 已确认上报到的字节位点 */
private long checkpoint = 0;
public WalStore(Path dir) throws IOException {
Files.createDirectories(dir);
this.channel = FileChannel.open(dir.resolve("collect.wal"),
StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE);
this.channel.position(this.channel.size()); // 追加模式
}
public synchronized void append(byte[] payload) throws IOException {
if (channel.size() >= MAX_FILE_SIZE) roll();
CRC32 crc = new CRC32();
crc.update(payload);
ByteBuffer header = ByteBuffer.allocate(HEADER_SIZE);
header.putInt(payload.length);
header.putInt((int) crc.getValue());
header.flip();
channel.write(header);
channel.write(ByteBuffer.wrap(payload));
}
/** 从 checkpoint 回放;CRC 不匹配即停止,避免脏数据无限传播 */
public synchronized List<byte[]> replay() throws IOException {
List<byte[]> out = new ArrayList<>();
channel.position(checkpoint);
ByteBuffer lenBuf = ByteBuffer.allocate(4);
while (channel.read(lenBuf) == 4) {
lenBuf.flip();
int len = lenBuf.getInt();
lenBuf.clear();
if (len <= 0 || len > 1024 * 1024) break; // 损坏,停
ByteBuffer crcBuf = ByteBuffer.allocate(4);
if (channel.read(crcBuf) < 4) break;
crcBuf.flip();
int expectCrc = crcBuf.getInt();
ByteBuffer data = ByteBuffer.allocate(len);
if (channel.read(data) < len) break;
data.flip();
CRC32 crc = new CRC32();
crc.update(data.array());
if ((int) crc.getValue() != expectCrc) break;
out.add(data.array());
checkpoint += HEADER_SIZE + len;
}
channel.position(channel.size()); // 回到追加模式
return out;
}
public synchronized void commit(long offset) { this.checkpoint = offset; }
private void roll() throws IOException {
// 生产实现:关闭当前文件 → 重命名 → 新建;并按配额清理最老段
}
@Override
public void close() {
try { channel.force(true); channel.close(); } catch (IOException ignored) { }
}
}
容量规划:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 环形缓冲容量 | 4096 条 | 约 4~8MB(按每条 1~2KB) |
| WAL 单文件上限 | 64 MB | 超过滚动 |
| WAL 磁盘配额 | 1~2 GB | 超过丢最老 |
| fsync 策略 | 每 1s 或每 1000 条 | 同步刷盘太慢,丢 1s 可接受 |
| 补报限速 | ≤ 正常速率 2 倍 | 避免恢复瞬间打垮平台 |
第 6 章(续)写操作安全
6.1 三条原则
| 原则 | 说明 |
|---|---|
| 幂等 | 同一 requestId 重复执行结果一致,服务端去重 |
| 回读校验 | 写完读回比对,不一致告警并有限重试 |
| 写互斥 | 写与读共用设备锁,保护"一问一答"串行性 |
6.2 为什么必须串行
❌ 并发访问会破坏两阶段读:
线程A: [写请求]────────────────────►
线程B: [读请求]─────────────────►
PLC : ◄──[写响应]──┐
└─ A、B 同时从同一 InputStream
做两阶段读 → 抢字节 → 帧错位
→ 表现为"偶发数据错",极难复现
6.3 写服务实现
package com.fastbee.collector.write;
import com.fastbee.collector.driver.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** 写操作服务:幂等 + 回读校验。互斥由驱动内部 roundTripLock 保证。 */
public class WriteService {
private static final Logger log = LoggerFactory.getLogger(WriteService.class);
private final Map<String, WriteResult> processed = new ConcurrentHashMap<>();
private final Set<String> inflight = ConcurrentHashMap.newKeySet();
private final int maxRetry;
public WriteService(int maxRetry) { this.maxRetry = maxRetry; }
public WriteResult write(String requestId, ProtocolDriver driver,
TagConfig tag, byte[] value) {
WriteResult cached = processed.get(requestId);
if (cached != null) return cached; // ① 幂等
if (!inflight.add(requestId)) {
return WriteResult.rejected("request is processing: " + requestId);
}
try {
WriteResult r = doWrite(driver, tag, value);
processed.put(requestId, r);
return r;
} finally {
inflight.remove(requestId);
}
}
private WriteResult doWrite(ProtocolDriver driver, TagConfig tag, byte[] value) {
for (int attempt = 1; attempt <= maxRetry; attempt++) {
try {
driver.write(tag, value); // 驱动内部已加锁
if (tag.readback) { // ② 回读校验
if (!same(driver.read(tag), value)) {
log.warn("readback mismatch, tag={}, attempt={}", tag.key, attempt);
continue;
}
}
return WriteResult.success();
} catch (DriverException e) {
// 协议层拒绝(如 PLC 在 RUN 且禁止运行中写入 → 三菱 0x0055)
if (!e.shouldReconnect()) return WriteResult.failed(e.getMessage());
log.warn("write attempt {} failed, tag={}", attempt, tag.key, e);
}
}
return WriteResult.failed("exceeded max retry " + maxRetry);
}
private static boolean same(TagValue back, byte[] expect) {
if (back == null || !back.isGood()) return false;
long a = (long) back.value;
long b = ((long)(expect[0] & 0xFF) << 8) | (expect.length > 1 ? (expect[1] & 0xFF) : 0);
return a == b;
}
public static final class WriteResult {
public final boolean success;
public final String message;
private WriteResult(boolean s, String m) { success = s; message = m; }
public static WriteResult success() { return new WriteResult(true, "OK"); }
public static WriteResult failed(String msg) { return new WriteResult(false, msg); }
public static WriteResult rejected(String msg) { return new WriteResult(false, msg); }
}
}
6.4 写安全清单
□ 写请求带 requestId(UUID),服务端按 requestId 去重
□ 写前校验目标值在量程内(避免把 0x7FFF 写进去)
□ 写操作走独立的低频队列,不与高频采集争锁
□ 关键控制点写后回读校验
□ 落审计日志:谁 / 何时 / 写了什么 / 前后值
□ 失败不要无限重试,超过上限就告警
□ PLC 在 RUN 且未允许"运行中写入"时写会失败
第 7 章 配置模型:设备-通道-测点三层
7.1 三层模型
设备 Device(一台 PLC / 一个网关)
└── 通道 Channel(一条物理连接:IP + 端口 + 协议参数)
└── 测点 Tag(一个数据点:地址 + 类型 + 周期 + 缩放 + 死区)
| 层 | 职责 | 关键字段 |
|---|---|---|
| Device | 业务身份 | deviceId、productId、deviceNum、enabled |
| Channel | 通信参数 | 协议、IP、端口、超时、站号/机架槽号 |
| Tag | 数据定义 | 地址、数据类型、周期、缩放、死区、权限 |
为什么要 Channel 这一层:一台 PLC 可能有多个网口;一个网关下挂多个从站。把"连接"抽出来,才能让多个逻辑设备共享一条物理连接(或在需要时隔离)。
7.2 完整 YAML 示例
# collector-devices.yaml
collector:
workerThreads: 32
perDeviceTimeoutMs: 3000
reportMode: BATCH # SINGLE | BATCH | ON_CHANGE
mqtt:
brokerUrl: tcp://127.0.0.1:1883
clientId: fastbee-collector-01
username: collector
password: '******'
qos: 1
keepAliveSec: 60
wal:
enabled: true
dir: ./data/wal
maxFileSizeMb: 64
totalQuotaMb: 1024
devices:
# ================= 设备 1:西门子 S7-1200 =================
- deviceId: dev-s7-1200-01
productId: 'P1001'
deviceNum: 'DEV0001'
enabled: true
channels:
- channelId: ch-s7-main
protocol: s7
host: 192.168.1.110
port: 102
connectTimeoutMs: 3000
readTimeoutMs: 2000
plcType: S1200 # S200/S200_SMART/S300/S400/S1200/S1500/SINUMERIK_828D
rack: 0
slot: 1
heartbeatMs: 3000
tags:
- key: temperature
address: DB1.0
dataType: FLOAT32
periodMs: 1000
factor: 0.1
offset: 0
deadband: 0.5
access: R
- key: pressure
address: DB1.4
dataType: INT16
periodMs: 1000
factor: 1.0
deadband: 1
access: R
- key: run_status
address: DB1.6.0 # DB1,字节 6,位 0
dataType: BOOL
periodMs: 500
access: R
- key: set_speed
address: DB1.10
dataType: INT16
periodMs: 0 # 0 = 不轮询,仅下发
access: RW
readback: true
# ================= 设备 2:三菱 Q 系列 =================
- deviceId: dev-mc-q-01
productId: 'P1002'
deviceNum: 'DEV0002'
enabled: true
channels:
- channelId: ch-mc-main
protocol: mc
host: 192.168.1.120
port: 6000
connectTimeoutMs: 3000
readTimeoutMs: 2000
series: Q_L # A / QnA / Q_L / IQ_R
frameType: FRAME_3E # FRAME_1E / FRAME_3E / FRAME_4E
monitorTimer: 10 # CPU 监视定时器:值 × 250ms → 10 = 2.5s
heartbeatMs: 3000
tags:
- key: counter
address: D100
dataType: INT32
periodMs: 1000
access: R
- key: alarm
address: M100
dataType: BOOL
periodMs: 500
access: R
# ================= 设备 3:Modbus TCP 仪表 =================
- deviceId: dev-modbus-01
productId: 'P1003'
deviceNum: 'DEV0003'
enabled: true
channels:
- channelId: ch-modbus-main
protocol: modbus-tcp
host: 192.168.1.130
port: 502
unitId: 1
connectTimeoutMs: 3000
readTimeoutMs: 2000
byteOrder: CDAB # ABCD / BADC / CDAB / DCBA —— 见 01 篇 03
heartbeatMs: 5000
tags:
- key: flow
address: '40001'
dataType: FLOAT32
periodMs: 1000
factor: 0.01
deadband: 0.1
access: R
- key: valve_open
address: '00001'
dataType: BOOL
periodMs: 500
access: RW
readback: true
7.3 等价 JSON(供接口下发)
{
"deviceId": "dev-modbus-01",
"productId": "P1003",
"deviceNum": "DEV0003",
"enabled": true,
"channels": [
{
"channelId": "ch-modbus-main",
"protocol": "modbus-tcp",
"host": "192.168.1.130",
"port": 502,
"unitId": 1,
"connectTimeoutMs": 3000,
"readTimeoutMs": 2000,
"byteOrder": "CDAB",
"heartbeatMs": 5000,
"tags": [
{ "key": "flow", "address": "40001", "dataType": "FLOAT32",
"periodMs": 1000, "factor": 0.01, "offset": 0,
"deadband": 0.1, "access": "R" },
{ "key": "valve_open", "address": "00001", "dataType": "BOOL",
"periodMs": 500, "access": "RW", "readback": true }
]
}
]
}
第 8 章 与 FastBee 的对接
⚠️ 重要声明:本章的物模型映射与 MQTT 主题格式为基于通用物联网平台实践的推荐设计。FastBee 的实际主题格式、物模型字段名、payload schema 需以
fastbee-server/fastbee-collector源码与官方文档为准;此处结构应视为"建议起点",不是已核实事实。
8.1 映射关系
FastBee 平台侧 采集器侧
──────────────────────────────────────────────────────
产品 Product(productId) ◄──► 协议模板(如"S7-1200 通用模板")
│ 1 : N
▼
设备 Device(deviceNum) ◄──► DeviceConfig + Channel(IP/端口/机架槽号)
│ 1 : N
▼
物模型 ThingModel ◄──► TagConfig
├─ 属性 Property (地址/类型/周期/缩放/死区)
├─ 功能 Function(可写)
└─ 事件 Event
| FastBee 概念 | 采集器概念 | 要点 |
|---|---|---|
产品 productId |
协议模板 | 同型号复用一套点表 |
设备 deviceNum |
DeviceConfig |
通常 1 deviceNum = 1 连接 |
| 物模型属性 identifier | TagConfig.key |
必须一致,否则平台无法入库 |
| 属性数据类型 | DataType |
int/decimal/bool/string 要对上 |
| 功能(可写) | access: RW |
平台下行到采集器执行写 |
8.2 建议的 MQTT 主题
【上行:采集器 → 平台】
属性上报 {prefix}/{productId}/{deviceNum}/property/post
事件上报 {prefix}/{productId}/{deviceNum}/event/post/{eventId}
设备状态 {prefix}/{productId}/{deviceNum}/status/post
【下行:平台 → 采集器】
属性设置 {prefix}/{productId}/{deviceNum}/property/set
功能调用 {prefix}/{productId}/{deviceNum}/function/invoke
配置下发 {prefix}/{productId}/{deviceNum}/config/push
{prefix} 建议留空或统一配置,与平台保持一致。
下行 payload 示例(含幂等键):
{
"requestId": "b3f1c2a4-7d5e-4a1f-9c8b-2e6d0f1a3c5d",
"productId": "P1003",
"deviceNum": "DEV0003",
"key": "valve_open",
"value": 1
}
8.3 会话与遗嘱
【Client ID】fastbee-collector-{实例编号}-{随机后缀}
多实例部署必须唯一,否则互相踢线
【遗嘱 LWT】
topic: {prefix}/{productId}/{deviceNum}/status/post
payload: {"status":"offline","ts":<epoch>}
qos: 1 retain: false
→ 进程异常退出时 Broker 代发,平台能立刻看到"离线"
【Retain】
设备最新状态可用 retain=true 发到 status 主题,
平台重启后能立即拿到当前状态。
8.4 TDengine 落库建议
| 决策 | 建议 |
|---|---|
| 超级表 | 一个产品一张超级表,标签 device_num、product_id |
| 子表命名 | t_{productId}_{deviceNum} |
| 列设计 | ts TIMESTAMP + 每个物模型属性一列 + quality 列 |
| 写入方式 | 批量写入(攒批 ≥ 1000 行或 ≥ 1s),单条写入吞吐极差 |
| BAD 处理 | 建议写 NULL 保留时间戳,便于后续插值 |
第 9 章 性能预估表与 Netty 切换点
9.1 BIO「一设备一线程」预算表
前提:局域网(RTT ≈ 0.5~2ms),每设备单连接,周期 500ms~1s。
| 设备数 | 采集线程 | 栈内存(虚拟) | RSS(估) | 堆(buffer+对象) | 单轮耗时 | CPU |
|---|---|---|---|---|---|---|
| 20 | ~22 | 22 MB | ~30 MB | ~2 MB | 2~5 ms | < 3% |
| 50 | ~53 | 53 MB | ~70 MB | ~4 MB | 3~8 ms | < 5% |
| 100 | ~105 | 105 MB | ~130 MB | ~7 MB | 5~15 ms | < 10% |
| 200 | ~210 | 210 MB | ~250 MB | ~14 MB | 8~25 ms | 10~20% |
| 500 | ~510 | 510 MB | ~600 MB | ~35 MB | 20~60 ms | 25~40% |
| 1000 | ~1010 | 1.0 GB | ~1.2 GB | ~70 MB | 40~120 ms | 50%+ ⚠️ |
· 栈按 -Xss1m(64 位 HotSpot 默认)计算,是虚拟内存,不直接占 RSS
· RSS 含 JVM 自身开销(约 150~250MB)+ 线程栈实际触碰页 + 堆
· 堆按每设备 2 个 4KB buffer + 点表对象约 50KB 估算
· 单轮耗时 = 最慢设备的采集耗时(并行执行)
9.2 各规模推荐方案
| 规模 | 推荐 | 理由 |
|---|---|---|
| ≤ 50 台 | BIO + 一设备一线程 | 最简单,最好排查 |
| 50~200 台 | BIO + 共享线程池(本设计) | 线程数与设备数解耦 |
| 200~500 台 | BIO + 线程池 + 收紧连接预算 | 需监控 skipped 指标 |
| 500~1000 台 | ⚠️ 观察期 | skipped/total > 5% 就该换 |
| > 1000 台 | Netty | BIO 线程成本扛不住 |
| 周期 < 200ms | Netty | 无论台数 |
9.3 换 Netty 的触发信号
| 信号 | 阈值 | 动作 |
|---|---|---|
| 线程数 | > 500 | 换 Netty |
| 上下文切换 | vmstat 的 cs > 5 万/秒 |
换 Netty |
| 采集跳过率 | skipped / total > 5% |
先扩线程池,仍不行就换 |
| GC 时间占比 | > 5% | 先查 buffer 分配,再考虑换 |
| 单轮耗时 | > 采集周期的 80% | 优化批量合并,仍不行就换 |
9.4 换内核时接口层不动
改动范围:
✗ CollectScheduler —— 不动(workerPool 换成 Netty EventLoopGroup 提交)
✗ DataPipeline —— 不动
✗ MqttReporter —— 不动
✓ ModbusTcpDriver —— 重写内部收发(BIO Socket → Netty 解码器)
→ 换内核成本 ≈ 重写 driver 包,业务层零改动
Netty 侧定帧(对照 04 篇 §7 的 INetMessage):
// Modbus TCP:跳过前 4 字节,从第 5 字节起 2 字节是长度字段
new LengthFieldBasedFrameDecoder(1024, 4, 2, 0, 0);
第 10 章 小结
10.1 五个最关键的设计决策
| # | 决策 | 为什么 |
|---|---|---|
| 1 | SPI 隔离第三方库 | 授权风险、库 bug、性能瓶颈全部降级为"换一个实现类" |
| 2 | 单设备串行 + 跨设备并行 | 前者是 BIO 两阶段读的正确性要求,后者是吞吐要求 |
| 3 | 重连必须带抖动 | 否则掉电恢复会形成重连风暴,二次打满 PLC |
| 4 | 连接数预算 | S7-1200 只有 8 个连接,不设预算必然踩坑 |
| 5 | 失败也要上报 BAD | "数据不动"比"数据标记为坏"危险得多 |
10.2 落地顺序建议
第 1 周:ProtocolDriver + DeviceConfig/TagConfig + 配置加载
第 2 周:Modbus 驱动(最简单的协议,先跑通全链路)
第 3 周:DeviceConnection 状态机 + 心跳 + 退避重连
第 4 周:CollectScheduler + DataPipeline + MQTT 上行
第 5 周:S7 驱动 + MC 驱动
第 6 周:WAL 落盘 + 断网续传 + 监控指标
先跑通 Modbus 再上 S7/MC:Modbus 无握手、无 TSAP、无子命令,是全链路最快的验证路径。
10.3 相关阅读
| 主题 | 篇目 |
|---|---|
| 两库对比与选型依据 | 06 篇 01 章 |
| 粘包原理与两阶段读 | 02 篇 01 / 04 篇 03 章 |
| 三协议报文细节 | 03 篇 01/02/03 |
| 字节序四顺序 | 01 篇 03 |
| 现场故障排查 | 07 篇 02 |
代码说明:本篇 Java 代码均为可编译的完整实现或明确标注的骨架。骨架部分(如
parseReadResponse的 range→tags 偏移映射、WAL 的文件滚动)已用注释标出补全位置。所有 FastBee 平台侧字段与主题格式均标注「需核实」。
本文是《工业通信与数据采集》栏目第 20/22 篇 · 上一篇:HslCommunication 与 iot-communication 横向对比与选型 | 栏目总览 | 下一篇:速查手册(附录)