在垂直圈子社交产品的迭代过程中,即时通讯(IM)和动态(Feeds流)向来是晚高峰时期流量最汹涌、对延迟最敏感的两个核心战场。
系统早期的聊天模块非常简陋,为了快速上线验证市场,老版本采用了最原始的 HTTP 短轮询机制——客户端每隔 3 秒自动调一次后台接口问“有没有新消息”。随着日活用户逐步逼近 25 万,晚高峰时段几十万次无效的 HTTP 请求直接把服务器的 CPU 顶死,消息甚至出现数秒的严重延迟,体验极其糟糕。
为了彻底干掉这个性能黑洞,团队对通信底座进行了一次彻底的微服务拆分与长连接自研重构。今天站在纯技术复盘的视角,聊聊如何基于 Netty 搭建一套端到端延迟低于 50ms、平稳支撑 60K+ 用户同时在线的分布式 IM 长连接网关。
01. 协议选型:为什么是原生 TCP Socket 而不是 WebSocket?
在立项之初,团队针对长连接技术选型进行过多方案评估。对于很多 H5 游戏或网页端应用,WebSocket 凭借浏览器原生支持和良好的跨平台兼容性,往往是第一选择。但在处理社交 App 后台静默运行的场景时,我们面对的硬伤完全不同,最终坚决地选择了原生的 TCP Socket:
极致榨干字节流(省电与省流量): 社交 App 需要长期在后台挂机。WebSocket 毕竟是由 HTTP 升级而来的,其报文头中依然带有部分协议升级和掩码(Masking)的开销。而原生 TCP 配合 Protobuf (Protocol Buffers) 序列化后,体积比常规 JSON 报文直接缩减了 70%,在移动端弱网环境下极大节省了带宽,更降低了用户手机后台常驻时的电量损耗。
网关层极致的内存优化: 原生 TCP 允许我们在 Netty 网关层做极致的堆外内存调优。利用自定义的
LengthFieldBasedFrameDecoder编解码器,数据在堆外直接处理,不经过 JVM 堆内存的二次拷贝。如果使用 WebSocket,Netty 必须引入全套的HttpObjectAggregator,单机网关的内存开销会直接翻倍,这会严重威胁晚高峰高并发下的网关水位安全。
为了彻底防范网络经典的粘包与半包问题,我自定义了二进制包头格式:固定 4 个字节的长度字段(Length) 作为包头,后面紧跟 2 个字节的命令号(CmdId,用于区分单聊、群聊、心跳等),最后才是业务 Payload。
02. 分布式路由管理:多节点下如何精准找人?
晚高峰 60K+ 用户同时在线,单机显然无法承载,我们最终部署了 4 台 Netty 路由网关节点。这里带来了一个经典的分布式难题:用户 A 连在网关 01 上,用户 B 连在网关 02 上,A 给 B 发消息,网关 01 如何精准把消息送过去?
我们的分布式路由方案采用了 Zookeeper + Redis 的双剑合璧架构:
服务注册(Zookeeper): 每台 Netty 服务器启动时,都会向 Zookeeper 注册一个临时节点,上报自己的 IP 和端口。
网关动态负载: 客户端建立连接前,先调一个 HTTP 路由接口,该接口去 Zookeeper 查看当前各节点的连接数,返回最空闲的 Netty 节点 IP。
路由表维护(Redis): 连接成功后,Netty 节点在本地内存的
ConcurrentHashMap里存一份User_ID -> Channel的单机映射;同时同步往 Redis 集群写入一条全局路由记录:User_ID -> Netty_IP_01。跨机转发逻辑: 当网关 01 收到 A 发给 B 的消息,发现 B 不在本地,便去 Redis 查到 B 在网关 02。网关 01 随即通过 RocketMQ(或内部 RPC)将消息转发给网关 02,网关 02 收到后直接从本地 Channel 队列捞出 B 的通道,定向推下去。
03. 核心痛点:客户端不可靠,消息如何防丢防重?
网络编程中最朴素的认知是:绝对不要盲目信任客户端。 手机在移动端弱网、进电梯、频繁断线重连的环境下极其不稳定。如果只依赖 TCP 自带的底层 ACK,只要数据到了手机网卡驱动,底层就会回执成功,而此时若 App 恰好崩溃或被系统杀死,这条消息就会彻底化为“消息黑洞”。
为了构筑绝对的可靠性防线,系统在 Netty 之上建立了一套应用层两阶段确认(ACK)机制:
上行 ACK 防丢失: 用户 A 发消息给服务器,网关将消息持久化(写入 MySQL 社交主从库)成功后,必须给 A 回一个应用层 ACK。如果 A 的 App 在 3 秒内没收到这个回执,便会自动触发超时重试。
下行 ACK 防漏收: 网关将消息推给用户 B,B 的 App 在代码层确保成功接收、解码、写入本地数据库并在屏幕上画出聊天气泡后,才会反向给网关回一个应用层
ACK(Message_ID)。网关如果 5 秒内没收到这个回执,立刻判定 B 已失联,转而将这条消息转存至 MongoDB 离线消息库 中,等 B 下次重连时再二次拉取。
由于上行和下行的超时重试机制,不可避免地会带来消息重复的副作用。
为了做到彻底的幂等去重,每条消息在网关生成时都会被赋予一个基于雪花算法的唯一 Message_ID。客户端 App 收到消息后,在将其渲染到聊天界面前,会先去手机本地的 SQLite 数据库 查一下这个 ID 是否存在。如果已存在,说明是重复投递,客户端会直接在后台将其丢弃(不重复渲染气泡),但会再次给网关补发一个应用层 ACK 告诉服务器别再重发了;若不存在,才正常落盘上屏。
04. 为什么线上单机我们只给 2 万连接?
在做晚高峰的架构容量规划时,很多人容易陷入“单机抗百万连接”的误区。但在真实的生产环境中,我们表现得非常克制——我们将单机连接上限死死控制在 2 万个连接左右。
这种克制基于两个极其现实的考量:
第一是真实业务开销,长连接本身不占内存,但长连接背后的聊天、刷动态、查 Redis、写 MySQL 会产生大量的临时对象,连接数过高极易在高峰期触发 Full GC 甚至 OOM。
第二是容灾冗余,4 台机器控制在 6 万多总连接,每台平均一万多。万一其中一台网关突然发生物理宕机,断线的 2 万用户会瞬间重连到剩下的 3 台机器上。由于每台机器都留足了容量弹性,剩下的节点可以完美接管流量,整个集群绝不会发生级联雪崩。
写在最后
自研 IM 系统的难点,从来不在于写出那个 Netty Server 的网络骨架,而在于如何把客户端当成不可靠实体,去反向推导全链路的防御规则。利用 TCP + Protobuf 榨干带宽,通过 Redis 全局路由打通集群,配合应用层 ACK 与手机本地 SQLite 强归留存,把这些底层的逻辑死磕清楚了,高并发弱网下的通信稳定性自然就立住了。