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 横向对比与选型 | 栏目总览 | 下一篇:速查手册(附录)