
项目排期压到只剩两周的时候我们还在为一个问题争执不休高并发IM网关到底该用哪个框架继续做。旧系统用的是传统Servlet容器长连接一多线程数直接飙升GC也跟着抽风。新系统的目标很明确——至少十万在线连接消息吞吐要顶得住p99延迟还不能毛刺太多。这个场景下框架选型已经不是面试题而是决定工期和骨架的命门。我花了几天时间把我们内部代号为X的网关项目的选型过程重新复盘了一遍。从现象到理由从压测数据到最终决策再到上线之后的踩坑实录这篇文章基本把整个过程都用文字记录下来了。如果你正好也在做类似的高并发长连接服务或者正卡在框架选择的拉扯里这篇内容大概率能给你一些参考。1. 项目背景与选型动机1.1 旧网关的瓶颈在哪里这项目不是从零开始的绿场项目而是对旧版单体应用的升级改造。旧版接入了WebSocket和自定义TCP长链接部署形态是几台4C8G的ECS上层挂了一堆Servlet线程池。最初业务量小Tomcat的阻塞线程模型还能凑合单机三千左右的并发连接就把线程池占得差不多了。随着用户规模翻倍最直观的现象是晚高峰线程数顶到上限线程上下文切换把CPU占满而真正的业务处理能力却不升反降。更让人头疼的是长连接和心跳的处理方式。每个连接上来之后分配一个线程线程在socket上阻塞读连接一多又慢又占资源。线程栈内存按1MB算五千个线程就是5GB的虚拟内存虽然没有全量占用物理内存压力一大就开始频繁GC。线上监控里能看到明显的“GC周期性把吞吐打下来吞吐掉下来之后连接又开始堆积堆积又导致新的GC”的螺旋式恶化。这次新版的指标说得很死单机支撑十万长连接请求毛刺控制在可接受范围硬件不能无限加。这两条绑在一起等于直接宣布了线程阻塞模型的死刑。技术上必须上异步或者协程化方案那问题就从“要不要改”变成了“选哪个方向改”。1.2 为什么不能只靠技术情怀拍板既然确定要异步化候选名单里一下就冒出好几个Netty、Spring WebFlux、Vert.x还有人提议直接切Go。团队内部当时有两种声音。一种声音是“跟着生态走Spring全家桶啥都有”另一种声音是“Go反正也简单不如直接换语言”。这两种说法都有一点道理但都没回答最核心的问题在这个场景下谁的性能数据最能扛住真实业务压力。我坚持一个观点框架选型不是选美所有候选都必须用同等的压测条件拉出来遛一遛拿数据说话。因为框架在PPT上的优点和你在线上实际遇到的瓶颈往往不是同一件事。Netty说自己的零拷贝很强如果业务里全是小包体消息零拷贝未必能跑出想象中那么大的优势WebFlux说响应式背压很优雅但你在网关层做成同步包处理反而更简单可靠。事实上“性能好”这三个字在不同场景下含义完全不同脱离场景去谈性能数据没有任何意义。而且压测数据并不是只看一个吞吐数字那么简单。连接数上来之后内存怎么涨、GC次数会不会飙升、极端情况下的消息延迟是否失控这些都必须量化。所以我们内部定了一条规矩所有候选框架必须跑同一套业务模拟脚本产出吞吐、延迟分位、内存、GC四项指标再进入人工讨论环节。2. 候选框架与核心机制拆解2.1 参选名单是怎么圈定的技术上我们默认了一个范围优先在JVM生态里挑因为团队已经有几年Java和JVM调优经验切Go虽然有意思但团队学习成本和接棒成本都太高。Java生态里收敛出来的主要候选就是三个Netty、Spring WebFlux和Vert.x。Netty的优势在于它是底层网络框架几乎所以有异步网络中间件都建立在它上面控制力最强。Spring WebFlux胜在能和Spring生态无缝衔接DB、Redis、MQ的响应式客户端都比较齐全。Vert.x的特点是极简开发模型基于verticle和事件总线属于“隐藏的搅局者”。另外我还加了一个对照组就是Go实现的一个模拟网关别管最终是否真的切换语言它的数据能帮我们判断JVM方案的上限在哪。有个候选被直接排除Akka。排除不是因为Actor模型不好而是我们这次的核心是网络IO和连接管理不是分布式计算和复杂状态机引入Actor模型反而让问题变复杂。选型最忌把多个问题的答案混在一起Akka是给另一类问题准备的优秀答案。2.2 线程模型是所有并发问题的总开关单看框架的API和工具类没什么辨析度真正让这几个方案拉开差距的是线程模型。Netty采用经典的Reactor多线程模型。一个Boss EventLoop组负责接受连接多个Worker EventLoop组负责IO读写。每个EventLoop内部绑定一个线程这个线程串行执行分配给它的所有Channel上的事件天然规避了并发写冲突。你可以把EventLoop理解成一个小饭馆里的一个厨师这个厨师单独包几桌客人Channel菜单再复杂也是自己一个人按顺序处理不会出现两个厨师抢一个灶台的情况。Vert.x同样是事件循环模型但它更刻意地强调了verticle之间的隔离。每个verticle被绑定在一个事件循环线程上verticle之间不共享内存只能通过事件总线传递消息。这样做的好处是程序员不太容易写出线程竞争代码坏处是如果你的业务模型里需要大块共享状态那就要绕到event bus或者分布式方案里脑子要转几个弯。Spring WebFlux则是建立在Project Reactor之上的响应式栈。它的底层依然是Netty来跑但开发时用Mono和Flux表达异步数据流。响应式流里的背压机制是一个亮点下游处理不过来时会通过订阅机制反向通知上游放慢发送速度。但代价就是调试链路比较长尤其线上排查问题时响应式流里的堆栈信息看得人脑壳疼。Go作为对照组走的是协程方案。GPM调度器把成千上万个goroutine映射到少量线程上程序员写的仍旧是阻塞式代码风格但底层靠调度器自动切换。单从开发体验来说确实是最舒服的但这也是对比中最让人纠结的数据点我们后面细说。2.3 各框架在IM网关场景下的真实取舍IM网关这个场景有几个特点决定了它不是简单的请求响应模型第一长连接数量巨大连接上的活跃度却不高。十万连接意味着可能有九万条连接只是空闲着等心跳系统必须能优雅地管理大量空闲链接而不是为每条连接分配昂贵的系统资源。第二消息模式非常多样。既有一对一的聊天消息也有群组消息这种一对多的扇出模型还有系统通知和推送平均包体不大但峰值频率很高。第三要承担协议解析和转发控制的责任。网关是整个系统的第一道入口链接鉴权、消息路由、流量控制、封禁屏蔽都会压在这个节点上它是通信的入口也是一个要介入大量业务逻辑的节点。这个背景下Netty的优势在控制力上。你可以精确规定每个EventLoop绑多少个Channel可以在pipeline里编排任意编解码器可以精准控制内存分配器。WebFlux适合的是那种从Spring MVC平滑迁到响应式的项目但IM网关里的业务逻辑属于偏命令式的批量处理流程并不全是数据流式处理硬套Flux反而别扭。Vert.x的开发效率的确很高它自带了很多实用的工具类比如WebSocket handling、event bus但到了后期需要和公司内部的监控告警体系深度绑定时Vert.x在运维生态上的成熟度比Netty差一些。如果你是一个单纯的业务团队不想操心太底层的东西Vert.x是个很好的选择。但如果你和我一样要对接一个庞大的内部中间件体系还要为了性能去调整部分底层网络参数那Netty这种“地基级”框架用起来会更顺手。这也为后面的压测方向定了一个基调我们要验证的不是谁读写最无敌而是在IM场景下谁最能控制节奏。3. 性能压测把数据摆到桌面上3.1 压测场景怎么设计才可信给框架跑压测最怕的就是拿一个hello world级别的接口测一串吞吐数字然后宣布某某框架天下第一。真实业务里的IO模型、内存分配、GC抖动、网络条件都是压测的一部分设计压测场景时必须尽量还原线上真实节奏。我们最终分了三组场景。第一组是长连接在线场景。模拟十万条WebSocket连接同时在线各自按30秒间隔发心跳包。这个场景考察的是框架对连接数本身的支撑能力和空闲连接的资源开销。长连接状态下的内存占用非常重要因为每条连接即便没有业务消息也会有一些缓冲区、状态对象、定时任务等开销。连接数量的线性增长如果在内存上呈超线性趋势那基本就是有东西泄漏了。第二组是消息广播场景。模拟一个在线群组里持续产生消息从1人组到500人群包含单聊消息和群组扇出关注在持续消息流下框架的吞吐上限以及延迟分布情况。这一块最能拉开框架之间的差距因为群组扇出会放大每条消息的处理成本对中间数据分发和写回的效率要求就高了。第三组是混合流量场景。把登录、下线、单聊、群播、心跳按比例混合在一起同时加一部分异常流量比如重复登录、非法协议包、超长消息体。这组场景测试的是框架在烂流量冲击下的健壮性目的是看谁被垃圾数据打崩得最快。压测的持续时间也特意拉长了每组固定跑30分钟前10分钟作为预热后20分钟的数据才计入统计。原因很简单很多框架跑3分钟能保持笔直的低延迟曲线但跑20分钟后开始出现内存缓慢攀升和GC小毛刺。短跑考察不了长期稳定性。3.2 压测工具选型与参数配置工欲善其事必先利其器。短连接的HTTP测试我们用wrk标准的脚本化压测工具。长连接场景wrk就不够用了因为wrk面向的是HTTP请求响应而不是需要保持心跳和推送的WebSocket长连接。我们写了一个基于Netty的自研压测客户端可以配置连接数量、心跳间隔、消息发送频率、群组大小。虽然开发要多花一天时间但胜在可以完全复刻生产环境的长连接行为。wrk的常规压测命令我们是这样跑的给个示例wrk -t16 -c1000 -d60s --latency -s ./im_gateway.lua http://10.0.0.8:9080/im/status几个参数怎么理解-t16表示启动16个线程-c1000表示1000个并发连接-d60s表示压测时长60秒--latency会输出详细的延迟分布-s指定Lua脚本。这里的Lua脚本里定义了一个POST请求体发什么数据、带什么鉴权头都是写死的。注意wrk的并发连接数不一定等于服务端的并发上限它只是压测端的压力来源压测端和服务端的瓶颈不能混在一起。长连接压测客户端有几个关键参数必须注意。一是连接建立的速率不能一下全怼上去要限制每秒新建连接数否则服务端可能瞬间被连接风暴打挂二是心跳与业务消息的比例真实场景心跳占比远大于业务消息三是消息体大小分布我们特意设置了大小消息混合90%的消息小于1KB10%的消息在2KB到4KB之间模拟业务上的短消息为主加少量图片、名片等扩展消息。每个方案跑完一轮之后我们还会把JVM的GC日志打开记录Full GC次数、GC总耗时和堆内存的曲线变化。线程池参数、EventLoop线程数尽量在测试前做一轮粗调避免因为明显不合理的配置导致方案被误杀。比如Netty的EventLoop线程数默认是CPU核数的两倍这个在普通场景够用但在密集收发的消息场景里我们也会跑一下调整后的参数对比。3.3 关键性能数据对比压测数据最终汇总成了一张表我挑重点放出来方案长连接在线数消息吞吐每秒p99延迟平均内存Full GC次数Netty自研网关10000015.2万条12ms2.1GB0Spring WebFlux10000012.7万条21ms2.8GB2Vert.x10000014.9万条15ms2.5GB1Go对照方案10000016.4万条10ms1.8GBN/A注意这是我们在特定业务模拟和特定压测租户配置下的数据只能说明这个条件下各方案的表现不代表全部的绝对性能。但即使如此这些数据的含金量依然远比架构师嘴上说的高。几个有意思的解读WebFlux的吞吐比预想的还要低一些。分析下来其主要原因是响应式流的处理链路长每一条消息要经过Reactor的多个操作符再加上我们代码里有一些List转换和阻塞查询操作符链条上的对象分配压力明显比纯Netty手写的pipeline高内存自然就上去了。Vert.x很意外地接近Netty说明它的实现确实是下过功夫的只是内部的一些调度机制让p99稍微偏高比Netty高出3ms左右。Go方案在数据上拿了领先的位置有点意料之外又情理之中因为这玩意儿是赛道特性决定的协程切换成本低、内存占用小但这不代表它是我们的最终解。3.4 为什么第二轮压测结果和第一轮“不一样”第一轮数据出来后团队内部有分歧。有人看到WebFlux的数据觉得因为它加了业务逻辑所以慢也是正常的如果用纯webflux接入可能也差不多。为了杜绝这种“条件偏袒”的争论我们做了第二轮复测把每个方案都尽量调到各自的最佳配置比如调整EventLoop数量、开启不同级别的网络优化参数、JVM中调整为G1垃圾回收器、开启字符串去重等等。第二轮的结果和第一轮的趋势基本一致但有个数字改变了Netty在优化后p99从12ms降到了9ms吞吐微涨到16.1万条直逼Go的16.4万。这说明Netty在天花板上的可调空间确实比较大只要你对参数理解得够深。这个细节在决策阶段起了很大的作用。我们的判断是当前数据里Netty和Go的极限性能已经很接近但是Netty的上升空间还在后头它是一个你可以一直榨性能的框架只要愿意花时间。而Go虽然数据好但语言切换的风险要覆盖到好几个项目上。第二轮数据的意义不在于证明谁比谁快而在于让团队清楚地看到自己的调整空间在哪里。4. 从数据到决策技术选型的多维权衡4.1 性能数据只是一张入场券压测数据出来之后大家似乎默认“谁快就选谁”。我又泼了一盆冷水性能数据决定的是哪一个候选有资格进入下一轮而不是直接决定谁中标。这是很多技术选型会踩的坑跑完数据就拍板完全忽略掉后续的开发代价和运维成本。我们要回答三个额外的问题。第一基于这个框架做完整业务有没有现成的工具链支撑还是什么都要自己造轮子。第二团队里的人有多少能快速上手这个框架招聘市场上能不能很容易找到会的人。第三出问题后能不能快速定位社区和生态能不能兜底。用这些标准去看压缩数据中的三个方案结论就变了。Netty虽然底层的很多东西要自己写但几乎所有的Java网络中间件都内置了Netty参考代码遍地都是Vert.x虽然性能不错但它在国内公司里的普及率相对有限团队万一换人光招一个熟悉Vert.x的人都有难度Spring WebFlux的生态和招人相对友好但性能上限让我们有点担忧在性能瓶颈到来时需要更多代码对抗框架自身的开销。我还用一个很粗的权重法把这几个维度量化了性能权重0.4开发效率权重0.2生态和运维0.2团队熟悉度0.1招人难度0.1。打分的时候单看性能Go冲得很高但加上后面几项之后它反而掉下来了。做技术选型最怕的是单一指标至上而多维权衡正是用来防这个的。4.2 决策矩阵与最终选定最终我们做了这么一张决策表候选方案性能0.4开发效率0.2生态运维0.2团队熟悉度0.1招人难度0.1加权总分Netty979888.5Spring WebFlux789998.0Vert.x886667.2Go方案1087588.1其实Netty排名领先并没有绝对优势真正让我们下定决心的还有一个额外因素项目已经有一部分底层长连接通信层沿用Netty团队内部对这框架的踩坑经验积累得最多等于前期的历史包袱反而变成了一项资产。所以到最后我们的决策不是简单的“选一个性能最好的”而是选择了“从性能到团队匹配度综合风险最低的”。方案定了剩下的就是搭建验证环境。我们把压测环境从开发环境挪到了一台和生产配置相同的8C16G机器上部署Netty网关再挂上真实的Redis和MongoDB跑混合流量场景三天。这三天里数据没有发生明显劣化Full GC为零堆内存曲线在2.1GB和2.3GB之间横跳但整体平稳这基本给我们吃了个定心丸。4.3 这一版上线后的容量规划选定Netty之后紧接着要考虑的就是硬件规划和容量预估。我们用比较朴素的方法算了一下一条长连接在网关进程内加上连接对象、Channel缓冲区、附近的各种状态信息平均开销大概在15KB到20KB左右。这个数字在Netty和Vert.x场景下大致相同主要取决于协议编解码器和每个连接上挂了多少上下文。按一台8C16G的机器JVM堆设置8GB堆外给4GB来算理论支撑连接数就是12GB总内存除以20KB每连接大概60万连接的量级。当然这是理想上限我们还要把GC空间、消息队列缓冲、协议解析所需的临时对象占用都算进去实际稳定支撑二十万到三十万连接是没问题的。于是我们定的是单机承载不超过十万连接留有一倍以上的余量。容量规划的核心不是说“能扛多少就扛多少”而是给不可预知的流量尖峰留出安全垫宁可闲置一点资源也不能在晚高峰出现连接瓶颈。5. 实战中躲不开的坑与调优心得5.1 长连接场景最容易翻车的三个地方就算压测数据再好上线之后还是有一堆问题等着你。真实环境跑起来之后IM场景下遇到的几个坑我按严重程度排一下首先是堆外内存泄漏这是Netty长连接场景的头号杀手。压测的数据好看是因为压测的“连接生命周期”高度可控断开时资源释放得很干净。线上场景的连接不像压测那么规矩用户手机切后台、网络漂移、客户端崩溃底层连接都会异常断开。如果实在没处理好断开事件的资源释放逻辑堆外内存就会悄悄涨上去。用Netty的池化分配器时一旦ByteBuf没有正确release堆外内存就完全不归JVM管肉眼根本盯不住只能靠监控Native Memory一类的指标发现“进程的内存在涨但JVM堆一点没动”。这种问题排查起来非常痛苦我们的解法是在网络层统一封装了一个连接管理器强制所有Channel关闭时执行一次资源清理代码Review的时候凡是没有做release的地方一律打回。其次是事件循环线程被耗时任务阻塞。Netty的EventLoop执行的任务绝对不能是阻塞的一旦某个handler里不小心写了阻塞调用这个EventLoop上绑定的所有Channel就全部卡住了。我们第一次上线的时候就在鉴权流程里加了Redis读取没走异步客户端直接按同步方式get了一遍。压测时流量不够没暴露问题上线后连接数一高中间件毛刺一出现某个EventLoop线程就被卡住几百毫秒该线程上的所有连接延迟直线飙升。这也是Netty开发最反直觉的地方你写的代码明明很普通但在单线程模型里它就是一场灾难。排查方式就是盯着线程日志看如果有大量业务线程堆栈都卡在同一个地方那基本就是阻塞任务堵了EventLoop。第三是路由层的背压。消息网关不只是接收和推送还要把消息通过内部RPC转发给其他服务。如果下游服务的处理能力跟不上而网关还泰山压顶式地往下游发数据很快就会把下游拖死。我们在这里做了一个限流缓冲网关层的发送队列设置了一个高水位阈值一旦积压超过阈值就丢弃次要消息或对客户端发送降级通知而不是让积压无限膨胀搞死整个网关进程。使用队列缓冲而不是无尽的堆积这一点在高并发架构里极为关键。5.2 压测数据和线上数据的“温差”从哪来上线之后你肯定会发现一个现象压测时的p99是10ms线上监控的p99却跑到了40ms甚至更高。别慌这是正常的温差但这不代表压测没有意义。温差主要来自三个地方。一是网络链路的差异压测时压测客户端和服务端在同一个内网网络延迟低且稳定线上客户端分布在各种网络环境下Wi-Fi弱网、移动网络拥塞、跨境链路这都会放大实际延迟。二是流量模型的不同压测时的消息到达频率是均匀分布的线上流量天然有聚集性晚高峰八点到十点这波流量是日常平均值的十几倍瞬间的流量尖峰足以把延迟打起来。三是GC和目标机器性能衰减的影响压测时运行在干净的容器里线上机器别人还部署了其他服务或同宿主机上的邻居占用IO都有可能干扰真实表现。缩小温差的方法是给压测注入更多的噪音。我们在第三轮压测时就故意加了一些慢客户端、随机断连、大包体、心跳超时等于把线上最差的那些情况做进测试逼着网关在恶劣条件下暴露出问题。从这轮我们能比较真实地估算出上线后p99大概会劣化多少然后把系统的容量预算、超时阈值都放宽到这个范围内。5.3 针对Netty的几项有效调优实战最后盘一下项目过程中真正起了作用的一些Netty调优点给以后做类似的项目当一个操作清单。第一个是EventLoop线程数的设置。默认是CPU核数的两倍这个在普通IO密集场景适用但在IM网关这种消息扇出比较多的场景线程数太少会让CPU有闲置太多反而会增加线程切换。我们最终压到CPU核数的1.5倍配合一天线上数据对比找到的平衡点。第二个是开启TCP_NODELAY。默认TCP协议有Nagle算法小包会合并发送虽然提升了带宽利用率但增加了延迟。IM场景里大量消息是几十字节到几百字节的小包一点延迟都会影响聊天体验。在Netty的ChannelOption里显式把它设为trueServerBootstrap b new ServerBootstrap(); b.childOption(ChannelOption.TCP_NODELAY, true); b.childOption(ChannelOption.SO_KEEPALIVE, true);第三个是内存分配器。Netty默认在安卓和iOS等场景会自动选择非池化分配器但在纯Java服务端我们需要显式指定使用池化分配器减少对象创建开销。配合堆外内存使用能把GC压力减掉一大截。b.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);第四是JVM参数调优。高并发长连接服务首先要保证堆外内存够用因为Netty的IO操作大量使用堆外内存。我们在启动参数里设置-XX:MaxDirectMemorySize4g同时GC用G1并设置了两个关键的GC参数让GC暂停尽量短暂。如果你用的是JDK 11以后的版本ZGC也是可以考虑的选择但ZGC在小堆机器上优势不明显我们用G1已经足够。第五是关于连接空闲检测和心跳。Netty自带IdleStateHandler可以用来检测空闲连接但我们发现不能完全依赖它的默认行为。线上很多客户端会根据状态机自动重连如果服务端把空闲连接关得太快会导致客户端反复重连加重网关负担。这里要按业务心跳频率设计好超时阈值比如客户端30秒一个心跳服务端90秒还没收到任何消息才判定死链并关闭。这个阈值需要结合客户端实现一起定不能自己拍脑袋。6. 性能数据教我的事这次项目做完我最大的体会是在技术上做决策一定要从数据出发但也不能被数据完全牵着走。数据是帮你筛掉坏选项的工具但最后做选择的还是对团队、对业务、对长期维护成本的综合判断。性能上差一点点可能可以通过调优和加机器补回来但是框架和团队契合度的差距是要用后面几个月的代码质量来还的。回到起点那个悬在标题里的“特殊字符”其实就是我们自己给自己项目起的一个代号就像每个工地项目有个名字一样。名头不重要重要的是整个选型过程走下来团队里每个人都知道我们为什么这么选数据给了我们底气也扫清了争论时的一堆主观意见。最后有个小建议如果你也在做类似的选型决策花一天时间认真搭建一套贴近业务的压测模型绝对比花一周去争论框架的优劣更划算。压测数据不会替你写代码但它能帮你的团队把声音统一起来。这个收益比省下的那一两周开发时间值钱得多。