
简介面向Java短信服务开发者的CMPP3.0协议实现示例包集中解决中国移动短信网关对接中的长连接保活、消息编解码、并发收发等实际难点适合正在构建短信接口或研究协议底层交互的初中级工程师学习。压缩包共十七个文件以十四个Java源文件为主体覆盖协议实体、收发线程、定时任务等核心模块另附一份properties配置文件便于设置网关地址、账号和心跳参数两个txt说明文档可帮助理解代码结构与运行流程。整个资源仅二十三KB结构紧凑适合快速阅读和二次改造已有九百四十九人学习参考。通过这份实现读者能够看懂CMPP_SUBMIT提交、CMPP_DELIVER下行接收、CMPP_QUERY状态查询等命令的交互流程也能掌握长连接、心跳保活、GBK编码转换、异常重试等机制在Java工程中的具体写法对自建短信收发模块有可直接参考的价值。1. CMPP 3.0 的 Java 实现究竟在做什么先看清它在短信链路里的位置做短信业务的人迟早会撞上 CMPP 3.0。它是中国移动面向 SP/企业短信平台开放的网关协议Java 实现通常是自建短信网关、企业通知中心或运营商接入模块里最核心的一段代码。很多人以为 CMPP 的难点是编解码实际接进去才发现真正的成本在连接状态管理、超时重发、状态报告对账这些不显眼的地方。这篇笔记按我做过的一个从零到上线的 CMPP 3.0 Java 接入方案来讲覆盖报文结构、Netty 建连、下发与回执、常见翻车点最后给出模拟验证的思路。适合手里已经有一个 Java 项目、正准备联调运营商网关或者被现成网关 SDK 的黑匣子坑过、想自己掌控协议的团队。2. 报文结构先立住12 字节消息头与 CONNECT/SUBMIT/DELIVER 的字段拼法2.1 为什么先看消息头CMPP 3.0 每条报文都是「头 体」的定长关系CMPP 3.0 的报文结构非常朴素每条消息由 12 字节的公共消息头和消息体组成。消息头固定三段total_length4 字节无符号整数表示整条消息含头的字节数command_id4 字节无符号整数标识命令字sequence_id4 字节无符号整数标识请求流水号。Java 侧用 Netty 的 ByteBuf 读写时最省事的做法是直接按大端序Big-Endian依次写入。网关侧对字节序非常敏感用 ByteOrder.BIG_ENDIAN 是移动网关的常见约定。不要因为本机是小端就在编码时自作主张ByteBuf 默认就是大端按顺序 writeInt / writeBytes 即可。消息头的关键作用有两个一是让接收方拿到 total_length 后能判断“一条完整报文到没到”二是通过 command_id 决定后续消息体怎么解析。CMPP 3.0 的常见命令字我都建议定义成常量避免散落 Magic Number。注意响应类的 command_id 和请求类相差 0x80000000比如 CMPP_CONNECT 是 0x00000001CMPP_CONNECT_RESP 是 0x80000001。2.2 六个核心命令字从连接到退出的完整会话闭环CMPP 3.0 一个会话内需要打交道的命令不算多但每个都承载不同阶段。连接阶段用 CMPP_CONNECT / CONNECT_RESP业务阶段用 CMPP_SUBMIT / SUBMIT_RESP 和 CMPP_DELIVER / DELIVER_RESP保活阶段用 CMPP_ACTIVE_TEST / ACTIVE_TEST_RESP退出阶段用 CMPP_TERMINATE。覆盖这四个阶段一条长连接就能把短信下发、上行、回执都跑完。我把命令字按“阶段”整理成了一张表方便在代码里做 switch 分发时对照阶段command_id方向消息体重点连接0x00000001SP → ISMGsource_addr、authenticatorSource、version、timestamp连接应答0x80000001ISMG → SPstatus、authenticatorISMG、version下发0x00000004SP → ISMGMsg_Src、Msg_Fmt、Msg_Content、Msg_Length下发应答0x80000004ISMG → SPMsg_Id、Result上行/回执0x00000005ISMG → SPDest_Id、Src_Terminal_Id、Msg_Content回执应答0x80000005SP → ISMGMsg_Id 原样返回心跳0x00000008SP → ISMG消息体为空可带自定义数据心跳应答0x80000008ISMG → SP4 字节保留位从上表能看到一个容易忽略的点DELIVER 的方向是网关主动推给 SP 的SP 收到后必须回 DELIVER_RESP否则网关侧会认为 SP 没收到反复重推。我见过不止一个项目只处理 SUBMIT_RESP忘了给 DELIVER 回包导致上行和状态报告越堆越多最终被网关判定为异常连接断开。2.3 消息体字段的拼写顺序是硬约束不是约定CMPP 3.0 的消息体字段顺序在协议文档里是严格定义的Java 实现时最容易翻车的地方就是字段顺序记错。比如 CMPP_SUBMIT从 Msg_Type 开始依次是 Need_Report、Priority、Service_Id、Fee_User_Type、Fee_Terminal_Id、Fee_Terminal_Type、TP_Pid、TP_Udhi、Msg_Fmt、Msg_Src、Msg_Length、Msg_Content、Msg_Id、Pk_Total、Pk_Number。其中 Service_Id 是 10 字节定长Fee_Terminal_Id 是 21 字节定长Msg_Src 是 6 字节定长。写编码代码时一定不能图省事只按字段名插值要按“定长字段补位、变长字段紧跟长度”的原则来。定长字段用空格补足协议文档一般写“Octet String”补位符用 0x20 空格变长字段Msg_Content则由前面的 Msg_Length 字段指明字节数。为什么强调这个因为 Java 开发很容易把 CMPP 当成 JSON 来写字段动态拼接结果定长字段少补了位网关解析错位后返回的应答和请求对不上这种问题从日志看像玄学其实是编码边界错了。2.4 用枚举把命令字和解析逻辑捆在一起我习惯的做法是先写一个 CmppCommandId 枚举把命令字、请求/响应关系、消息体解析入口都挂上去。这样在 ChannelInboundHandler 里收到一条报文时只需要用 command_id 查枚举就能直接调到对应的 decode 方法不用写一长串 if-else。public enum CmppCommandId { CONNECT(0x00000001, true), CONNECT_RESP(0x80000001, false), SUBMIT(0x00000004, true), SUBMIT_RESP(0x80000004, false), DELIVER(0x00000005, false), DELIVER_RESP(0x80000005, true), ACTIVE_TEST(0x00000008, true), ACTIVE_TEST_RESP(0x80000008, false); private final int id; private final boolean fromSp; CmppCommandId(int id, boolean fromSp) { this.id id; this.fromSp fromSp; } public static CmppCommandId fromId(int id) { for (CmppCommandId cmd : values()) { if (cmd.id id) { return cmd; } } return null; } }这个枚举里 fromSp 字段用来标记哪些命令字是 SP 主动发起的方便在会话状态机里判断“这是请求还是应答”。收到未知 command_id 时返回 null直接打印告警日志并丢弃比强行解析安全得多。有一点要注意CMPP 3.0 的应答消息和请求消息 ID 差 0x80000000这个位运算关系可以让代码少维护一半映射但不要依赖它来还原请求类型因为 sequence_id 才是真正用来关联请求应答的。3. 用 Netty 在本地跑通最小会话登录认证、心跳与拆包参数3.1 选 Netty 而不是裸 Socket省的是半包处理和线程模型CMPP 3.0 对接方案里通信层用 Netty 4.1 是 Java 阵营里最常见的选择。理由不是单纯因为它“高级”而是 CMPP 的 TCP 长连接天然需要处理三类问题粘包/半包、异步响应关联、空闲超时。这三件事用原生 Socket 写都有现成方案但每次都要重复调Netty 提供了现成的拆包器和 IdleStateHandler。如果用 Spring Boot 做业务层Netty 的 Channel 可以由一个独立的 Spring Bean 管理网关连接的生命周期和业务 Service 解耦。注意 Netty 的 worker 线程里不要直接调短信业务的下游接口否则一个慢 SQL 会卡住整个 Channel 的 IO 线程导致所有消息超时。常见的做法是 Netty 收到 SUBMIT_RESP 或 DELIVER 后转交给业务线程池或消息队列处理。3.2 最小客户端启动代码连接、拆包、注册回调先从能跑通的基础代码开始。Bootstrap 里最关键的三个配置是拆包器、空闲检测、业务 Handler。EventLoopGroup group new NioEventLoopGroup(1); Bootstrap b new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new LengthFieldBasedFrameDecoder( 1024 * 1024, 0, 4, 0, 0)); ch.pipeline().addLast(new CmppDecoder()); ch.pipeline().addLast(new CmppEncoder()); ch.pipeline().addLast(new IdleStateHandler(0, 40, 0)); ch.pipeline().addLast(new CmppClientHandler()); } }); ChannelFuture f b.connect(gatewayHost, gatewayPort).sync();这段配置里 LengthFieldBasedFrameDecoder 的五个参数值得展开最大帧长给 1MB因为 CMPP 3.0 单条消息不会超过这个量级lengthFieldOffset 是 0因为 total_length 就在报文最前面lengthFieldLength 是 4对应消息头第一个字段lengthAdjustment 为 0total_length 已经包含了消息头自身initialBytesToStrip 为 0保留完整报文让 CmppDecoder 自己去读 total_length 和 command_id。initialBytesToStrip 如果是 4前面四个字节会被剥掉后续解码器就看不到 total_length反而不方便。IdleStateHandler 的第二个参数 40 表示写空闲 40 秒触发一次事件。CMPP 网关侧通常 2 分钟没有数据就会断开连接所以心跳周期设在 40 秒左右是常见做法留足重试余量。TCP_NODELAY 必须开短信报文一般都很短Nagle 算法会把小包攒一起发造成毫秒级延迟网关侧超时判断会变得更敏感。3.3 登录认证authenticatorSource 不是简单拼字符串 MD5CMPP_CONNECT 的认证字段 authenticatorSource 是登录被拒的最常见原因。它的算法是MD5(Source_Addr 9 字节的 0x00 shared secret timestamp)这个 timestamp 和消息头里的 sequence_id 没有关系是独立的 4 字节时间戳格式是 MMDDHHMMSS 的十进制数字。注意是“月日时分秒”不是 Java 里常用的 yyyyMMddHHmmss。public static byte[] buildAuthenticatorSource(String sourceAddr, String sharedSecret, String timestamp) { ByteBuffer buf ByteBuffer.allocate(64); byte[] addrBytes sourceAddr.getBytes(StandardCharsets.US_ASCII); buf.put(addrBytes); for (int i 0; i 9; i) { buf.put((byte) 0x00); } buf.put(sharedSecret.getBytes(StandardCharsets.US_ASCII)); buf.put(timestamp.getBytes(StandardCharsets.US_ASCII)); byte[] input new byte[buf.position()]; buf.flip(); buf.get(input); return DigestUtils.md5(input); }这段代码里有三个细节。第一Source_Addr 就是登录账号比如企业代码按 ASCII 写入第二shared secret 是双方在后台约定的密码同样用 ASCII不需要转 GBK第三timestamp 在这里是 10 位十进制数字的字符串拼进 MD5 原文。整个 authenticatorSource 是 16 字节刚好一个 MD5 长度不要截断也不要续拼。很多登录失败是因为补了 9 个 0 但补在了字符串里“0”字符而不是补二进制 0x00这两者 MD5 结果完全不同。3.4 心跳要发 CMPP_ACTIVE_TEST不要指望 TCP KeepAliveCMPP 3.0 的保活机制是应用层心跳也就是定时发 CMPP_ACTIVE_TEST。TCP 层的 KeepAlive 默认 2 小时才探测一次而且探测失败只能说明 TCP 层断了网关进程假死但 TCP 还通的情况下根本没反应。我用 IdleStateHandler 监听写空闲在 userEventTriggered 里发心跳这是最干净的做法。Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent e (IdleStateEvent) evt; if (e.state() IdleState.WRITER_IDLE) { CmppActiveTest activeTest new CmppActiveTest(); activeTest.setSequenceId(sequenceGenerator.next()); ctx.writeAndFlush(activeTest); } } else { super.userEventTriggered(ctx, evt); } }心跳包的 sequence_id 也要走统一的序列号生成器不要写死 0。网关回 ACTIVE_TEST_RESP 时通过 sequence_id 就能确认这条心跳是对应哪一次请求。如果连续两次心跳都没有收到应答就不要傻等了主动 close 触发重连。重连退避建议按 1 秒、2 秒、4 秒指数递增最多 30 秒封顶否则网关故障恢复后你的连接可能还停在旧的退避周期里。3.5 最小编解码器的骨架先读 total_length 再分发给业务CMPP 的 decoder 我建议直接继承 ByteToMessageDecoder不要在 pipeline 里再叠加别的 FrameDecoder。虽然上面加了 LengthFieldBasedFrameDecoder但这个拆包器只是解决 TCP 粘包之后 CmppDecoder 仍然要读 4 字节的 total_length 来校验报文完整度。public class CmppDecoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { if (in.readableBytes() 12) { return; } in.markReaderIndex(); int totalLength in.readInt(); int commandId in.readInt(); int sequenceId in.readInt(); if (totalLength 12 || totalLength 1024 * 1024) { in.resetReaderIndex(); ctx.close(); return; } if (in.readableBytes() totalLength - 12) { in.resetReaderIndex(); return; } byte[] body new byte[totalLength - 12]; in.readBytes(body); out.add(new CmppPacket(commandId, sequenceId, body)); } }这个 decoder 的完整校验逻辑是先 mark 读指针读够 12 字节消息头后判断剩余字节是否够 totalLength - 12。不够就 resetReaderIndex等下次 channelRead 再继续这正是 LengthFieldBasedFrameDecoder 已经做过一遍的事。为什么还要再做一次因为有些网关实现会在同一帧里塞多条消息拆包器只负责切帧不负责校验帧内 body 是否完整双保险能避免偶发解析错乱。totalLength 小于 12 属于非法报文直接关连接比丢包更安全避免后续数据全部错位。4. 下发与状态报告编码长短信拆分、msg_id 关联和窗口控制4.1 把一条普通短信编成 CMPP_SUBMIT字段顺序比 JDBC 参数还严格短信下发核心消息是 CMPP_SUBMIT。编码时最忌讳用 HashMap 存字段再遍历拼字节顺序一乱就是灾难。我一般直接用一个 Builder 类显式按协议顺序写字段。public ByteBuf encode(CmppSubmit submit) { ByteBuf buf allocator.buffer(256); int msgLength submit.getMsgContent().length; buf.writeInt(12 26 21 1 1 6 1 msgLength 8 1 1); buf.writeInt(CmppCommandId.SUBMIT.getId()); buf.writeInt(submit.getSequenceId()); buf.writeByte(submit.getMsgType()); // 1普通短信 buf.writeByte(submit.getNeedReport()); // 1要状态报告 buf.writeByte(submit.getPriority()); // 0普通优先级 writeFixedString(buf, submit.getServiceId(), 10); buf.writeByte(submit.getFeeUserType()); writeFixedString(buf, submit.getFeeTerminalId(), 21); buf.writeByte(submit.getFeeTerminalType()); buf.writeByte(submit.getTpPid()); buf.writeByte(submit.getTpUdhi()); buf.writeByte(submit.getMsgFmt()); writeFixedString(buf, submit.getMsgSrc(), 6); buf.writeByte(msgLength); buf.writeBytes(submit.getMsgContent()); buf.writeLong(submit.getMsgId()); // 拆包时各包相同 buf.writeByte(submit.getPkTotal()); buf.writeByte(submit.getPkNumber()); return buf; }total_length 的计算建议用 12 加上后面所有字段的固定长度再加变长内容长度。上面的 26 对应 Msg_Type 到 Msg_Src 前面若干定长字段的合计21 是 Fee_Terminal_Id后面的 8 是 Msg_Id最后的两个 1 是 Pk_Total 和 Pk_Number。这个数值一旦算错整个帧就会错位所以不建议手写硬编码最好用一个计算函数。如果你发现网关总是回 Result 非 0先别怀疑号码把你的 total_length 算一遍。4.2 msg_fmt 与中文编码GBK、UCS2、UTF-8 的选择逻辑CMPP 3.0 消息体里 Msg_Fmt 字段控制短信内容编码。常见的取值有 0ASCII、8UCS2、15GBK。做国内短信业务时中文内容基本在 8 和 15 之间选。我一般默认用 8UCS2也就是 Java 里的 UTF-16BE因为 UCS2 对运营商网关的兼容性最好解析出来的长度计算也直观。GBK 的好处是同样的 140 字节能装下更多汉字70 个汉字但部分网关在状态报告里对 GBK 的编解码处理不完全一致遇到乱码时排查成本高。Msg_Length 字段是消息体字节数不是字符数。用 UTF-16BE 编码时一个汉字占 2 字节70 个汉字的普通短信正好 140 字节这是 CMPP 单条短信的硬上限。如果内容超过这个长度就必须走长短信拆分不能硬塞。4.3 长短信拆分6 字节的 TP_udhi 头是多数项目的分水岭长短信的机制是在短信内容前加一个 TP_udhi 用户数据头再按片段拆成多条 SUBMIT。CMPP 3.0 里常见做法是在 Msg_Content 前加 6 字节头0x05 0x00 0x03 参考号 总条数 当前序号然后把 TP_Udhi 字段置 1。参考号自己生成同一批短信的所有片段用同一个参考号。public static ListCmppSubmit splitLongMessage( String content, String msgSrc, int needReport, int sequenceBase) { byte[] contentBytes content.getBytes(StandardCharsets.UTF_16BE); int singleSize 134; // 67 个 UCS2 字符 6 字节 udhi 头 140 int totalPk (contentBytes.length singleSize - 1) / singleSize; byte ref (byte) (ThreadLocalRandom.current().nextInt(256)); ListCmppSubmit submits new ArrayList(totalPk); for (int i 0; i totalPk; i) { int offset i * singleSize; int len Math.min(singleSize, contentBytes.length - offset); byte[] udhi new byte[] {0x05, 0x00, 0x03, ref, (byte) totalPk, (byte) (i 1)}; byte[] body new byte[udhi.length len]; System.arraycopy(udhi, 0, body, 0, udhi.length); System.arraycopy(contentBytes, offset, body, udhi.length, len); // 构造 CmppSubmittpUdhi1pkTotaltotalPkpkNumberi1 } return submits; }拆包时每个 SUBMIT 的 Msg_Content 都是「udhi 头 该片段内容」Msg_Length 包含 udhi 头长度。这里有个常见坑UCS2 的普通短信能放 70 字但加 6 字节头后必须退回 67 字否则总长度超过 140 字节会被网关直接拒绝。还有一种实现用 7 字节头0x06 0x08 0x04 加四位内容即 16 位参考号兼容性也常见但联调时以网关支持的格式为准。我默认用 0x05 0x00 0x03 这套国内运营商对它的支持最普遍。4.4 submit_resp 到达后用 msg_id 换出 pending 记录网关收到 SUBMIT 后会回 SUBMIT_RESP其中携带网关分配的 Msg_Id8 字节和 Result 字段。Msg_Id 是后续状态报告关联的唯一依据必须把它和业务方的消息 ID 做映射保存下来。实现时我建议维护一个 ConcurrentHashMapLong, PendingMessagekey 是 sequence_idvalue 里存业务消息 ID、目标号码、片段序号、发送时间。收到 SUBMIT_RESP 后先校验 ResultResult 为 0 表示成功用网关返回的 Msg_Id 替换 key 重新放入映射等状态报告回来时再消费。Result 非 0 时直接走失败回执把 pending 记录移除不要等状态报告。这个映射表还承担窗口控制。如果网关没回包或回包慢pending 会持续增长。超过阈值我一般设 32就不再从业务层取新的 SUBMIT进入等待。网关侧对并发在途消息数是有限制的无脑并发只会触发网关限流或丢包。4.5 状态报告是被动消息处理不好等于对账永远对不上状态报告通过 CMPP_DELIVER 推给 SP当 DELIVER 里的 Registered_Delivery 字段为 1 时Msg_Content 不是上行短信正文而是状态报告数据。它包含 msg_id、状态stat、提交时间、到达时间等字段按协议规定的偏移截取即可。状态报告和 SUBMIT_RESP 的区别在于SUBMIT_RESP 表示网关“收下了”这条短信状态报告表示手机“收到了”或失败原因。做短信平台计费和对账时两个信号都要留痕。我处理 DELIVER 的思路是Registered_Delivery1 时解析状态报告用 Msg_Id 查映射表更新业务消息状态Registered_Delivery0 时按用户上行短信处理。千万不要把状态报告当上行内容直接入库否则对账数据全是脏的。网关如果没收到 DELIVER_RESP会在超时窗口内重推同一条状态报告所以 DELIVER_RESP 的 Msg_Id 一定要原样返回不要自己生成新的。5. 避坑排查Java 对接 CMPP 3.0 最常见的 5 个翻车点从登录失败到连接泄漏5.1 登录永远失败网关返回的 status 一直是非 0现象TCP 连接能建立但 CONNECT_RESP 的 status 字段不是 0连接被网关断开。原因九成是 authenticatorSource 算错。常见的有三种timestamp 用了 yyyyMMddHHmmss 而不是 MMDDHHMMSSshared secret 拼 MD5 前没有按 ASCII 而是按 UTF-8 编码source_addr 补 0 时补成了字符 0 而不是字节 0x00。另一种是 timestamp 的数值和消息体里写入的 4 字节不一致。解决先把要拼 MD5 的原文打出来对照协议文档逐字节检查。我自己的排查经验是把 source_addr 的十六进制、补位 9 个 0x00、shared secret 的 ASCII hex、timestamp 的 ASCII hex 分开打印用在线 MD5 或 Java 里 MessageDigest 打印 hex 结果和网关接入文档里的样例比对。如果你们拿不到样例就先自己写个单元测试固定入参保证同一入参多次计算结果一致再找网关侧核对计算规则。5.2 拆包正常但偶发乱码和下一条命令错位现象一段时间跑得好好的突然某个长短信内容开头出现乱码或者后续消息全部解析失败日志里报 total_length 校验异常。原因长短信拆包的 udhi 头长度没算进 Msg_Length或 Pk_Total、Pk_Number 超过 255。还有一种是拆包后多个片段复用了同一个 sequence_id网关把后面的片段当成前一个片段的重复包丢弃导致对端和本端对消息边界的认知不一致。解决拆包时每个片段必须独立走 sequenceGenerator.next()Pk_Total 用实际片段数Pk_Number 从 1 开始。加一个自检方法把所有片段拼回来后用 UTF-16BE 解码和原始内容逐字符比对。自检通过再发线上这能挡住一大半长短信问题。5.3 连接会无缘无故被网关关闭报错是 CLOSE_WAIT 堆积现象运维监控发现连接每天早上断一次重连后正常但一段时间后又断系统里出现大量 CLOSE_WAIT 状态的 TCP 连接。原因网关侧一般 2 分钟没有收到任何数据就会断开空闲连接。如果你依赖业务下发来驱动连接活跃夜间业务低谷就必然断链。CLOSE_WAIT 堆积通常是应用层收到 close 事件后没有把写队列里的数据清掉也没有释放对应的 Channel导致 TCP 四次挥手卡在中间。解决加 IdleStateHandler 写空闲心跳间隔不要超过网关断链阈值的一半40 秒是比较稳的值。收到 ChannelInactive 事件时把 pending 映射表里所有 SUBMIT 记录标记为失败然后走指数退避重连不要立即重连。CLOSE_WAIT 要用 netstat 检查每次重连前把旧 Channel 的所有引用置空避免内存里挂着大量废弃连接。5.4 SUBMIT_RESP 和请求对不上日志里 sequence_id 错乱现象发送顺序是 1001、1002、1003收到的应答顺序是 1002、1003、1001按顺序匹配结果全错。原因CMPP 是异步协议应答不保证按请求顺序返回。有些 Java 实现用 List 或队列按发送顺序匹配应答这就会错位。另一个原因是 sequence_id 生成器重启后从 1 开始自增和上次进程的流水号重复网关侧可能还在处理上一条连接的请求应答串了。解决应答匹配必须用 Map 按 sequence_id 关联不要按顺序 poll。sequence_id 生成器要把日期编码进高 16 位低 16 位自增重启后高 16 位会变天然的避免跨天重复。我把 sequence_id 的生成规则定为高 16 位是 MMDD 的十六进制低 16 位是 AtomicInteger 自增这样每天开头自然归零一次版本升级重启也不会撞号。5.5 连接看起来没问题但线程池耗尽接口越来越慢现象短信下发接口偶尔卡住线程池拒绝任务但连接状态是 ESTABLISHED心跳也正常。heap dump 里看到大量等待中的发送任务。原因业务侧把短信内容直接 Netty writeAndFlush如果网关侧处理不过来Channel 的写缓冲会无限堆积业务调用却已经返回成功。等网关恢复后积压消息突然全量发出去又触发网关限流。这是典型的背压没做好。解决在业务线程和 Netty Channel 之间加一个有界阻塞队列队列满时发送接口快速失败或抛异常而不是继续堆积。窗口控制不能丢未确认的 SUBMIT 数量达到阈值后业务层不再向队列投递新任务。我在生产里用 Semaphore 控制总量acquire 不到就等或直接拒绝比靠 Netty 的 highWaterMark 更可控。6. 上线前的最后一公里多通道组装、模拟网关验证与验收清单6.1 把单连接扩成多通道别让一个账号卡死全部业务企业短信量上来后单连接往往不够用。CMPP 3.0 支持一个账号建立多条连接或配置多个账号分别连接不同网关。我一般把连接抽象成 CmppChannel 对象内部持有独立的 Channel、sequenceGenerator、pending 映射和信号量由 CmppChannelManager 统一管理。下发时按轮询选通道如果某个通道的 pending 数量接近窗口上限就跳过全部满时再等待。多通道最怕的是状态报告跨通道错乱。思路是不管哪个通道收到状态报告都直接查全局 Msg_Id 映射表不要按通道隔离。Msg_Id 是网关侧全局唯一的按通道拆分映射反而是过度设计。通道断线时Manager 自动把该通道的 pending 转失败不阻塞其他通道。6.2 先用自己的模拟网关验证再申请真实联调真实网关联调要排队而且测试号码受限大部分问题可以在本地用模拟网关先暴露。我通常用一个简单的 Netty Server 监听端口记录收到的 CMPP 报文按协议回 ACK收到 CONNECT 就回 CONNECT_RESP收到 SUBMIT 就回 SUBMIT_RESP 并将 Msg_Id 设为 sequence_id 的某种映射收到 ACTIVE_TEST 就回 ACTIVE_TEST_RESP。模拟网关的价值是能验证三件事客户端的认证算法是否正确模拟端校验 authenticatorSource、拆包是否完整模拟端按 total_length 累计报文、状态报告关联逻辑是否可靠模拟端主动推一条 Registered_Delivery1 的 DELIVER。真实网关联调时再验证运营商特有的行为比如长短信包头格式兼容性。验证时用 Wireshark 抓包看 TCP 层能确认粘包拆包但 CMPP 是私有协议Wireshark 不一定能解析出每个字段所以模拟网关里打印 hex dump 才是定位问题的关键手段。我在模拟网关里对每条报文都输出 total_length、command_id、sequence_id 的 hex和客户端日志比对一次就能抓出字段偏移错误。6.3 我用一份清单验收过了才敢切线上这份清单不是新想法是我第三次被网关掐断连接之后总结的第一认证字段单测固定入参结果可复现第二心跳 40 秒一次连发两次无应答自动重连第三SUBMIT 窗口不超过 32超出拒绝发送第四DELIVER 收到后 1 秒内回 DELIVER_RESP第五状态报告能通过 Msg_Id 关联到业务消息关联不到时有告警第六断开重连后 sequence_id 不重复。前三条保证连接稳定后三条保证数据可对账。第一次完整对接 CMPP 3.0 时我卡在 authenticatorSource 上整整两天后来发现是时间戳格式写成了 yyyyMMddHHmmss而协议要的是 MMDDHHMMSS。从此我养成了一个习惯凡是协议里的二进制字段先写十六进制比对测试再进业务代码这个习惯让我在后来的 CMPP 长短信拆分和状态报告推送里少踩了很多同样的坑。希望这篇笔记里列出的方案和坑能帮你把 CMPP 3.0 的 Java 接入从「能连上」推进到「敢上线」那这 9000 多字就值了。本文还有配套的精品资源点击获取