
先说一个很多人容易忽略的事实IM 即时通讯系统表面上是个“聊天工具”本质上是一套“长连接管理 消息可靠性 高并发推送”的组合体。网上一搜 Java IM 源码出来的开源项目少说也有十几个可真拿到公司内部用面对客服分流、组织架构同步、消息审计、多端登录这些定制需求时你会发现改别人的代码比从零写一套还难受。我去年帮一家创业公司从零落地过一套基于 Java 原生技术栈的 IM 系统这里说的“原生”不是让你从 Socket 手写开始而是以 JDK 为底座、以 Netty 为网络基础设施自己掌控协议、会话、存储、推送、部署这整条链路。这套方案的好处是每一层你都看得懂、改得动出问题能顺着代码一路查到根因而不是在黑盒里猜。这篇文章会把通信链路设计、消息可靠性机制、群聊分发策略、部署演进方案以及我实测踩过的几个大坑完整拆开讲适合正准备自研 IM、或者想彻底搞懂 IM 底层原理的 Java 后端开发者参考。1. 为什么还要坚持 Java 原生而不是直接套开源框架1.1 开源 IM 的“拿来主义”陷阱先聊一个很现实的问题OpenIM、悟空 IM、芋道 IM 这些开源项目功能截图一个比一个漂亮代码量也是动辄几十万行起步。很多人拉下来编译通过后觉得“已经到手了”结果一接业务就发现完全不是那么回事。IM 系统不是 CRUD 管理系统它的复杂度集中在连接状态、消息顺序、离线补偿、多端同步这些“实时性”细节里。当你需要改一个群公告的推送逻辑或者调整消息撤回的时序策略时必须把别人的连接管理、会话路由、存储模型全部吃透才能动手。我见过最典型的例子某个开源项目把消息表设计成单表不分区在线用户一到两万消息表直接卡死 MySQL而你想改它的存储层得先理解它散落在十几个类里的 DAO 调用链——这种改造工作量真的不如自己重写。1.2 什么才算真正的“Java 原生”实现我讲的“原生”落点其实是这条技术栈组合JDK 自带 NIO 与并发工具负责底层 IO 模型与线程调度Netty 只作为高性能网络通信库提供 Reactor 线程模型、编解码框架、拆包粘包处理业务链路协议定义、会话管理、消息存储、推送策略、接入层路由全部由自己实现。很多人纠结“用了 Netty 还算不算原生”我觉得这是把框架和基础设施搞混了。Netty 的定位是网络编程库不是 IM 框架它没有替你决定会话怎么存、消息怎么排序、离线消息怎么拉取。真正决定 IM 系统灵魂的恰恰是你自己写的这一层业务链路。这也是为什么我推荐自研 IM 时选 Netty 而不是选一个完整的 IM 开源框架——给你一块地基和给你一栋不能拆墙的房子是完全不同的两码事。1.3 哪些场景适合自研哪些场景别碰自研这里给一份我自己的选型判断表方便你对号入座场景特征推荐方案原因内部 OA/IM定制化需求极多自研Java 原生 Netty可控性最强改造成本最低只有 2~4 周交付时间后端人手不足直接接云厂商 IM 或开源项目自研连基本链路都跑不完面向 C 端日活百万以上自研但要做好架构分层开源方案很难支撑这种规模与定制业务是标准聊天无特殊合规要求开源项目二次开发省时省力前提是别碰核心链路我自己踩过一次教训有一个项目本来只需要做 App 内嵌客服聊天我按“标准 IM”的规模设计了群聊、已读回执、多端同步结果团队三个人干了三个月客服那边只用到了单聊 离线消息 工单关联。后来我把这套思路沉淀成一句话先搞清楚你的 IM 是“聊天工具”还是“业务系统里的一条实时通道”这决定了你是要造一台车还是只装一个轮子。2. 通信链路设计长连接、协议拆包与心跳保活2.1 通道选型TCP 长连接、WebSocket 还是 HTTP 轮询IM 的实时性全靠连接通道撑着通道选型直接决定上下行延迟和服务器资源消耗。我在实际项目里遇到过三种通道混用的情况这里直接给结论移动端 App、桌面客户端、服务端之间用 TCP 长连接。Netty 维护起来最顺手控制力最强适合自定义私有协议。浏览器端、H5 页面用 WebSocket。浏览器只认这个TCP 裸连在网页端根本推不进去。低实时性场景比如通知类消息、邮件提醒HTTP 短轮询或者 Server-Sent EventsSSE足够不要浪费长连接资源。通道这块最容易犯的错误是把 WebSocket 当成长连接的全部。其实 WebSocket 的底层也是 TCP它只是给 TCP 加了一层浏览器友好的握手与帧封装。如果你在服务端统一收敛成 TCP 长连接网关层做协议转换TCP 协议与 WebSocket 协议互转上层业务逻辑就不用关心客户端到底是从 App 来的还是从浏览器来的。2.2 自定义私有协议消息头到底该放哪些字段既然走 TCP 长连接就必须设计私有协议。很多新手喜欢直接用 JSON 字符串加换行符当协议开发期确实方便一上生产就出问题JSON 解析耗 CPU、消息体内含换行符会拆包错乱、没法做高效的二进制扩展。我们最终用的协议方案是“二进制消息头 可序列化消息体”消息头用固定 24 字节-------------------------------------------------------------------------------- | magic | version| command| flags | sequenceId | bodyLength | reserved | | 2 bytes| 1 byte | 1 byte | 1 byte | 8 bytes | 4 bytes | 7 bytes | --------------------------------------------------------------------------------magic魔数固定 0xAC 0xED用来快速识别非法连接防止非 IM 客户端乱发数据打爆服务端。command命令字比如 0x01 登录、0x02 心跳、0x03 单聊消息、0x04 群聊消息。flags标志位比如是否压缩、是否需要 ACK、是否是离线消息补偿。sequenceId客户端生成的递增序列号用于消息去重和服务端响应关联。bodyLength消息体长度这是拆包的关键字段。消息体默认使用 JSON 序列化因为业务组维护成本低后续如果遇到性能瓶颈再针对高频消息比如文本聊天改成 Protobuf。这里面的经验是协议头要固定、要紧凑消息体要灵活、要好维护不要为了追求极致的性能把体量很小的 JSON 也换成二进制工程上的收益不值当。2.3 粘包拆包Netty 的 LengthFieldBasedFrameDecoder 参数怎么调TCP 是流式协议没有消息边界。客户端连续发送多条消息时服务端可能一次读到半条消息或者一次读到好几条粘在一起。Netty 里解决这个问题最优雅的方式就是 LengthFieldBasedFrameDecoder。我们在代码里是这么配置的new LengthFieldBasedFrameDecoder( 1024 * 1024, // maxFrameLength单条消息最大 1MB超过直接报错 0, // lengthFieldOffset长度字段从第 0 字节开始 4, // lengthFieldLength长度字段占 4 字节 2, // lengthAdjustment长度字段之后还有 2 字节的 command 0 // initialBytesToStrip不剥离任何字节让后续 handler 拿到完整协议头 )有个细节必须强调lengthAdjustment 很多人搞不明白。如果协议设计是 [4 字节长度][2 字节 command][N 字节 body]长度字段的值是 N那么实际帧的总长度 4 2 N。Netty 拿到长度字段后要加上 lengthAdjustment 才能算出完整帧的结束位置。这里的取值是 2对应当前帧“长度字段之后、body 之前”的剩余字节数。调完拆包器之后不要马上联调先用十六进制报文工具测一遍边界情况半包、粘包、空 body、body 长度字段被恶意写大。我见过太多线上 IM 事故根子都出在拆包边界没测透。2.4 心跳保活为什么客户端心跳间隔要比服务端探测间隔短TCP 长连接不会自己一直活着中间任何一层网络设备路由器、防火墙、运营商 NAT都可能静默掐断空闲连接。所以 IM 系统必须有心跳机制。我们用 Netty 的 IdleStateHandler 实现// 服务端 ch.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS)); // 60 秒没读到客户端数据就触发 userEventTriggered // 客户端 ch.pipeline().addLast(new IdleStateHandler(0, 25, 0, TimeUnit.SECONDS)); // 25 秒没向服务端写出数据就主动发一次心跳包这里有个非常关键的经验客户端的写空闲时间25 秒必须小于服务端的读空闲时间60 秒。原因很简单客户端主动心跳是维持连接的“保鲜动作”服务端被动探测是“兜底清理动作”。如果两者反了客户端 60 秒才发一次服务端 60 秒没收到数据就判定超时很容易因为一次网络抖动导致连接被误杀客户端还来不及补救体验就会断崖式下降。心跳包本身要做得越轻量越好服务端收到后只需要更新最近活跃时间不用落库、不用回业务消息返回一个 4 字节的 ACK 即可。如果心跳要携带业务数据就说明你的设计已经跑偏了。2.5 断线重连指数退避必须加随机抖动客户端断线重连最忌讳的是所有客户端在同一时刻无限重试。想象一个场景晚上 12 点 Nginx 超时断开了一半连接第二天早上 8 点用户集中打开 App重连请求瞬间把服务端打崩——这不是网络问题这是重连策略问题。我用的方案是“指数退避 随机抖动”retryDelay Math.min(60, baseDelay * (2 ^ retryCount)) RandomUtil.randomInt(0, 5000); // baseDelay 初始 1 秒retryCount 最大重试次数从 1 秒开始第二次 2 秒第三次 4 秒依次翻倍上限 60 秒每次再加上 0~5 秒的随机值。这样既能保证大部分客户端在网络恢复后 1 分钟内连回来又不会形成重连请求的流量尖峰。还有一个容易漏的细节客户端重连成功后必须做一次增量同步把断线期间漏掉的消息拉回来。否则重连只是“连上了”消息还是对不齐。这个增量同步的位点就是消息可靠性设计里的 seq 概念下面展开聊。3. 消息可靠性链路随时会断但消息一条都不能丢3.1 消息生命周期一条消息从发出到已读的完整链路客户端小李给小王发一条文本消息这条消息会经历这样一串流程小李的客户端生成全局唯一 messageIdUUID 或雪花 ID把消息发送到服务端接入层接入层校验完整性后把消息投递到消息处理模块落库存储存储成功后服务端给小李回一条 ACK消息已收到服务端查询小王的在线状态如果在线推送到小王所在网关节点小王客户端收到后回执 ACK如果小王离线消息进入离线消息表等小王下次登录后按 seq 增量拉取小王看到消息后客户端上报已读回执单聊场景服务端更新会话已读位点。这条链路每一环都可能断。可靠性设计的核心就是在每一环都提供“重试 幂等 对账”的能力。3.2 ACK 机制发送方、接收方、服务端三方各自的职责ACK 不是只有一种。我一开始设计的时候只做了“客户端到服务端”的 ACK结果消息发送方永远不知道消息到底送达没有。后来拆成三类上行 ACK客户端发消息服务端持久化成功后回的业务确认。客户端超时没收到就重发重发带同一个 messageId。下行 ACK服务端推消息给接收方接收方客户端收到后回的确认。服务端如果没收到这个 ACK会重推几次超过次数就转入离线消息。已读回执接收方真正看到消息后上报这个改变的是会话的已读位点不影响消息是否投递成功。这里有个性能优化点下行 ACK 没必要逐条回客户端可以把多条消息的 ACK 合并成一个批量 ACK 包服务端用一个数组接收。否则群聊场景下几十条消息刷过来ACK 风暴能把服务端小包打满。3.3 消息序号seq与离线消息拉取每个用户维护一个单调递增的消息序号 seq这个 seq 由服务端统一分配。为什么不能由客户端自己生成因为多端登录的时候手机和电脑同时发消息客户端本地序号会冲突排序会乱。服务端分配 seq 的核心作用有三个消息排序依据离线消息增量拉取的游标重复消息去重的参考位点。离线消息的存储我用的是一张独立的离线消息表CREATE TABLE offline_message ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 接收方用户ID, msg_seq bigint NOT NULL COMMENT 该用户的全局消息序号, msg_id varchar(64) NOT NULL COMMENT 消息全局唯一ID, sender_id bigint NOT NULL DEFAULT 0, content text COMMENT 消息内容, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_seq (user_id, msg_seq) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;客户端登录成功后把本地已确认的最大 seq 上报服务端查出这之后的所有离线消息下发。这里有个大坑离线消息表会无限膨胀必须在每次拉取成功后清理过期数据。我们的策略是“拉取确认后延迟 5 分钟删除”防止客户端拉取完但还没落本地时崩溃导致数据丢失。3.4 消息幂等重发可以重来但不能重复入库ACK 超时重发机制上线后最直接的问题就是一条消息可能被发送方重复投递多次。如果没有幂等设计接收方就会看到两条一模一样的消息。幂等的关键就是 messageId。服务端收到一条上行消息时先查 Redis 里有没有这个 messageId 的处理记录Boolean first redisTemplate .opsForValue() .setIfAbsent(im:msg: messageId, 1, Duration.ofHours(24)); if (first null || !first) { // 重复消息直接返回 ACK不重复落库 return ack(messageId); }Redis 的 SETNX 天然适合做这种一次性去重但要注意给 key 设置过期时间避免消息去重表无限膨胀。如果消息量极大还可以把去重从 Redis 挪到本地 Caffeine 缓存 数据库唯一索引双层保证。我个人建议消息表对 messageId 建唯一索引这是最后一道兜底Redis 挂了也不会重复入库。4. 群聊与高并发场景别把单机方案的思路硬套到分布式4.1 群聊消息的两种分发策略扩散写与读扩散群聊是 IM 系统里最容易出性能问题的场景。一个 1000 人的群有人发一条消息是往群里每个人的收件箱里都存一份还是只存一条、大家各自拉取这两种方案各有代价扩散写消息到达服务端后给群内每个成员的消息表都插一条记录。好处是接收方离线消息拉取逻辑简单坏处是写放大严重1000 人群就是 1000 次写。读扩散消息只存一份接收方拉取时动态合并自己加入的所有群的时间线。好处是写次数少坏处是拉取逻辑复杂需要合并群消息和单聊消息分页很容易乱。我们实盘的经验是混合策略群成员 200 人以下用扩散写因为写量可控接收方体验最好200 人以上的大群用读扩散避免频繁的大规模写放大。这个阈值不是拍脑袋定的我们用压测数据算过MySQL 单机在混合读写场景下200 人左右的扩散写还能保持在 SLA 之内再往上就会明显拖慢消息时延。4.2 会话路由用户在不同网关节点之间消息怎么找到对方单机版 IM 不需要考虑路由所有连接都在一个 JVM 里直接内存寻址。一旦接入层部署了多个 Netty 节点用户 A 连在 node1用户 B 连在 node2A 给 B 发消息时node1 怎么知道该转发给 node2我们的方案是用 Redis 维护一张在线路由表key: im:route:{userId} value: { nodeId: node-1, channelId: xxx, lastHeartbeat: 1699999999 } TTL: 90 秒依赖心跳续期消息发送流程变成客户端 A 发消息到 node1node1 解析接收方 userId查 Redis 路由表如果接收方在线且在同一节点直接 channel.writeAndFlush如果接收方在别的节点走内部 RPC 转发到目标节点如果接收方离线进入离线消息流程。跨节点转发的内部链路小规模用 Netty 节点间的内部长连接通道就够了也可以用 RocketMQ / Kafka 做异步解耦。我建议接入层节点少于 10 个的时候直接用内部 RPC少引入一个 MQ 中间件就少一类故障。4.3 在线状态管理上下线、踢人、多端登录的底层逻辑在线状态不是一个布尔值而是“用户与网关节点”的映射关系。用户上线时客户端通过接入层鉴权成功后服务端往 Redis 写路由信息用户下线时客户端主动发下线请求或者服务端探测到连接断开后把路由信息标记下线。多端登录是另一个容易踩坑的点。同一个账号在手机、PC、网页同时在线在线状态必须从“用户维度”细化到“设备维度”。我们的方案是允许同账号最多 3 个端同时在线每一端有独立的 channelId 和 deviceType。广播消息时服务端先查用户的所有在线设备逐设备下发如果某个设备的连接已经失效只移除该设备的路由不影响其他端。这里要特别提醒千万不要把在线状态设计成简单的 Redis String 覆盖写。A 端上线把 userId 对应的 value 覆盖成 A 的 channelB 端一上线把 A 顶掉用户开个网页版 IM 就把 App 端挤下线绝对会被业务方骂死。5. 部署落地方案从单机演示到可灰度上线的演进路径5.1 单机最小闭环一套 Docker Compose 跑起来刚开始做功能联调或给老板演示的时候不需要一上来就搞 K8s一个 docker-compose.yml 就能把整套环境拉起来。最小闭环包含三个容器IM ServerSpring Boot Netty、MySQL、Redis。version: 3.8 services: im-mysql: image: mysql:8.0 container_name: im-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: im_db ports: - 3306:3306 volumes: - ./sql:/docker-entrypoint-initdb.d command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci im-redis: image: redis:7.0 container_name: im-redis ports: - 6379:6379 im-server: image: openjdk:17-jdk-slim container_name: im-server depends_on: - im-mysql - im-redis volumes: - ./target/im-server.jar:/app/im-server.jar ports: - 8080:8080 - 9000:9000 environment: SPRING_DATASOURCE_URL: jdbc:mysql://im-mysql:3306/im_db?useUnicodetruecharacterEncodingutf8 SPRING_DATA_REDIS_HOST: im-redis entrypoint: [java, -Xms512m, -Xmx512m, -jar, /app/im-server.jar]这里有一个很实用的建议把建表 SQL 放在 ./sql 目录挂载到容器的 docker-entrypoint-initdb.dMySQL 容器首次启动时会自动执行省得每次部署都手动导表。IM Server 暴露两个端口8080 走 HTTP 用于健康检查和管理接口9000 是 Netty 的 TCP 长连接端口。5.2 多节点部署从单机到网关层无状态化当在线用户数上来之后单机 Netty 的连接数会先触顶。Linux 单进程可以维持的连接数理论上很高但受限于文件描述符、线程调度、GC 压力实际生产中单节点支撑 5 万~10 万长连接已经是比较稳的上限了。多节点部署时接入层要做到“无状态”连接状态全部落在 Redis节点本身不保存用户路由客户端启动时通过 Nginx / SLB 做负载均衡连接到任意可用节点节点重启前做优雅下线客户端感知后自动重连到其他节点。架构上会变成这样一条链路客户端 App / Web │ ▼ 接入层 Nginx / SLB四层负载均衡转发 TCP 长连接 │ ├──► IM Server Node 1 (Netty) ├──► IM Server Node 2 (Netty) └──► IM Server Node 3 (Netty) │ ▼ Redis路由表 消息去重 在线状态 │ ▼ MySQL消息存储 离线消息 用户关系这里有一个关键配置如果用的是 Nginx 四层转发 TCP 长连接必须把 proxy_timeout 调大默认 60 秒的空闲超时会把你 IM 的心跳连接全部切断。我见过一次线上事故IM 消息经常发不出去排查到最后发现是 Nginx 的 stream 模块默认空闲超时把长连接全断了。5.3 优雅停机重启服务端时怎么做到用户无感知IM 服务端发布重启最怕什么怕存量连接被直接 kill客户端上一秒还显示在线下一秒全部断线重连。如果几十万客户端同时重连重新接入的请求洪峰能瞬间打满 CPU。优雅停机要分三步走从负载均衡摘掉节点流量不再接收新连接对存量连接广播“服务端即将维护”的推送消息同时标记当前节点状态为 draining等待存量连接处理完当前请求或者等待一个最大宽限期比如 30 秒然后关闭 EventLoopGroup。Spring Boot 里可以用 ApplicationListener 监听 ContextClosedEvent配合 PreDestroy 做资源回收。Netty 侧的关闭顺序也很有讲究先 channelGroup.close()再 workerGroup.shutdownGracefully()最后 bossGroup.shutdownGracefully()。顺序反了会出现新连接还没处理完就被 shutdown 的情况。客户端那边也要配合收到服务端的维护通知后不要立即重连等待几秒后再随机延迟重连避免重启后再次形成连接风暴。6. 实战中踩过的坑与性能调优笔记6.1 把业务操作直接写在 EventLoop 线程上这是最大的性能杀手Netty 的 IO 线程EventLoop数量默认是 CPU 核数的两倍它负责处理 Channel 的读写事件。很多人写第一个 Netty Handler 时习惯直接在 channelRead 里查数据库、调外部接口看起来没问题实际上是把所有连接的 IO 处理全部堵在几个线程上。正确的做法是Handler 里只做编解码和消息路由耗时操作丢给独立的业务线程池。我们的线程池模型是ExecutorService bizExecutor new ThreadPoolExecutor( 16, 64, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(10000), new ThreadFactoryBuilder().setNameFormat(im-biz-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );CallerRunsPolicy 很关键线程池满了之后任务会回退给调用方线程也就是 EventLoop执行。这个策略看起来会导致 IO 线程被阻塞但它能天然形成背压防止任务无限堆积导致内存溢出。真实环境里宁可短暂阻塞 IO也不能让任务队列无上限增长。6.2 ByteBuf 引用计数泄漏堆外内存被吃光的隐形杀手Netty 的 ByteBuf 使用堆外内存时需要手动 release 释放引用计数。如果 handler 处理完消息后没有调用 ReferenceCountUtil.release或者你用了 ctx.write 但忘了一端 release堆外内存就会一点点泄漏最终触发 OOM而且崩溃前毫无征兆只有 GC 日志里能看到堆外内存持续增长。我自己排查这类问题的经验是先打开 Netty 的泄漏检测级别System.setProperty(io.netty.leakDetection.level, PARANOID);这个参数会大幅降低吞吐量不适合生产环境长时间开启但定位泄漏源非常好用。日志里一旦出现 LEAK: ByteBuf.release() was not called before its garbage-collected跟着堆栈就能找到没正确的 handler。定位后改代码再把检测级别调回 SIMPLE 或关闭。6.3 消息乱序同一个会话的消息不能从多个线程同时发出分布式环境下消息乱序是我后期才遇到的坑。同一个用户的消息如果被分发到不同的 MQ partition或者 Redis publish 的 channel 消费端并发处理就会出现“消息 B 先到、消息 A 后到”的乱序。解决办法是保证同一个用户的消息走同一个队列分片RocketMQ / Kafka 场景消息 key 按接收方 userId hash保证同一 userId 的消息进同一个 partition线程池场景对目标 userId 取模固定路由到同一个业务线程从源头消除并发数据库场景消息落库后推送动作不要并行按 seq 串行发送。压测时还要注意一个细节TCP 层本身保证有序但客户端如果同时维护了多条 TCP 连接比如长连接断了又重连新旧连接之间就可能并发收包客户端必须按 seq 做一次重新排序。6.4 数据库连接池被慢查询拖死消息落库是 IM 系统里读写频率最高的操作。之前出现过一次故障某天消息量突增MySQL 慢查询日志里全是消息表的大分页查询连接池被打满线上消息发送集体超时。排查后发现根源是分页拉取历史消息用了 LIMIT offset 的深分页写法偏移量大了之后 MySQL 要扫描大量无用行。优化方案是改成基于 seq 的游标分页-- 不推荐 SELECT * FROM message WHERE session_id ? ORDER BY msg_seq DESC LIMIT 20, 20; -- 推荐带上最后一次拉取的 msg_seq SELECT * FROM message WHERE session_id ? AND msg_seq ? ORDER BY msg_seq DESC LIMIT 20;另外消息表的写入一定要做成批量插入不要一条一条 insert。IM Server 里把 1 秒内到达的消息攒一把一次 INSERT 带几十条 VALUES配合 rewriteBatchedStatementstrue写入性能能提升一个数量级。6.5 连接风暴不只是客户端的问题服务端也要做自我保护前面讲了客户端要指数退避服务端也不能干等着被冲垮。我们的做法是接入层加一个简单的“半连接保护”当当前活跃连接数超过最大阈值的 80% 时对新连接直接返回“服务繁忙请稍后重试”客户端收到这个响应后延迟较长时间再重连同时监控连接建立速率超过每秒预估值的 N 倍就触发告警提醒运维扩容。这种保护本质上不是提升吞吐而是给系统留出缓冲时间。IM 系统的雪崩往往不是单点被打垮而是连接风暴引发 CPU 被打满CPU 打满导致心跳延迟心跳延迟导致客户端误判断线断线后再次触发重连恶性循环。加一层自我保护就是打断这个循环的第一道闸。7. 一些压箱底的建议整套系统落地之后回头看最有价值的不是某个技术方案多精巧而是“怎么把它组织成一条你能 hold 住的链路”。如果你也准备从零写一套 Java 原生 IM我的建议是第一版只做单聊 离线消息 在线状态这三件事把这条主链路彻底跑通、压稳群聊、已读回执、多端同步都是在这张网上挂能力。消息 seq 的分配和路由表的设计一定要在最开始就统一收敛到服务端不要允许客户端参与任何消息排序和去重逻辑不然后面每次加功能都会在这里踩坑。另外一个容易被忽略的点是监控。长连接系统和传统 HTTP 接口不一样接口挂了能被调用方立刻感知连接挂了可能没人知道。上线前至少要把这三个指标监控起来活跃连接数、消息上行 TPS、消息端到端推送时延。哪天消息时延从 50ms 涨到 500ms先看 GC 曲线再看 MySQL 慢查询最后看是否出现了连接泄漏按这个顺序排查绝大多数问题都能在用户投诉之前发现。