简介:面向微电网监控系统开发者的 IEC104 主站客户端程序,采用 Java 实现,围绕基于 TCP/IP 的电力远动通信场景,可用于理解主站与从站之间上行遥信、遥测及下行遥控、遥调的应答式交互,并对照 U 帧、S 帧、I 帧的 APCI/ASDU 结构开展协议学习与联调。压缩包共 105 个文件,约 3.59MB,含 23 个 Java 源文件、30 个 class 文件、9 个 jar 依赖、22 张 png 图示、10 个 json 数据样例及 properties、xml 等配置,便于查看实现、运行验证和梳理依赖。已有 735 人学习,适合需要参考主站客户端结构、报文解析与设备数据模型的中级开发者。
1. 从一条遥测报文说起:IEC104 主站客户端在微电网里干什么
微电网监控后台要拿到储能柜、逆变器、保护装置的电压、电流、SOC 和开关状态,常见链路是设备侧跑 IEC104 从站,后台侧跑 IEC104 主站客户端。IEC104_microgrid就是后者:Java 写的主站客户端,负责主动连接从站、激活链路、总召唤、周期召唤、解析 ASDU、下发遥控,再把数据交给微电网业务层。它适合正在做电力采集网关、储能 EMS、微电网监控后台的 Java 开发者,尤其是需要把协议栈嵌进 Spring Boot 服务、又不想从零啃 104 帧格式的人。下面按工程结构、核心链路、联调、避坑和二次开发拆开讲。
2. 工程结构与技术选型:Java 主站客户端如何拆出连接、规约和业务三层
2.1 目录结构:从 client 到 protocol 的职责边界
我拿到这类项目,第一眼不会先看启动类,而是看包结构有没有把 TCP 连接、104 规约和业务采集拆开。因为 IEC104 主站客户端的坑大多不在“能不能连上”,而在“连上以后序号、召唤、遥控和业务点表缠在一起”。这个项目的目录大致可以按下面这种方式理解,实际源码包里的类名可能略有差异,但分层逻辑是清楚的:
IEC104_microgrid/ ├── src/main/java/ │ ├── client/ # 主站连接入口 │ │ ├── Iec104Client.java │ │ ├── Iec104ChannelInitializer.java │ │ └── ReconnectHandler.java │ ├── protocol/ # 104 规约对象 │ │ ├── apci/ # I/S/U 帧控制域 │ │ ├── asdu/ # 类型标识、传送原因、信息体 │ │ └── codec/ # 编解码器 │ ├── session/ # 从站会话管理 │ │ ├── SlaveSession.java │ │ └── SessionManager.java │ ├── service/ # 总召唤、遥控、时钟同步 │ │ ├── InterrogationService.java │ │ ├── ControlService.java │ │ └── TelemetryService.java │ ├── config/ # 从站列表和规约参数 │ └── web/ # 可选,对前端暴露查询接口 ├── src/main/resources/ │ ├── application.yml │ └── point-mapping.csv └── pom.xmlclient层只做一件事:根据配置里的从站 IP、端口建立 TCP 长连接,把 Netty 的 Channel 初始化好。protocol层不关心微电网业务,它只认 0x68 启动字符、APDU 长度、控制域和 ASDU。session层维护每个从站的连接状态、发送序号 N(S)、接收序号 N(R)、是否已激活 STARTDT。service层才是“总召唤什么时候发、遥控选择后等多久执行、遥测点号映射到哪个设备”的地方。业务层不应该直接碰字节数组,这一点在二次开发时很关键。
2.2 技术选型:Netty、Spring Boot 与配置外置
标题里关键词是 Java,IEC104 主站客户端在 Java 里常见做法是 Netty 做 TCP 客户端。原因不玄学:一个微电网管理系统往往要接十几台甚至几十台从站,每台从站一条长连接,还要处理心跳、重连、粘包和半包。阻塞 Socket 写起来快,但连接一多,线程和超时管理会变得很难受。Netty 的ByteToMessageDecoder适合处理 104 的“启动字符 + 长度 + 控制域 + ASDU”这种变长帧,IdleStateHandler可以配合 t1/t2/t3 做链路检测。
Spring Boot 不是协议栈的必需品,但这个项目属于微电网管理系统的一部分,配置外置、日志、健康检查、REST 查询接口用 Spring Boot 管起来更顺手。依赖上通常会有 Netty、SLF4J、Jackson 或 YAML 配置解析,数据入库再看业务侧选 MyBatis 或 JPA。下面是一段常见的pom.xml依赖片段,版本号用属性占位,实际以源码包为准:
<!-- pom.xml:协议通信和配置管理的关键依赖 --> <dependencies> <!-- Netty 负责 TCP 长连接、编解码和空闲检测 --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>${netty.version}</version> </dependency> <!-- Spring Boot 提供配置绑定、日志和可选 Web 接口 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- 如果项目带查询接口,再加 Web starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>这段依赖的逻辑是:Netty 管通信,Spring Boot 管工程化能力,二者不要互相渗透。参数上netty.version跟着项目父 POM 走,不要在不同模块里各写一套。spring-boot-starter-web只在需要对外提供接口时引入,纯采集进程不引 Web 也能跑。我一般会把协议编解码器做成不依赖 Spring 容器的普通类,这样单元测试可以直接喂十六进制字节,不用启动整个应用。
2.3 会话模型:一个从站一条长连接
IEC104 主站是主动连接方,从站是监听方,默认端口常见 2404。每个从站对应一个SlaveSession,里面至少要有:channel、commonAddress、sendSeq、recvSeq、started、lastTestTime。不要把所有从站塞进一个全局序号变量,这是血泪经验:两个从站同时上送 I 帧,接收序号会互相污染,最后链路复位,日志里看起来像“从站不稳定”,实际是主站自己把序号写乱了。
会话层还要管总召唤节奏。常见做法是连接激活后先发总召唤,之后按配置周期发总召唤或分组召唤;遥测遥信走突发上送,主站收到 I 帧后回 S 帧确认。每个从站独立维护 K/W 窗口,k是未确认 I 帧最大数量,w是收到多少帧后必须确认。参数不要拍脑袋,先按从站侧约定配,联调时再看抓包调整。
2.4 配置样例:从站列表和 t1/t2/t3/k/w 参数
配置外置能让现场改从站 IP 不用重新打包。下面是一段示例application.yml,字段名可以按项目实际类调整:
iec104: client: # 主站作为客户端,主动连接从站 slaves: - name: "储能柜A" host: "192.0.2.10" port: 2404 common-address: 1 enabled: true - name: "逆变器B" host: "192.0.2.11" port: 2404 common-address: 2 enabled: true # t1:发送帧后等待确认的超时,单位秒 t1: 15 # t2:收到 I 帧后无数据时回 S 帧的超时,单位秒 t2: 10 # t3:链路空闲时发 TESTFR 的周期,单位秒 t3: 20 # k:未确认 I 帧最大数量;w:收到多少帧后确认 k: 12 w: 8 # 总召唤周期,单位秒 interrogation-interval: 300这段配置的逻辑是:slaves里每一项对应一条连接,common-address用来过滤 ASDU 公共地址,避免一个从站里挂多个公共地址时串数据。t1/t2/t3和k/w是 104 链路稳定的关键参数,现场如果频繁掉线,先别怀疑 Netty,先看这三个超时和窗口值是否和从站侧一致。interrogation-interval控制总召唤频率,太密会给从站压力,太疏又会让业务侧看到旧数据。参数说明放在配置类里做校验,比如k不能大于 32767,t1不能小于 1 秒,启动时直接 fail fast,比运行中才发现好。
3. 核心链路拆解:一帧 IEC104 从 TCP 字节流到业务遥测的完整路径
3.1 APCI 帧头解析:I/S/U 三种帧怎么区分
IEC104 的 APDU 由启动字符0x68、1 字节 APDU 长度、4 字节控制域和 ASDU 组成。控制域第一个字节的低两位决定帧类型:00是 I 帧,01是 S 帧,11是 U 帧。I 帧带发送序号 N(S) 和接收序号 N(R),S 帧只带 N(R),U 帧带 STARTDT、STOPDT、TESTFR 这些链路控制功能。下面这段 Java 代码是我拆这类项目时最常看到的解析方式:
// 解析 4 字节控制域,返回帧类型和序号 public static ApciFrame parseControl(byte[] control) { int b0 = control[0] & 0xFF; int b1 = control[1] & 0xFF; int b2 = control[2] & 0xFF; int b3 = control[3] & 0xFF; if ((b0 & 0x01) == 0) { // I 帧:bit0=0,N(S) 在 b0/b1,N(R) 在 b2/b3,低 15 位有效 int sendSeq = ((b0 & 0xFE) >> 1) | (b1 << 7); int recvSeq = ((b2 & 0xFE) >> 1) | (b3 << 7); return ApciFrame.iFrame(sendSeq, recvSeq); } if ((b0 & 0x03) == 1) { // S 帧:bit0=1、bit1=0,只有 N(R) int recvSeq = ((b2 & 0xFE) >> 1) | (b3 << 7); return ApciFrame.sFrame(recvSeq); } // U 帧:bit0=1、bit1=1,功能码看 b0 return ApciFrame.uFrame(b0); }这段代码的逻辑是先把控制域拆成四个无符号字节,再按低两位判断帧类型。I 帧的 N(S) 和 N(R) 都是 15 位,解析时要右移一位并把高字节左移 7 位拼回来;S 帧只取 N(R);U 帧直接用第一个字节做功能码匹配,比如0x07是 STARTDT 激活,0x0B是 STARTDT 确认,0x43是 TESTFR 激活,0x83是 TESTFR 确认。参数上最容易翻车的是把byte直接当有符号数用,0x83会变成负数,所以每个字节都要& 0xFF。另外序号是模 32768 的,解析出来只是当前值,比较新旧序号时不能简单用大于小于。
3.2 ASDU 解码:类型标识、传送原因和信息对象地址
控制域之后是 ASDU。ASDU 头至少包含类型标识、可变结构限定词、传送原因、公共地址,信息体里再按类型带信息对象地址和值。微电网里常见类型标识有:单点遥信1、双点遥信3、归一化遥测9、标度化遥测11、短浮点遥测13、累计量15、单点遥控45、双点遥控46、设点命令50、总召唤100、时钟同步103。传送原因里总召唤响应常见20,激活6,激活确认7,激活终止10。下面是一段解析 ASDU 头部的代码:
// 解析 ASDU 公共头,信息体按类型标识继续读 public static AsduHeader parseAsduHeader(ByteBuf in) { int typeId = in.readUnsignedByte(); // 类型标识 int vsq = in.readUnsignedByte(); // SQ + 信息对象个数 int cotLow = in.readUnsignedByte(); // 传送原因低字节 int cotHigh = in.readUnsignedByte(); // 传送原因高字节,含源发地址 int commonAddress = in.readUnsignedShort(); // 公共地址,常见 2 字节 int count = vsq & 0x7F; // 低 7 位是信息对象个数 boolean sequence = (vsq & 0x80) != 0; // 最高位 SQ:连续地址标志 int cause = cotLow & 0x3F; // 低 6 位是传送原因 boolean negative = (cotLow & 0x40) != 0; // P/N 位 boolean test = (cotLow & 0x80) != 0; // T 位 return new AsduHeader(typeId, count, sequence, cause, negative, test, commonAddress, cotHigh); }这段代码的逻辑是先读固定 6 字节公共头,再把可变结构限定词拆成“个数”和“是否连续地址”,把传送原因拆成原因值、否定确认位和试验位。参数上commonAddress的字节数要看项目约定,常见是 2 字节,也有 1 字节的私有实现,源码里如果写死 2 字节,接不同从站时就要留意。信息对象地址常见 3 字节,遥测值按类型不同长度也不同:归一化值 2 字节、短浮点 4 字节。解析时不要一次性把整个 ASDU 读成字符串,按typeId分派到不同的信息体解析器,后面加点表映射才不乱。
3.3 总召唤与遥控下发:序号管理和选择/执行流程
总召唤是主站主动发C_IC_NA_1,类型标识 100,传送原因 6,信息对象地址通常填 0,限定词填 20 表示站总召唤。从站回一系列 ASDU,传送原因 20,最后回激活终止。遥控则是先发选择,收到激活确认后再发执行,执行完收回终止。下面是一段构造 I 帧和总召唤 ASDU 的示例:
// 构造 I 帧:控制域 + ASDU,发送序号由会话维护 public ByteBuf buildIFrame(SlaveSession session, byte[] asdu) { int send = session.nextSendSeq(); // N(S) 自增,模 32768 int recv = session.getRecvSeq(); // 当前已确认的 N(R) ByteBuf out = Unpooled.buffer(6 + asdu.length); out.writeByte(0x68); // 启动字符 out.writeByte(4 + asdu.length); // APDU 长度:控制域 4 字节 + ASDU out.writeByte((send << 1) & 0xFE); // N(S) 低 8 位 out.writeByte((send >> 7) & 0xFF); // N(S) 高 7 位 out.writeByte((recv << 1) & 0xFE); // N(R) 低 8 位 out.writeByte((recv >> 7) & 0xFF); // N(R) 高 7 位 out.writeBytes(asdu); return out; } // 总召唤 ASDU:类型 100,原因 6,公共地址 1,信息对象地址 0 public byte[] buildInterrogationAsdu(int commonAddress) { ByteBuf asdu = Unpooled.buffer(); asdu.writeByte(100); // C_IC_NA_1 asdu.writeByte(0x01); // SQ=0,1 个信息对象 asdu.writeByte(6); // 传送原因:激活 asdu.writeByte(0); // 源发地址 asdu.writeShort(commonAddress); // 公共地址 asdu.writeMedium(0); // 信息对象地址 asdu.writeByte(20); // 限定词:站总召唤 byte[] result = new byte[asdu.readableBytes()]; asdu.readBytes(result); return result; }这段代码的逻辑是:I 帧长度字段只算控制域和 ASDU,不含启动字符和长度字节本身;N(S) 每次发新 I 帧自增,N(R) 用当前已经确认的接收序号。总召唤 ASDU 里类型标识 100、传送原因 6、信息对象地址 0、限定词 20 是常见组合,公共地址要和从站配置一致。参数上nextSendSeq()必须做& 0x7FFF,否则超过 32767 后控制域会写花。遥控不要直接发执行,常见流程是选择→等激活确认→执行→等激活终止,中间任何一步超时都要记日志并释放遥控锁,避免同一个点位被重复下发。
3.4 业务落点:遥测遥信如何映射到微电网点表
协议解析完只是拿到“公共地址 + 信息对象地址 + 值”,微电网业务要的是“几号储能柜的 A 相电压”。这中间需要点表。常见做法是用 CSV 或数据库表维护commonAddress、infoAddress、typeId、pointCode、pointName、unit、scale的映射。解析器产出TelemetryFrame,业务层根据点表转成PointValue,再入库、告警、推前端。下面是一段业务落点的接口示例:
// 协议层只产出原始点值,业务层按点表翻译 public interface PointValueSink { void onPointValue(RawPointValue value); } public class RawPointValue { private int commonAddress; private int infoAddress; private int typeId; private double value; private long timestamp; // getter/setter 省略 } // 微电网业务实现:查点表、做缩放、写库并判断告警 public class MicrogridPointSink implements PointValueSink { private final PointMappingRepository mappingRepository; private final TelemetryRepository telemetryRepository; @Override public void onPointValue(RawPointValue raw) { PointMapping mapping = mappingRepository.find( raw.getCommonAddress(), raw.getInfoAddress(), raw.getTypeId()); if (mapping == null) { // 点表没配就丢掉,但要打 warn,别静默吞数据 return; } double engineeringValue = raw.getValue() * mapping.getScale() + mapping.getOffset(); telemetryRepository.save(mapping.getPointCode(), engineeringValue, raw.getTimestamp()); } }这段代码的逻辑是把协议层和业务层用PointValueSink隔开,协议层不查数据库,业务层不解析字节。参数上scale和offset用来做工程值转换,比如从站上送的是归一化值或标度化值,业务侧要换成电压、电流的实际单位。timestamp优先用 ASDU 里的时标,没有时标就用主站接收时间,但要在入库时标清楚来源。点表没配时不要静默丢,至少打一条 warn,否则联调时会出现“从站明明上送了,后台就是没有”的黑匣子。
4. 运行与联调:把主站客户端接到模拟从站上跑通
4.1 启动前配置:端口、公共地址和点表
第一次跑不要直接上现场设备,先用 IEC104 从站模拟器。模拟器监听 2404,配置公共地址、遥测点、遥信点,主站客户端配置里填同样的 IP、端口和公共地址。点表先配三条:一条遥测、一条遥信、一条遥控。下面是一段最小配置,和前面的application.yml对应:
iec104: client: slaves: - name: "模拟从站" host: "127.0.0.1" port: 2404 common-address: 1 enabled: true t1: 15 t2: 10 t3: 20 k: 12 w: 8 interrogation-interval: 60这段配置的逻辑是把模拟从站当成一台真实设备,公共地址必须和模拟器里配的一致。interrogation-interval设短一点,60 秒一次,方便联调时反复看总召唤。端口不要随手改,104 默认 2404,现场防火墙也常按这个端口放行。点表文件里infoAddress要和模拟器里建的遥测点号一致,类型标识也要对:模拟器上送短浮点遥测就配 13,上送单点遥信就配 1。
4.2 启动命令与日志开关
项目如果是 Spring Boot 结构,常见启动方式是 Maven 或 java -jar。第一次跑建议把协议层日志开到 DEBUG,把收发的十六进制帧打出来。命令示例如下:
# 在项目根目录编译并启动 mvn clean package -DskipTests java -jar target/iec104-microgrid.jar # 如果项目是 Spring Boot 且本地开发,也可以直接跑 mvn spring-boot:run # 临时把协议包日志调到 DEBUG,方便看帧 java -jar target/iec104-microgrid.jar \ --logging.level.com.example.iec104.protocol=DEBUG这段命令的逻辑是先把依赖和编译跑通,再用 jar 启动。--logging.level...是 Spring Boot 的日志级别参数,包名按源码实际包名替换。参数上不要在生产环境长期开 DEBUG,104 帧日志量大,磁盘容易被刷满;联调时可以只开protocol和session两个包。如果启动就报端口占用,先确认本机没有别的 104 主站或模拟器占着连接。
4.3 联调检查点:STARTDT、总召唤、遥控
联调不要只看“连接成功”。TCP 连上只是第一步,IEC104 还要 U 帧 STARTDT 激活。检查顺序我一般按下面走:
- 看日志里有没有 TCP 连接建立,远端地址和端口是否正确。
- 看主站有没有发
STARTDT act,从站有没有回STARTDT con。 - 看总召唤帧有没有发出去,从站有没有回类型标识 100、传送原因 20 的 ASDU。
- 看遥测遥信有没有进
PointValueSink,点表映射有没有命中。 - 看遥控选择有没有激活确认,执行有没有激活终止。
这五步里最容易跳过的是第 2 步。TCP 连上但没激活,从站不会主动上送数据,日志看起来就是“连接正常、没有报文”。总召唤只回一半,先查公共地址和点表,再查 K/W 窗口是不是满了没回 S 帧。遥控失败先看类型标识:单点遥控 45、双点遥控 46、设点命令 50,选择和执行的信息对象地址必须一致,传送原因选择是 6,执行也是 6,从站确认是 7。
4.4 抓包与十六进制日志排查
协议问题绕不开看字节。常见做法是在测试环境用抓包工具看 TCP 流,或者直接看应用日志里的十六进制。下面是一段日志打印的示例代码:
// 在编解码器入口打印收发帧,联调时开,生产环境关 private static final Logger log = LoggerFactory.getLogger(Iec104Codec.class); private void logFrame(String direction, ByteBuf frame) { if (!log.isDebugEnabled()) { return; } byte[] bytes = new byte[frame.readableBytes()]; frame.getBytes(frame.readerIndex(), bytes); log.debug("{} {} bytes: {}", direction, bytes.length, toHex(bytes)); } private static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(bytes.length * 3); for (byte b : bytes) { sb.append(String.format("%02X ", b)); } return sb.toString(); }这段代码的逻辑是用getBytes读一份副本,不改变readerIndex,避免日志打完影响后续解码。参数上direction用RX/TX区分收和发,toHex每个字节补两位十六进制。看日志时先找68启动字符,再看长度字段和控制域。I 帧控制域第一个字节最低位是 0,S 帧是01,U 帧常见07、0B、43、83。如果日志里出现长度和控制域对不上,优先怀疑粘包半包处理,而不是从站。
4.5 接微电网业务:入库、告警和前端推送
联调通以后再把数据接到业务。微电网侧一般要做三件事:时序入库、越限告警、前端实时刷新。入库不要每条遥测一个事务,按批量或按时间窗聚合;告警要做死区,避免值在阈值附近抖动刷屏;前端推送可以用 WebSocket,但协议层不要直接依赖 WebSocket。下面是一段批量入库的示意:
// 业务层批量消费点值,降低数据库压力 public class TelemetryBatchWriter { private final List<RawPointValue> buffer = new ArrayList<>(200); public synchronized void accept(RawPointValue value) { buffer.add(value); if (buffer.size() >= 200) { flush(); } } private void flush() { if (buffer.isEmpty()) { return; } // 批量写库,具体 SQL 或 ORM 按项目实际 telemetryRepository.saveBatch(new ArrayList<>(buffer)); buffer.clear(); } }这段代码的逻辑是用固定大小缓冲攒批,避免每条遥测都打一次数据库。参数上 200 是经验值,现场点位多可以调大,但要注意内存和延迟。synchronized只是示意,实际可以用单线程队列或 Disruptor 之类,但不要为了性能把顺序搞乱。告警判断放在入库前还是入库后要看业务,我一般放在入库前,先算工程值和死区,再决定是否产生告警事件。
5. 避坑与排查:IEC104 主站客户端最容易翻车的 5 个点
5.1 连接成功但遥测不动:STARTDT 没激活
现象:日志显示 TCP 连接建立,从站地址和端口都对,但没有任何 ASDU 上送,总召唤也像石沉大海。
原因:IEC104 的 TCP 连接建立不等于链路激活。主站没有发STARTDT act,或者从站回了STARTDT con但主站没把会话状态置为已启动,后续总召唤帧被从站忽略。
解决:连接建立后立即发 U 帧0x07,收到0x0B再把session.started置为 true,然后才允许发总召唤。联调时把 U 帧收发打进日志,确认STARTDT act/con成对出现。如果从站一直不回,先查从站是否要求先发 TESTFR,或者公共地址是否配错。
5.2 总召唤只回一半:K/W 窗口和公共地址
现象:总召唤发出去,从站回了几帧遥测就停了,后面还有点位没上来,等下次总召唤又重复。
原因:常见有两种。一是主站收到 I 帧后没有按w回 S 帧,未确认 I 帧达到k窗口上限,从站停止发送。二是公共地址不匹配,主站把部分 ASDU 过滤掉了,看起来像从站只回一半。
解决:先看日志里 S 帧有没有按节奏发,k和w是否和从站侧一致;再把公共地址过滤日志打开,确认被丢掉的 ASDU 公共地址是多少。总召唤响应里信息对象个数多时,主站要边收边确认,不要等全部收完再回 S 帧。参数上k=12、w=8是常见起点,现场从站窗口小就往下调。
5.3 遥控失败:选择/执行与类型标识对不上
现象:遥控下发后从站不回确认,或者回了否定确认,断路器没动作。
原因:遥控类型标识用错,单点遥控写成双点;选择和执行的信息对象地址不一致;传送原因不是 6;或者没有等选择确认就直接发执行。
解决:先核对点表里的遥控类型,单点命令 45、双点命令 46、设点命令 50 不要混。选择帧和执行帧的公共地址、信息对象地址、命令值必须一致,选择后等激活确认再发执行,执行后等激活终止。超时时间不要设太短,现场链路慢时 5 秒可能不够用,但也不能无限等,建议会话级加遥控锁和超时释放。
5.4 序号错乱:N(S)/N(R) 没有按 32768 取模
现象:跑一段时间后链路突然复位,日志里出现序号跳变,从站回否定确认或直接断开。
原因:I 帧发送序号或接收序号超过 32767 没有取模,控制域写入了非法值;或者多线程同时发 I 帧,sendSeq自增不是原子的,两个帧拿到同一个序号。
解决:nextSendSeq()里做& 0x7FFF,接收序号比较也用模运算;每个从站的发送操作串行化,不要在一个 Channel 上并发写多个 I 帧。收到 I 帧后及时更新recvSeq并回 S 帧,S 帧的 N(R) 表示“下一个期望收到的序号”,不是“最后收到的序号”,这一点很容易写错。
5.5 断线重连风暴:没有退避和抖动
现象:从站网络闪断后,主站日志里大量重连,CPU 升高,从站侧连接数被打满。
原因:重连策略是固定 1 秒重试,多个从站同时断线时一起重连;或者连接失败后没有关闭旧 Channel,句柄泄漏。
解决:重连加指数退避和随机抖动,比如 1 秒、2 秒、4 秒、8 秒,上限 60 秒,再加 0~500 毫秒随机。重连前先确认旧 Channel 已关闭,SessionManager里按从站维度串行重连。生产环境把重连日志限流,避免日志把磁盘刷满。
6. 二次开发:把主站客户端改成点表驱动、可观测的采集组件
这套代码要真正放进微电网管理系统,我建议先改三个地方:点表驱动、协议层与业务层解耦、指标可观测。点表不要写死在 Java 常量里,用 CSV 或数据库表加载,启动时校验commonAddress + infoAddress + typeId是否重复;协议层只产出RawPointValue,业务层通过PointValueSink消费,这样以后换 Kafka、换时序库都不用动解码器;指标至少暴露连接数、总召唤次数、I 帧收发数、遥控失败数,排查时不用翻日志猜。
| 改造点 | 做法 | 验证方法 |
|---|---|---|
| 点表驱动 | 从 CSV/DB 加载映射,支持热刷新 | 改一条点表,重启后看工程值是否变化 |
| 协议解耦 | 解码器只依赖PointValueSink接口 | 用单元测试直接喂十六进制帧 |
| 可观测 | Micrometer 或日志统计连接、召唤、遥控 | 模拟断线,看指标是否下降再恢复 |
| 批量入库 | 固定窗口攒批,失败重试 | 压测 1000 点/秒,看数据库连接数 |
// 点表驱动示例:启动时加载,按公共地址和信息地址索引 public class PointMappingRegistry { private final Map<String, PointMapping> index = new ConcurrentHashMap<>(); public void load(List<PointMapping> mappings) { Map<String, PointMapping> next = new HashMap<>(); for (PointMapping m : mappings) { String key = m.getCommonAddress() + ":" + m.getInfoAddress() + ":" + m.getTypeId(); if (next.put(key, m) != null) { throw new IllegalArgumentException("点表重复: " + key); } } index.clear(); index.putAll(next); } public PointMapping find(int ca, int ioa, int typeId) { return index.get(ca + ":" + ioa + ":" + typeId); } }这段代码的逻辑是用公共地址:信息对象地址:类型标识做唯一键,加载时发现重复直接抛错,避免运行中后一条覆盖前一条。参数上typeId要参与键,因为同一个信息地址在不同类型下含义可能不同。验证时先拿模拟从站回放一段抓包,确认总召唤、遥测上送、遥控选择/执行都能走通,再换真实设备。从那以后我每次接新从站,都强制先跑一遍总召唤回放和遥控选择/执行,再让业务层碰数据。希望帮到你。
本文还有配套的精品资源,点击获取