从 0 到 1:我用 Netty TCP 搭建物联网云平台,并对接车辆电池信息解析
目录
- [背景:为什么不是 MQTT,而是 Netty TCP](#背景:为什么不是 MQTT,而是 Netty TCP)
- [工程骨架:一条 JVM 里跑 HTTP 和 TCP](#工程骨架:一条 JVM 里跑 HTTP 和 TCP)
- [协议设计:8 段帧、命令字、应答规则](#协议设计:8 段帧、命令字、应答规则)
- CRC16:联调时最容易扯皮的一层
- [Netty 接入层:Pipeline、线程、背压](#Netty 接入层:Pipeline、线程、背压)
- [上行全链路:从字节到 MySQL](#上行全链路:从字节到 MySQL)
- [车辆电池信息 0x53:字段表与业务串联](#车辆电池信息 0x53:字段表与业务串联)
- [养护过程 0x55 / 详情 0x54:订单号维度的数据关联](#养护过程 0x55 / 详情 0x54:订单号维度的数据关联)
- [下行指令:HTTP 如何打到 TCP Channel](#下行指令:HTTP 如何打到 TCP Channel)
- [多副本 K8s:连接在 A Pod,请求在 B Pod](#多副本 K8s:连接在 A Pod,请求在 B Pod)
- 协议日志与排障手册
- 本地运行与配置清单
- 我踩过的坑与建议的实施顺序
- 后续演进方向
1. 背景:为什么不是 MQTT,而是 Netty TCP
我这套平台服务的场景是:充电桩/养护终端 通过运营商或厂商给定的 私有二进制协议 长连接上报数据。平台要解析 车辆 VIN、电池标称参数、养护过程 SOC、电芯温度 等,管理端(独立前端仓库 evc-web)再通过 HTTP 做设备运维、检测订单、下发「开启/结束养护」「固件升级」。
在立项时我评估过几条路:
| 方案 | 优点 | 我为什么没选(或没在第一版选) |
|---|---|---|
| MQTT + 自建 Broker | 生态成熟、订阅模型清晰 | 终端固件已按 TCP 私有帧实现,改传输层成本高 |
| 独立网关服务 + HTTP 内网 | 接入与业务解耦 | 第一版团队规模小,多一个进程多一份部署和联调链路 |
| Spring Boot 内嵌 Netty | 与 MyBatis/事务/权限同进程 | 资源隔离弱,但用线程模型 + 队列可以压住 |
最终选型:在 evc-server 模块里启动 Netty TCP Server(默认 9000) ,与 Spring MVC(管理端 API)共用数据源和事务。设备走 TCP;人和运营系统走 HTTP。这不是「理论上最优」,而是 交付速度、排障路径、事务一致性 三者平衡后的结果。
如果你也在做类似项目,可以把我这篇当成一条可复制的路线:
协议先钉死 → Netty 只做 I/O → 业务异步化 → 多副本用 Redis 回答「连接在哪台机器上」。
2. 工程骨架:一条 JVM 里跑 HTTP 和 TCP
2.1 模块划分
后端是 Java 21 + Spring Boot 3.5 多模块 Maven 工程:
| 模块 | 职责 |
|---|---|
evc-dependencies |
BOM,集中第三方版本 |
evc-framework |
自研 Starter:Web、Security、MyBatis、Redis、定时任务、租户等 |
evc-infra |
基础设施:定时任务运维、接口文档、代码生成 |
evc-system |
用户、部门、角色菜单、字典、租户 |
evc-server |
可运行应用:HTTP + Netty TCP + 业务域(设备、订单、电池等) |
依赖关系(简化):
evc-dependencies → evc-framework → evc-infra → evc-system → evc-server
设备相关代码集中在:
evc-server/src/main/java/com/usteu/evc/server/battery/
├── common/ # CRC、BCD、设备号转换、协议常量
├── netty/
│ ├── server/ # NettyTcpServer、配置
│ ├── codec/ # ProtocolDecoder / ProtocolEncoder
│ ├── handler/ # ConnectHandler、BusinessHandler
│ ├── channel/ # ChannelManager
│ ├── ingest/ # 有界队列 + ProtocolInboundProcessor
│ ├── cluster/ # Redis 归属 + 下行 Pub/Sub
│ ├── model/ # ProtocolMessage、*Req、*Resp
│ └── log/ # 协议报文落库
└── (业务 Service 在 server.service 包)
2.2 运行时架构图
#mermaid-svg-FFgMrj0WNGPaMm22{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FFgMrj0WNGPaMm22 .error-icon{fill:#552222;}#mermaid-svg-FFgMrj0WNGPaMm22 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FFgMrj0WNGPaMm22 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 .marker.cross{stroke:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FFgMrj0WNGPaMm22 p{margin:0;}#mermaid-svg-FFgMrj0WNGPaMm22 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster-label text{fill:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster-label span{color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster-label span p{background-color:transparent;}#mermaid-svg-FFgMrj0WNGPaMm22 .label text,#mermaid-svg-FFgMrj0WNGPaMm22 span{fill:#333;color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .node rect,#mermaid-svg-FFgMrj0WNGPaMm22 .node circle,#mermaid-svg-FFgMrj0WNGPaMm22 .node ellipse,#mermaid-svg-FFgMrj0WNGPaMm22 .node polygon,#mermaid-svg-FFgMrj0WNGPaMm22 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .rough-node .label text,#mermaid-svg-FFgMrj0WNGPaMm22 .node .label text,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape .label,#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape .label{text-anchor:middle;}#mermaid-svg-FFgMrj0WNGPaMm22 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .rough-node .label,#mermaid-svg-FFgMrj0WNGPaMm22 .node .label,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape .label,#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape .label{text-align:center;}#mermaid-svg-FFgMrj0WNGPaMm22 .node.clickable{cursor:pointer;}#mermaid-svg-FFgMrj0WNGPaMm22 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 .arrowheadPath{fill:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FFgMrj0WNGPaMm22 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FFgMrj0WNGPaMm22 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FFgMrj0WNGPaMm22 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FFgMrj0WNGPaMm22 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FFgMrj0WNGPaMm22 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster text{fill:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster span{color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FFgMrj0WNGPaMm22 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 rect.text{fill:none;stroke-width:0;}#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape p,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape .label rect,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FFgMrj0WNGPaMm22 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FFgMrj0WNGPaMm22 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FFgMrj0WNGPaMm22 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
evc-server Pod
终端侧
长连接 私有协议
充电桩 / 养护终端
Netty TCP :9000
Spring MVC :8088
InboundMessageIngestService
ProtocolInboundProcessor
UploadDataProcessServiceImpl
MaintainDownlinkLocalExecutor
MySQL
Redis
evc-web 管理端
读代码的入口建议:
NettyTcpServer.java--- Pipeline 怎么搭ProtocolDecoder.java--- 粘包/CRCBusinessHandler.java--- 线程切换与入队ProtocolInboundProcessor.java--- 命令字分发与应答UploadDataProcessServiceImpl.java--- 业务落库
3. 协议设计:8 段帧、命令字、应答规则
和硬件同事对齐后,我把协议常量收口在 ProtocolConstants,避免魔法数字散落。
3.1 标准帧结构(8 段)
| 序号 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 1 | 固定头 | 2B | 0xA27C |
| 2 | 长度 | 2B | 从「版本」到 CRC(含 CRC) 的字节数 |
| 3 | 版本 | 1B | 当前 0x07 |
| 4 | 电桩 ID | 4B | 大端 Int,帧头逻辑桩号 |
| 5 | 序号 | 1B | 请求/应答配对,下行由服务端递增 |
| 6 | 命令字 | 1B | 业务类型 |
| 7 | 数据域 | N | 与命令字相关 |
| 8 | CRC16 | 2B | 大端,覆盖「固定头」到「数据域末尾」 |
总帧长 = 4(固定头 + 长度字段本身)+ length字段值。
解码器里最小帧长注释写得很清楚:
固定头2 + 长度2 + 版本1 + 电桩ID4 + 序号1 + 命令字1 + CRC2 = 13 字节(数据域可为 0)
3.2 命令字总表
上行(终端 → 服务端)
| 命令字 | 含义 | 数据域实体类 | 服务端应答 |
|---|---|---|---|
0x50 |
重连上报设备信息 | ReconnectInfoReq |
0xF0(结果 + 服务器时间 BCD) |
0x51 |
心跳 | HeartbeatReq(标签 0xAA) |
0xF1(结果 + 对时) |
0x52 |
状态包(枪状态) | StatusPacketReq |
0xF2(仅结果) |
0x53 |
车辆与电池信息 | BatteryInfoReq |
0xF3(仅结果) |
0x54 |
养护详情 | MaintainDetailReq |
0xF4 |
0x55 |
养护过程数据 | MaintainProcessReq(固定 36 字节) |
0xF5 |
下行(服务端 → 终端)
| 命令字 | 含义 | 终端应答 |
|---|---|---|
0x40 |
开启养护 | 0xE0 |
0x41 |
结束养护 | 0xE1 |
0x42 |
升级(FTP 参数) | 0xE2 |
执行结果码:0x00 成功,0x01 失败。
3.3 设备号与枪号的两套编号
联调早期我吃过编号混乱的亏,后来在 ProtocolDeviceIdUtil 里写死规则:
- 帧头
pileId:4 字节大端 Int,与业务表evc_device.device_no十进制字符串互转(当前实现是String.valueOf(pileId))。 0x50数据域桩号 :4 字节按 HEX 展开后再转十进制业务设备号(hexDeviceNoToBusinessDeviceNo)。- 枪 :数据域里是完整枪编号(Int),对应
evc_device_gun.gun_no,不要用「枪序号」冒充业务枪号。
状态包 0x52 更新枪状态时,我是 先 pileId → deviceNo → 再按 gunId 查枪表,避免更新错行。
4. CRC16:联调时最容易扯皮的一层
CRC16Util 的实现要点:
- 算法:CRC16/MODBUS (多项式
0xA001,初值0xFFFF)。 - 密钥拼接 :
CRC( crcKey + 报文数据 ),密钥来自配置netty.protocol.crc-key(十六进制字符串,启动时解析为字节数组)。 - 校验范围:从 固定头 到 数据域末尾(不含 CRC 两字节)。
- 字节序:CRC 结果 高字节在前(大端)。
解码器里我用 getBytes 窥视 校验,通过后再移动 readerIndex 顺序读字段------避免读指针乱跑导致 CRC 算错。
排障顺序 (代码注释里也写了):先看长度 → 固定头 → CRC。CRC 失败时我会关连接并把 原始帧 HEX 记入 evc_protocol_message_log,和嵌入式同事对表时极省时间。
5. Netty 接入层:Pipeline、线程、背压
5.1 Pipeline 顺序(不可乱)
NettyTcpServer 在 @PostConstruct 启动,每个 SocketChannel 的 Pipeline:
ProtocolDecoder → ProtocolEncoder → ConnectHandler → BusinessHandler
| 组件 | 类型 | 职责 |
|---|---|---|
ProtocolDecoder |
LengthFieldBasedFrameDecoder 子类 |
粘包/拆包、固定头、长度、CRC、转 ProtocolMessage |
ProtocolEncoder |
MessageToByteEncoder |
ProtocolMessage → 字节流,回填长度、算 CRC、打出站 HEX 日志 |
ConnectHandler |
ChannelInboundHandlerAdapter |
连接建立/断开、异常;断开时清理 Channel 与 Redis 归属 |
BusinessHandler |
SimpleChannelInboundHandler |
CRC 复核、注册连接、入队异步处理 |
5.2 粘包:LengthFieldBasedFrameDecoder 参数
java
// 长度字段在偏移 2,长度 2 字节;总帧长 = 4 + length
super(MAX_FRAME_LENGTH, 2, 2, 0, 0);
MAX_FRAME_LENGTH = 1024;
超过 1024 的帧直接拒掉,防止恶意或异常终端撑爆内存。
5.3 三层线程模型(这套设计的核心)
很多人写 Netty 业务时直接在 channelRead 里调 MyBatis,连接一多 EventLoop 就被 JDBC 卡死。我拆了三层:
#mermaid-svg-IbRPozn4NFKMy7iB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IbRPozn4NFKMy7iB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IbRPozn4NFKMy7iB .error-icon{fill:#552222;}#mermaid-svg-IbRPozn4NFKMy7iB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IbRPozn4NFKMy7iB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB .marker.cross{stroke:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IbRPozn4NFKMy7iB p{margin:0;}#mermaid-svg-IbRPozn4NFKMy7iB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster-label text{fill:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster-label span{color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster-label span p{background-color:transparent;}#mermaid-svg-IbRPozn4NFKMy7iB .label text,#mermaid-svg-IbRPozn4NFKMy7iB span{fill:#333;color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .node rect,#mermaid-svg-IbRPozn4NFKMy7iB .node circle,#mermaid-svg-IbRPozn4NFKMy7iB .node ellipse,#mermaid-svg-IbRPozn4NFKMy7iB .node polygon,#mermaid-svg-IbRPozn4NFKMy7iB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .rough-node .label text,#mermaid-svg-IbRPozn4NFKMy7iB .node .label text,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape .label,#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape .label{text-anchor:middle;}#mermaid-svg-IbRPozn4NFKMy7iB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .rough-node .label,#mermaid-svg-IbRPozn4NFKMy7iB .node .label,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape .label,#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape .label{text-align:center;}#mermaid-svg-IbRPozn4NFKMy7iB .node.clickable{cursor:pointer;}#mermaid-svg-IbRPozn4NFKMy7iB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB .arrowheadPath{fill:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IbRPozn4NFKMy7iB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IbRPozn4NFKMy7iB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IbRPozn4NFKMy7iB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IbRPozn4NFKMy7iB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IbRPozn4NFKMy7iB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IbRPozn4NFKMy7iB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster text{fill:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster span{color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IbRPozn4NFKMy7iB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IbRPozn4NFKMy7iB rect.text{fill:none;stroke-width:0;}#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape p,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape .label rect,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IbRPozn4NFKMy7iB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IbRPozn4NFKMy7iB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IbRPozn4NFKMy7iB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Layer 3: Ingest 线程池
Layer 2: businessExecutorGroup
Layer 1: Netty Worker
编解码 + socket 读写
BusinessHandler.channelRead0
ArrayBlockingQueue 5000
50 个 consumer
ProtocolInboundProcessor
| 层级 | 配置项 | 默认值 | 做什么 |
|---|---|---|---|
| Boss/Worker | boss-threads / worker-threads |
2 / 8 | 只做网络 I/O |
| 业务 Executor | business-threads |
32 | 每 Channel 绑定一个 executor,channelRead 里 execute 后再处理 |
| 入站消费 | ingest-queue-capacity / ingest-consumer-threads |
5000 / 50 | JDBC、应答组包、协议日志 |
背压 :InboundMessageIngestService.offer() 队列满返回 false,BusinessHandler 直接 ctx.close()。我的逻辑是:DB 或消费线程跟不上时,宁可让终端重连,也不要在内存里无限堆报文。
出站协议日志也单独做了有界队列(outbound-log-queue-capacity),ProtocolEncoder 里 offer 失败只打 warn,不阻塞写 socket。
5.4 连接管理:ChannelManager
本机内存维护双向映射:
pileId → ChannelChannel → pileIdpileId → AtomicInteger序号(0~255 循环)
首次收到某桩合法报文时 registerChannel;ConnectHandler.channelInactive 时 removeChannel 并 pileOwnershipService.unregister。
6. 上行全链路:从字节到 MySQL
用 0x53 车辆电池信息 走一遍完整路径,其它命令字同理。
6.1 时序图
MySQL UploadDataProcessService ProtocolInboundProcessor IngestQueue BusinessHandler ProtocolDecoder 终端 MySQL UploadDataProcessService ProtocolInboundProcessor IngestQueue BusinessHandler ProtocolDecoder 终端 #mermaid-svg-slpcloMKntlPyQrE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-slpcloMKntlPyQrE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-slpcloMKntlPyQrE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-slpcloMKntlPyQrE .error-icon{fill:#552222;}#mermaid-svg-slpcloMKntlPyQrE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-slpcloMKntlPyQrE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-slpcloMKntlPyQrE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-slpcloMKntlPyQrE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-slpcloMKntlPyQrE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-slpcloMKntlPyQrE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-slpcloMKntlPyQrE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-slpcloMKntlPyQrE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-slpcloMKntlPyQrE .marker.cross{stroke:#333333;}#mermaid-svg-slpcloMKntlPyQrE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-slpcloMKntlPyQrE p{margin:0;}#mermaid-svg-slpcloMKntlPyQrE .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-slpcloMKntlPyQrE text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-slpcloMKntlPyQrE .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-slpcloMKntlPyQrE .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE .sequenceNumber{fill:white;}#mermaid-svg-slpcloMKntlPyQrE #sequencenumber{fill:#333;}#mermaid-svg-slpcloMKntlPyQrE #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE .messageText{fill:#333;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-slpcloMKntlPyQrE .labelText,#mermaid-svg-slpcloMKntlPyQrE .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .loopText,#mermaid-svg-slpcloMKntlPyQrE .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-slpcloMKntlPyQrE .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-slpcloMKntlPyQrE .noteText,#mermaid-svg-slpcloMKntlPyQrE .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-slpcloMKntlPyQrE .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-slpcloMKntlPyQrE .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-slpcloMKntlPyQrE .actorPopupMenu{position:absolute;}#mermaid-svg-slpcloMKntlPyQrE .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-slpcloMKntlPyQrE .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-slpcloMKntlPyQrE .actor-man circle,#mermaid-svg-slpcloMKntlPyQrE line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-slpcloMKntlPyQrE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} TCP 字节流 拆帧 + CRC ProtocolMessage 注册 Channel + Redis touch offer(work) process(msg, channel) saveInboundAndBind 日志 processBatteryInfo insert vehicle_battery_info update check_order writeAndFlush 0xF3 应答
6.2 ProtocolInboundProcessor:命令分发中枢
所有上行在 switch (commandCode) 里路由,应答组包集中在 sendResponse,避免每个 Service 自己拼帧:
- 继承请求的
pileId、serialNo - 设置应答命令字(如
0xF3) - 数据域:多数仅 1 字节结果;
0xF0/0xF1额外带 6 字节 BCD 服务器时间
解析失败(IllegalArgumentException)会 markCurrentInboundParseFailed,并对需要应答的命令回 失败结果码。
6.3 协议实体:先 parse 再落库
每个 *Req 类提供 static Xxx parse(byte[] data):
- 入口先校验
data.length,不足直接抛异常,禁止 silent 越界读 - 偏移用局部变量
offset += n递增,和协议 PDF 一行一行对齐
ProtocolMessage 还提供 isCrcValid() 二次校验(Handler 里会再打一道),与 Decoder 里 CRC 形成双保险。
7. 车辆电池信息 0x53:字段表与业务串联
7.1 数据域字节布局(至少 51 字节)
| 偏移 | 长度 | 字段 | 编码 | 说明 |
|---|---|---|---|---|
| 0 | 6 | 上报时间 | BCD | BCDCodecUtil.bcdToLocalDateTime |
| 6 | 4 | 枪 ID | 大端 Int | 完整枪编号 |
| 10 | 10 | 养护订单号 | ASCII | 右补空格或定长 |
| 20 | 17 | 车辆 VIN | ASCII | 17 位 |
| 37 | 4 | 公里数 | 大端 Int | |
| 41 | 1 | 历史快充占比 | Byte | 单位 % |
| 42 | 1 | 电池类型 | Byte | 枚举由业务字典解释 |
| 43 | 2 | 标称电压 | Short | 分辨率 0.1 V |
| 45 | 2 | 电池容量 | Short | 分辨率 0.1 Ah |
| 47 | 2 | 额定总能量 | Short | 分辨率 0.1 kWh |
| 49 | 2 | 预估循环次数 | Short |
解析代码见 BatteryInfoReq.parse。
7.2 落库表 evc_vehicle_battery_info
实体 VehicleBatteryInfo 字段与协议一一对应,并增加:
device_no:由ProtocolDeviceIdUtil.pileIdToDeviceNo(msg.getPileId())转换create_time:服务端写入时间
7.3 与检测订单、用户车辆的联动
processBatteryInfo 里除了 insert 电池表,还会:
findOrCreateCheckOrder(orderNo, deviceNo, gunId)--- 保证订单主表存在- 回填
vin_no、mileage - 按 VIN 查
evc_user_vehicle,若有则补 手机号、车型
这样管理端查订单时,不必等 0x54 才看到 VIN。
7.4 应答
成功处理后 ProtocolInboundProcessor 发送 0xF3 ,数据域 1 字节:0x00。终端以此确认平台已收妥;若解析失败则 0x01。
8. 养护过程 0x55 / 详情 0x54:订单号维度的数据关联
8.1 为什么 0x55 要回查 0x53
协议设计上 0x55 养护过程数据不带 VIN(数据域固定 36 字节,主要是 SOC、充入电量、电芯温度、枪端电压电流等)。平台侧规则:
按 订单号 查询
0x53最新一条VehicleBatteryInfo,回填 VIN 再 insertmaintain_process_data。
0x54 养护详情同理。这是和协议方一起定的:终端省带宽,平台用订单号串会话。
8.2 0x55 数据域摘要(36 字节)
包含:上报时间(BCD)、枪 ID、订单号(ASCII)、养护前/中 SOC、充入电量、充电模式、电芯温差/最高最低温度及编号、枪电压(0.1V)、枪电流(0.01A)、输出功率(0.01kW) 等。详见 MaintainProcessReq。
8.3 其它上行命令的业务含义(简表)
| 命令 | 业务动作 |
|---|---|
0x50 |
更新 evc_device 在线状态、软硬件版本、SIM |
0x51 |
维护 heartbeat_log 最后心跳时间 |
0x52 |
更新 evc_device_gun.gun_status(空闲/插枪/充电/故障) |
9. 下行指令:HTTP 如何打到 TCP Channel
管理端点「开启养护」→ Controller → Service → 需要 往终端写 0x40 并 同步等 0xE0。
9.1 MaintainDownlinkLocalExecutor 流程
channelManager.isPileOnline(pileId)--- 本机是否有活跃 Channel- 组装
ProtocolMessage:CMD_MAINTAIN_START+MaintainStartReq.toBytes() getNextSerialNo(pileId)生成序号MAINTAIN_START_RESP_MAP.put(pileId + "_" + serialNo, CompletableFuture)channel.writeAndFlush(msg).sync()future.get(timeout)--- 默认netty.tcp.timeout=10秒UploadDataProcessServiceImpl.processMaintainStartResp收到0xE0时completeFuture
结束养护 0x41、升级 0x42 同理,对应 0xE1、0xE2。
9.2 编码器如何组帧
ProtocolEncoder:
- 写固定头 → 占位长度 → 版本/桩ID/序号/命令字/数据域
- 回填长度(版本到 CRC 前)
- 对当前 ByteBuf 全文算 CRC 追加
- 打 INFO 级 完整下行 HEX(联调神器)
- 协议日志异步入队
10. 多副本 K8s:连接在 A Pod,请求在 B Pod
10.1 问题定义
- TCP:终端被 LoadBalancer 分到某一 Pod,连接粘在该 Pod 的
ChannelManager里。 - HTTP:Ingress 把管理端请求打到 任意 Pod。
若 HTTP 落在 Pod-B,而连接在 Pod-A,直接 getChannel 必然失败。
10.2 方案一:Redis 桩归属(带 TTL)
NettyPileOwnershipService.touch(pileId):
KEY: battery:netty:pile-owner:{pileId}
VALUE: {instanceId} # 如 Pod 名 metadata.name
TTL: 90s(可配置 ownership-ttl-seconds)
每次收到该桩上行报文刷新 TTL。HTTP 下发前先查 owner:
- 本机:走
MaintainDownlinkLocalExecutor - 它机:
NettyClusterDownlinkBridge.requestRemote*
断开连接时 Lua 脚本 仅当 value 等于本 instanceId 才 DEL,防止误删新连接归属。
单实例开发:NETTY_CLUSTER_ROUTING_ENABLED=false,不访问 Redis。
10.3 方案二:Redis Pub/Sub 下行桥
Pod-A Netty Redis Pub/Sub Pod-B HTTP Pod-A Netty Redis Pub/Sub Pod-B HTTP #mermaid-svg-rTp6WuazZJHPkvg2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rTp6WuazZJHPkvg2 .error-icon{fill:#552222;}#mermaid-svg-rTp6WuazZJHPkvg2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rTp6WuazZJHPkvg2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rTp6WuazZJHPkvg2 .marker.cross{stroke:#333333;}#mermaid-svg-rTp6WuazZJHPkvg2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rTp6WuazZJHPkvg2 p{margin:0;}#mermaid-svg-rTp6WuazZJHPkvg2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rTp6WuazZJHPkvg2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rTp6WuazZJHPkvg2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 .sequenceNumber{fill:white;}#mermaid-svg-rTp6WuazZJHPkvg2 #sequencenumber{fill:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 .messageText{fill:#333;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rTp6WuazZJHPkvg2 .labelText,#mermaid-svg-rTp6WuazZJHPkvg2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .loopText,#mermaid-svg-rTp6WuazZJHPkvg2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rTp6WuazZJHPkvg2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-rTp6WuazZJHPkvg2 .noteText,#mermaid-svg-rTp6WuazZJHPkvg2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rTp6WuazZJHPkvg2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rTp6WuazZJHPkvg2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rTp6WuazZJHPkvg2 .actorPopupMenu{position:absolute;}#mermaid-svg-rTp6WuazZJHPkvg2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-rTp6WuazZJHPkvg2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rTp6WuazZJHPkvg2 .actor-man circle,#mermaid-svg-rTp6WuazZJHPkvg2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-rTp6WuazZJHPkvg2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PUBLISH battery:netty:downlink:{instanceA} JSON onCommandMessage MaintainDownlinkLocalExecutor PUBLISH battery:netty:downlink-ack complete CompletableFuture
RemoteDownlinkEnvelope 携带 correlationId、action(START/STOP/UPGRADE)、pileId、orderNo、FTP 参数等;ACK 频道 battery:netty:downlink-ack 把成功/失败带回发起方。
10.4 K8s 部署要点(仓库 script/k8s/)
Deployment(摘录):
replicas: 3- 容器端口:
8088(HTTP)、9000(Netty) NETTY_INSTANCE_ID←metadata.name(每 Pod 唯一)NETTY_CLUSTER_ROUTING_ENABLED来自 ConfigMap
Service:
evc-http:ClusterIP,给 Ingressevc-netty:LoadBalancer,暴露 9000 给终端(公网/内网 VIP)
注意 :Ingress 只配 HTTP;不要把二进制 TCP 硬塞 Ingress。终端接入走 Netty Service。
当前探针探的是 HTTP 端口------若 Netty 假死而 HTTP 仍响应,Pod 可能不会被重启。生产建议对 9000 做 TCP 探针或独立监控。
11. 协议日志与排障手册
EvcProtocolMessageLogService 统一处理:
| 场景 | 行为 |
|---|---|
| 上行入站 | 同步 insert + ThreadLocal 绑定日志 ID,业务结束后更新状态 |
| 出站下行 | 有界队列异步 insert,不阻塞 Encoder |
| 解码失败 | saveDecodeFailedInbound 存原始帧 HEX |
| 下行失败 | 离线、超时、终端返回失败等独立事务记录 |
业务状态包括:已处理、失败、解析失败、未知命令字等。
11.1 我建议的排障顺序
- Netty 是否监听 9000 --- 启动日志
Netty TCP服务启动成功 - 终端是否建连 ---
ConnectHandler的channelActive - 是否拆帧成功 --- 解码失败表是否有 HEX
- CRC --- 密钥是否与终端一致(
netty.protocol.crc-key) - 命令字是否命中 switch ---
收到上行报文,命令字 0x.. - 业务是否抛 IllegalArgumentException --- 数据域长度/格式
- 队列是否满 ---
入站队列已满,关闭连接 - 多副本 --- Redis 里 owner 是否指向持有连接的 Pod
11.2 典型日志关键词
报文固定头错误
报文长度不匹配
报文CRC校验失败
入站队列已满
本实例电桩不在线
集群下行超时
开启养护等待应答超时
12. 本地运行与配置清单
12.1 环境
- JDK 21、Maven
- MySQL、Redis(集群路由开时需要)
- Profile:
local(application.yaml默认)
12.2 启动
bash
mvn -pl evc-server -am spring-boot:run
主类:com.usteu.evc.server.EvcServerApplication
12.3 Netty 相关配置(application-local.yaml)
yaml
netty:
protocol:
crc-key: ${NETTY_PROTOCOL_CRC_KEY:8ECF1ABF} # 与终端一致
cluster:
routing-enabled: ${NETTY_CLUSTER_ROUTING_ENABLED:false}
instance-id: ${NETTY_INSTANCE_ID:${HOSTNAME:localhost}}
ownership-ttl-seconds: 90
tcp:
port: 9000
boss-threads: 2
worker-threads: 8
business-threads: 32
ingest-queue-capacity: 5000
ingest-consumer-threads: 50
keep-alive: true
timeout: 10
12.4 用 TCP 工具自测
可用 nc 或网络调试助手发送 HEX 帧(需自行按协议算 CRC)。更稳妥是写 JUnit 测 BatteryInfoReq.parse、CRC16Util.checkCRC,我仓库里已有 BCDCodecUtilTest、ReconnectInfoReqTest 等,改字段先跑测试再联真机。
13. 我踩过的坑与建议的实施顺序
13.1 坑
- 在 Worker 线程里写 SQL --- 初期试过,压测时延迟尖刺明显;必须进 Ingest 或 business 线程池。
- 长度字段含义理解反 --- 协议写的是「版本到 CRC」,不是「整帧」;算错会导致永远拆不出帧。
- pileId 与 0x50 数据域桩号混用 --- 重连用 HEX 转十进制,其它命令用帧头 Int,文档要写清。
- Connection reset 刷 ERROR --- K8s/LB 探测和终端断电都会触发,已降级 WARN。
- CompletableFuture 泄漏 --- 下行必须用
finally从 Map remove,超时也要 markDownlinkFailed。 - 多副本忘配 instanceId --- 三个 Pod 如果都用
localhost,Redis 归属会互相覆盖。
13.2 推荐实施顺序(可照着做)
| 阶段 | 交付物 |
|---|---|
| 1 | 协议 PDF + 命令字/数据域表 + CRC 样例向量(与终端对齐) |
| 2 | 纯 Java parse + CRC 单元测试,不碰 Netty |
| 3 | Netty 拆帧 + Decoder/Encoder + 打 HEX 日志 |
| 4 | 0x51 心跳 + 0x50 重连 + ChannelManager |
| 5 | Ingest 队列 + 0x53/0x55 落库 + 应答 |
| 6 | 0x40 下行 + Future 等应答 + HTTP 接口 |
| 7 | Redis 归属 + Pub/Sub + K8s 三副本压测 |
| 8 | 协议日志表 + 监控告警 |
14. 后续演进方向
- 在线状态机 :心跳、重连、断连统一推导
evc_device.online_status,而不是只靠 Channel 是否存在。 - 入站优先级:队列满直接断连较粗暴,可按桩 ID 或命令字分队列。
- 协议日志归档:数据量大后冷热分离或按日分表。
- 连接规模上万:评估独立接入集群、或 MQTT 网关与 TCP 并存。
- 安全:TLS over TCP、设备证书、帧级鉴权(当前依赖 CRC + 私网)。
结语
这套 EVC 平台对我而言,难点不在 Spring Boot CRUD,而在 二进制协议、长连接、多副本路由 叠在一起。Netty 负责把字节变成 ProtocolMessage;有界队列和业务线程池负责把 稳定性 握住;Redis 负责在 K8s 里回答「这根 TCP 线连在哪台机器上」。
车辆电池信息(0x53) 只是众多命令字中的一条,但它把 VIN、容量、循环次数和后续 0x55 养护过程 串成一条业务链------协议、解析、订单三处对齐,数据才闭环。