从压测 8000 并发直接被打崩,到后面稳定扛住 5 万在线、消息峰值 1.5 万条/秒,中间经历的大大小小的坑和优化,我觉得值得单独拿出来聊一聊。标题里的"即时通讯源码",实际指的是一套基于 Java NIO 自研的 IM 服务端,不是直接用开源框架二次开发的那种,而是把 Netty 通信层、自研协议解析、消息路由、离线存储都拢在一起的完整项目。这套系统上线前的压测表现很差,消息稍微一密集,服务端 CPU 就飙到 90% 以上,内存抖动严重,客户端明显感觉到消息延迟,更别提高峰期还有消息丢失的情况。如果你手头也有一套 IM 源码在调优,或者正在做高并发长连接类的服务,这篇文章里的排查思路和优化手法应该对你有直接用处。
我先把当时定位到的主要问题列出来。三个核心瓶颈,几乎和标题里的三个关键词一一对应:第一,流量洪峰时消息积压严重,消费速度跟不上生产速度;第二,网关层的连接分配策略太粗暴,所有新连接全往一台机器上怼;第三,堆内存里塞了大量 byte[] 和对象副本,GC 频繁到每秒好几次。下面挨个拆开讲。
1. 一上线就被消息洪峰打爆:问题到底出在哪
很多 IM 项目在开发环境跑得好好的,单测、接口测试全部通过,丢到生产环境一上量就趴窝。最典型的场景就是业务方做一次全员推送,或者在某个秒杀节点大量用户同时触发消息。系统卡住的表现通常是:客户端消息发出去没有回执,服务端日志里出现大量"队列已满"的报错,数据库连接池被打满,CPU 飙到 100% 后触发告警。
1.1 先画一张消息流转链路图,把每一环的耗时量化出来
我的习惯是,接手任何性能问题,第一件事不是改代码,而是把完整链路画出来,给每一段加一个耗时采集点。IM 的消息路径大致是这样的:客户端通过 Netty 接入,消息解码后进入业务线程池,然后经过消息校验、关系链检查、消息持久化,最后推送给在线接收方,不在线的写入离线表。
给每一段加上耗时统计后,我发现最离谱的不是 Netty 的编解码,也不是推送逻辑,而是消息持久化这一步。项目中用的是 MySQL 的批量插入,但批量大小固定为 500 条一次,并且每次插入都走的事务提交,也就是每 500 条消息就 COMMIT 一次。在消息高峰期,单台机器的数据库连接池只有 20 个连接,插入一条消息的平均耗时在 15ms 左右,500 条一批就是 7.5 秒。也就是说,上游生产者倒进来 5000 条消息,消费线程组拼死拼活也要好几秒才能落地,消息越积越多,最终触发内存堆积。
这其实是很多 IM 源码里最常见的共性问题——性能差的不是单点能力,而是链路中"木桶最短的那块板"。你通信层再快,落到存储这里变成了串行瓶颈,整体吞吐就上不去。
1.2 为什么说"同步落库"是即时通讯消息链路里最大的败笔
IM 消息在语义上确实需要可靠投递,但"可靠"不等于"每条消息都必须在内存里等数据库返回成功后才往下一个环节走"。如果你认真读一下主流 IM 系统的设计文档,会发现几乎没有一个系统会在业务线程里同步做数据库写入。
原因很简单:数据库写入的耗时方差太大。本地 SSD 上一条简单 INSERT 可能 1ms 就完成,但遇到锁竞争、binlog 刷盘、主从延迟,单条写入飙到 50ms 也不奇怪。一旦你在同步路径上等数据库,你就要为这个不可控的方差买单,线程池被打满、队列积压、内存暴涨都是连锁反应。
当时我做的第一个调整,就是把消息持久化从同步改成异步:业务线程只需要把消息体投递到内存队列就立即返回,由专门的落库线程组批量消费队列,攒够一批或者达到时间阈值再批量写库。这一步改完,单机吞吐从每秒 1200 条直接跳到 5000 条以上,效果立竿见影。
注意:异步化不是把问题消灭,而是把压力从"请求线程"转移到"后台批处理线程"。所以队列必须有界、必须监控积压量、必须设计背压策略。否则内存队列本身就会变成新的瓶颈。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 削峰不是扛峰值,而是削掉"没必要的压力"
很多人对削峰的理解是:加大服务器配置、扩大线程池、提高队列容量,硬扛峰值流量。这是完全错误的思路。削峰的核心是让系统在峰值流量下"平滑地工作",而不是让系统去匹配峰值。匹配峰值意味着你的资源利用率在 99% 的时间里都是浪费的,成本上完全不可接受。
2.1 客户端侧先削一刀:消息聚合与去重
IM 消息有个特征:很多消息是高度冗余的。举个例子,运营做全量推送,同样的内容要发送给 10 万个用户。如果服务端收到一条群发指令后就复制 10 万条消息分别落库、分别推送,这就是在给数据库和生产线程凭空制造压力。
我当时在客户端 SDK 和服务端协议层同时做了消息聚合。客户端侧的逻辑是:用户在聊天框里连续输入多条消息时,如果时间间隔小于某个阈值,并且会话对象相同,就尝试合并成一条复合消息。服务端推送时,对同一接收方的多条消息做打包处理,一起封装进一个 TCP 包,减少网络包数量。
单这一层优化,高峰期网络包总量下降了接近 40%。这个数字相当客观,因为 TCP 包减少意味着用户态和内核态之间的切换次数减少,CPU 中断负载也降下来了。
2.2 服务端削峰核心:两级队列加背压
做过消息中间件相关工作的朋友都知道,RocketMQ 和 Kafka 的削峰本质上都是"队列 + 异步消费 + 背压"的组合。IM 系统也可以借鉴这个思路,但不能照搬,因为 IM 有实时性要求——你不能让一条聊天消息在队列里等 10 秒再处理。
我给 IM 服务端设计的是两级队列:
第一级是 Netty 的 ChannelOutboundBuffer 之前的"待发送队列",这个队列只做短暂缓冲,容量很小,用于吸收瞬间的发送抖动,一般几百条就够了。第二级是消息持久化队列,容量放大一些,比如 5 万条,由批量线程组消费。
两级队列之外,最关键的机制是背压。当第二级队列的积压量超过阈值时,不再是无脑往队列里塞,而是触发"降级策略":新消息仍然要接收(因为连接不能断),但不再进入高开销的消息校验和关系链查询,只做最基本的协议解析和路由,直接走紧急通道落库。这保证了系统在极端情况下也不至于堆死。
2.3 削峰实测数据:消息积压量和 P99 延迟的变化
做完整改后,我用同样的压测脚本跑了三轮测试。优化前,模拟 3000 个在线用户同时发送消息,每秒产生约 8000 条消息,持续 60 秒。结果是前 10 秒服务端还能正常响应,第 15 秒开始队列积压量超过 2 万条,第 20 秒内存使用率飙到 85%,随后触发 GC 风暴,第 25 秒开始大量连接超时断开。
优化后,同样的压测参数,消息积压量从峰值 2 万条降到 1800 条左右,P99 消息延迟从 4.8 秒降到 320ms,整个压测期间没有一台机器触发内存告警。压测结束后的 5 秒钟内,积压消息全部消费完毕。
这组数据说明一个道理:削峰不是靠堆机器,而是靠把链路每一环的"弹性"做出来。任何一环有弹性,流量就能被吸收和消化;任何一环是硬连接,流量就会全部变成压力反弹回来。
3. 网关层负载均衡:句柄分配权不能只交给 Nginx
IM 系统和普通 HTTP 服务在负载均衡上有本质区别:HTTP 请求是短连接,请求来一次、负载均衡分配一次,用完就断开;IM 是长连接,客户端会和服务端建立长时间的 TCP/WebSocket 连接,连接一旦建立就固定在一台机器上,直到断开。这意味着 IM 的负载均衡核心不是"每来一个请求分给谁",而是"每个新连接分给谁"。
3.1 为什么 Nginx 按 IP Hash 做连接分发在 IM 场景不够用
很多 IM 项目直接复用 HTTP 的负载均衡方案,最常见的是 Nginx IP Hash。这种方案看起来没问题:同一个客户端总是连到同一台后端机器,有状态,很合理。但实际运行中你会发现,IP Hash 会带来两个问题。
第一,IP Hash 的粒度是"源 IP",不是"连接数"。如果一个公司出口 IP 下面挂了几百个员工(比如大家通过同一个代理上网),这几百个连接全部被分到同一台后端机器上。如果这时候另一台后端机器只有零星几个连接,整体负载就完全失衡了。
第二,IP Hash 不考虑每台机器的实时负载指标。机器 A 的 CPU 已经 90% 了,机器 B 闲得在摸鱼,但按照 Hash 规则,新连接照样被分到机器 A。这样的"均衡"其实是假均衡。
我当时做的第一件事,是把负载均衡策略从 IP Hash 改成"最少连接数 + 加权"的动态策略。网关层每 3 秒从每台 IM 服务端拉取一次当前连接数和 CPU 使用率,然后按照"连接数量 × 所有权重"的动态公式来选择目标机器。这个改造不复杂,但效果非常明显,高峰期的连接分布从"一台 4000 连接、一台 800 连接"变成了"两台都在 2400 左右"。
3.2 连接管理组件:前端网关 + 后端注册中心 + 心跳上报
负载均衡要做得更细,就不能只在网关层做文章。我的方案是引入一个轻量的"连接注册中心"组件,每台 IM 服务端启动时把自己注册上去,同时定时上报四类指标:当前活跃连接数、当前消息处理速率、JVM 老年代使用率、最近一分钟 CPU 平均负载。
网关或接入层在收到新连接请求时,向注册中心发起一次"最优节点查询",注册中心根据上报数据计算各节点的综合评分,返回得分最高的节点。这套机制说白了就是一个极简版的服务发现 + 动态路由。
这样做有个额外的好处——故障转移。当某台 IM 服务端心跳超过 15 秒没有更新,注册中心会自动把它标记为"不可用",新连接不再分发上去。已有的连接呢?由客户端 SDK 做自动断线重连,重连时网关自然会把连接分到健康节点上。
3.3 跨机房容灾:多活网关与就近接入的取舍
单机房的负载均衡搞定了,跨机房又是另一回事。我们当时有两个机房,一个为主,一个为备,原计划是主坏了切备。但我实测发现,如果只是"冷备",故障切换的耗时通常在 1 分钟以上,这在 IM 场景里是不可接受的——用户能忍受网页打不开 1 分钟,但忍受不了聊天消息 1 分钟收不到。
所以我把冷备改成了多活架构:两个机房都作为正式接入点,客户端 SDK 通过 HTTP-DNS 或配置文件拿到两个接入地址列表,启动时先测速,选择延迟最低的一个机房接入。两个机房之间通过 MQ 做消息同步。这样不仅实现了容灾,还把一半的流量分散到了备用机房,主用机房的压力直接下降了 50%。
跨机房消息同步有一个关键的坑是重复消息。两个机房同时收到同一条消息的转发,消费者必须做幂等。当时踩过这个坑,客户端出现了一次消息重复显示的问题。解决方案是在消息 ID 上加上全局唯一约束,落库时先按消息 ID 查一次,存在则跳过。
4. 内存资源优化:省下来的每一 MB 都换算成在线人数
聊完流量和连接,再说内存。IM 服务端是典型的内存敏感型应用,因为每个长连接都会占用内存资源:连接对象、Channel 缓冲区、消息编解码的临时对象、待发送队列、离线消息缓存。我见过一个线上事故,就是因为单连接占用的内存过高,导致一台 16GB 内存的机器只能支撑 4000 个在线用户,一到高峰期就 GC 到卡死。
4.1 先算一笔账:一个在线用户到底消耗多少内存
做内存优化,先得知道"钱"花在哪了。我压测时做了个内存分析,在一个稳定运行 12 小时、在线用户 5000 的实例上做了 JVM heap dump,分析结果是:Netty 的堆外内存(Direct Memory)占了 40%;业务对象(消息体、会话上下文、用户状态)占了 35%;GC 后的残留碎片和缓存占了 15%;剩下的是线程栈和元空间。
Netty 的堆外内存占比这么高,主要原因是每个 Channel 默认分配的 WriteBufferWatermark 太高。Netty 默认的低水位是 32KB,高水位是 64KB,这本身没问题,但在高并发连接场景下,每个连接预分配的内存累加起来就非常可观。5000 个连接,每个连接 64KB 的写缓冲区,就是 320MB 的堆外内存。
我调整的思路是:不要把每个连接的内存配足,而是让连接用多少申请多少。具体操作是把 ChannelOption 的 WriteBufferWaterMark 调低到低水位 8KB、高水位 16KB,同时开启 Channel 的 autoRead 动态调控。对于 IM 这种以轻量消息为主的场景,单条消息体一般在 1KB 以内,8KB 的低水位完全够用,16KB 的高水位也覆盖了绝大多数情况。
4.2 堆内复用对象:别再 new String、new byte[] 了
内存碎片和对象创建速率是堆内存 GC 的两大元凶。IM 服务端每秒钟要处理上万条消息,每条消息从解码到落库到推送,中间涉及的临时对象数量在 10~30 个。一秒 1 万条消息就是 30 万个临时对象,这些对象绝大多数朝生夕灭,直接造成 Young GC 压力陡增。
我在项目里做了三件针对性的优化:
第一,消息解码时不再 new byte[] 去拷贝,而是用 Netty 的 CompositeByteBuf 直接引用原始缓冲区,或者用 unsafe 内存拷贝减少数组创建。这里牵扯到协议层改造,需要一层层传引用,代码侵入性不小,但收益很大。
第二,用 ThreadLocal 复用编解码的中间对象。每条消息解码都需要一个 Message 对象、一个 Header 对象、一个元数据 Map,这些对象结构固定、生命周期短,非常适合放在 ThreadLocal 里复用。需要注意的一点是,复用的对象用完之后必须清理干净,否则会出现脏数据串消息的严重事故。
第三,集合类容器如果能预知大小,就提前初始化。比如解析完消息头后,已知消息体里有多少个扩展字段,创建 HashMap 时直接把 initialCapacity 设好,省掉扩容开销。
4.3 连接级别的内存边界控制:慢消费者检测与强制断开
长连接服务还有一个隐蔽的内存杀手——慢消费者。TCP 的滑动窗口机制决定了,如果接收方(客户端)处理不过来,发送方的发送缓冲区会越积越多。如果某个用户在一个群聊里,而他的客户端网络很差,服务端往这个连接写的消息就会堆积在 ChannelOutboundBuffer 里,占用越来越大的堆外内存。
这类连接不处理,每条可能吃掉几十 MB,卡死整个服务。我在系统里加了慢消费者检测:每隔 5 秒检查一次每个连接的发送缓冲区积压量,如果积压超过 32MB,就把该连接标记为"慢消费者",然后启动降级策略——停止推送非关键消息,只保留心跳和关键通知;如果积压超过 64MB,直接强制断开连接,让客户端重连。
你可能觉得强制断开太粗暴,但实际操作下来这是最有效的保护方式。一条慢连接卡住的堆外内存可以养 100 条正常连接。为了 1 条异常连接牺牲 100 条正常连接的资源完全不划算。客户端 SDK 只需要做好自动重连和增量消息拉取,断开重连用户几乎无感知。
4.4 JVM 参数与 GC 策略的最终配置
内存优化的收尾工作是 GC 调优。当时用的 JDK 8,默认的 Parallel Scavenge 在这个场景下表现不理想,Young GC 虽快但太频繁。我切换到了 G1,并做了几个关键参数调整,最终配置如下(8GB 堆内存的实例):
code复制-Xms8g -Xmx8g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=40
-XX:ConcGCThreads=4
-XX:+ParallelRefProcEnabled
-XX:MaxDirectMemorySize=1g
MaxDirectMemorySize 一定要显式设置,不加的话,Netty 的堆外内存会默认和堆内存一样大,一旦泄漏会直接拖垮进程。停顿控制在 50ms 以内后,实际压测的平均 GC 停顿在 18ms 左右,客户端基本感知不到卡顿。
5. 压测复盘:真正的性能瓶颈往往是"配置和习惯"
上面的调优做完,系统能力上了一个台阶。但压测过程中反映出来的问题还有不少,我把几个比较典型的"坑"单独列一节,这些细节如果你不注意,可能在线上一跑就炸。
5.1 消息顺序性:完全全局有序做不到,那就分区有序
IM 消息是有顺序需求的,同一个会话里的消息如果顺序乱了,用户看起来会非常奇怪。但在高并发场景下,保证全局有序是不可能的,代价太高,几乎等于把所有消息串行处理。
合理的方案是分区有序:按 conversationId 做哈希,同一个会话的所有消息一定投递到同一个线程处理,这个线程内部保证有序。我最初用的是简单的取模,但有一个问题是,如果某个会话的消息量特别大(比如一个 5000 人的大群),它会把负责它的线程打成热点,其他线程闲死。
后来我把哈希取模改成了"一致性哈希 + 热点会话自动拆分"的组合。热点拆分机制是大群消息不直接在接收方的连接上发送,而是先写入群消息存储,接收方通过拉取模式获取。这个小改动,把大群场景下的推送热点彻底打散了。
5.2 TCP_NODELAY、读写缓冲区和心跳,这些"小参数"有大差别
IM 长连接服务有几个"墙上的蚊子血"级别的参数,很多人配置的时候随手一填,结果线上性能差一截。
TCP_NODELAY 必须开。IM 消息是典型的"小包 + 高频"模型,如果开启 Nagle 算法,小消息会被缓冲,和后面的数据一起发,用户感知就是打字一个字一个字往外蹦的时候极其卡顿。开了 TCP_NODELAY 后,每条消息都立即发送。
SO_SNDBUF 和 SO_RCVBUF 需要根据消息大小来设置。我用的配置是:接收缓冲区 128KB,发送缓冲区 128KB。这两个值不是越大越好,因为每个连接都会按这个值预分配内核内存,设太大连接数一大就把内核内存吃光了。
心跳周期也是个大问题。客户端心跳间隔设置过短(比如 15 秒),服务端就会收到大量无意义的心跳包,这些包虽然很小,但一样要走一次完整的网络中断处理,在 2 万连接规模下,每秒光心跳就能产生上千次中断;设置过长又影响 NAT 超时感知。综合运营商和云厂商的 NAT 映射生命周期,心跳间隔 30 秒是一个合理的下限,60 秒是通用值。
5.3 压测脚本的坑:你以为的并发不是真的并发
压测 IM 系统比压测 HTTP 接口复杂得多。我见过不少团队用 HTTP 压测工具直接压 IM 的长连接接口,压出来的数据完全是假的——因为 HTTP 工具建立连接、断开连接的开销占了压倒性比例,真正测试到的不是 IM 服务,而是 TCP 握手和断开的极限。
正确的方式是写一个专门的模拟客户端压测工具,用真实的 IM 协议创建连接、加入群组、收发消息,并且要模拟多种用户行为:有活跃用户、有沉默用户、有频繁进出群的用户、有大量发送大消息的用户。用 MkDocs 之类的工具只能做初始验证,真正的性能压测必须考自定义压测器。
我当时写了一个基于 Netty 的模拟客户端池:每个模拟客户端线程维护 20 条长连接,循环执行"发消息->等待回执->再发消息"的流程。这样一个 4 核 8GB 的压测机就能模拟 1 万并发连接,配合 4 台压测机可以制造 4 万并发的压力。
5.4 灰度放量与容量评估:别等线上崩了再扩容
最后一点,是上线流程的规范性。性能调优不能一次性全量上线,哪怕压测数据再漂亮。我当时的标准流程是:先在 10% 的机器上灰度跑 48 小时观察核心指标,确认稳定后再放量到 30%,然后再全量。
容量评估用的是一个简单的公式:服务端单机可承载在线连接数 = 单机可用内存 / 单连接内存占用(含堆外)。以我们 8GB 堆内存 + 1GB 直接内存的实例为例,压测得出单连接平均占用约 1.2MB 内存,8GB 堆可以用 6GB 承载业务对象,那就大约是 5000 个连接。三台这样的实例就能支撑 15000 在线用户,加上合理的 buffer,2 万在线用户部署 5 台机器基本稳妥。
这就是我在这套即时通讯源码上调优的全过程。从最初 8000 并发打崩,到最终稳定支撑 5 万在线、峰值消息 1.5 万条/秒,整个过程中我最大的体会是:性能调优从来不是改一处代码就能完成的,它是对通信层、业务层、存储层、内存模型和部署架构的全面审视与再平衡。每解决一个瓶颈,立刻又会有下一个瓶颈冒出来,这个循环本身就很有意思,像在不停解锁新的关卡。希望这些思路和参数配置,能帮你在自己的 IM 或长连接服务调优路上少走几个弯路。
