
1. 一个物联网网关的选型风波今年年初我们团队接了一个物联网设备接入网关的项目。终端设备在册十几万台高峰期每秒上报的数据包在两三万条之间服务端还要同时维持接近十万条长连接用来下发指令和接收设备状态。这个“高并发”是实打实的硬需求不是PPT里用来渲染紧张氛围的形容词。项目进入设计评审时框架选型先吵了起来。一部分人觉得用Spring Boot理由很直白团队熟资料多出了问题一搜就有答案中间件生态也全。另一部分人主张用Vert.x或者Go理由同样直白高并发场景下Spring Boot“撑不住”连接一多线程就炸必须上非阻塞、上协程。两边各说各话谁也说服不了谁。“撑不住”撑不住到什么程度“线程炸”炸在什么量级没人能给出数字。我当时的感觉是这已经不是框架之争是信心之争。里面混着对新技术的好感、对Java传统体系的不满还有一点“大家不都这么用吗”的从众心理。于是我跟团队提了个要求先不做决定把候选框架拉到同一台机器上用项目里真实会出现的三种业务场景压一遍拿到吞吐、延迟、资源和稳定性数据之后再回来继续吵。这个提议意外得到了双方的同意——大家其实心里都清楚没有数据支撑的选型无论选哪个后面出了问题都说不清。这篇文章就把那次选型的前因后果、压测设计、实测数据和分析思路完整复盘一遍。如果你也在为高并发框架选型发愁或者准备向团队证明“某个框架更适合我们”这里面的思路和坑应该能帮到你。1.1 开会开来的不是结论是站队那次评审会的细节我不完全展开了但有几个论据特别典型。支持Spring Boot的一方说社区体量大招人容易JVM调优方案成熟业务代码写起来快支持非阻塞框架的一方说Tomcat一连接一线程十万连接就要开十万线程光栈空间就吃掉好几个GB更别说线程切换的CPU损耗。同一套事实两边解读出来的结论完全相反。其实他们说的都对但都只在特定场景下成立。高并发不是单一形态有的服务是CPU密集有的是I/O密集有的是海量连接但每个连接消息频率很低有的是连接数不多但每条消息都很重。不同形态下框架的并发模型决定了它表现完全不同。跑分如果不区分业务形态只比一个“谁QPS高”那得出的结论换一个业务后马上就失真。我发现做技术决策最缺的往往不是知识而是把知识放到特定场景里的能力。1.2 我把“技术选型”变成了“跑分选型”那次会议之后我定了一个流程先把业务形态拆解成可压测的场景再让候选框架在同等条件下跑这些场景同时记录五类指标——吞吐量QPS、延迟平均和P99/P999、CPU使用率、内存占用、GC频次。不记录不发生记录下来了才有讨论的锚点。这是那次选型最重要的转折点把“我觉得”替换成了“数据说”后面所有争执都变成了对数据真实性的讨论而不是对信仰的捍卫。当然要让“数据说”站得住压测设计本身必须严谨这正是很多人忽略的部分。下一节开始我把整个执行过程拆开讲。2. 参测框架与它们背后的并发模型2.1 为什么选这五个框架参测候选框架没有全上挑了五个最有代表性的框架类型并发模型选它的理由Spring Boot 3TomcatJVM阻塞式每连接占用一个线程团队现有主力框架业务代码成熟Spring Boot 3WebFluxJVM响应式事件循环少量线程同生态下的非阻塞方案迁移成本最低Vert.x 4JVM响应式事件循环Verticle社区在高并发领域口碑好技术积累久Go 1.21net/http原生协程goroutine跨语言对比验证JVM并发模型的天花板Spring Boot 3虚拟线程JVM虚拟线程虚拟线程阻塞式验证Java新特性能不能“两全其美”这个组合很有意思它既能在同一套语言生态里比Spring Boot阻塞 vs WebFlux响应式又能跨语言比JVM vs Go还能比Java自身的新路线虚拟线程 vs 传统线程池。像是把“框架之争”扩展成了“并发模型之争”。2.2 三大并发模型线程阻塞、事件循环、协程跑分之前理解这三个模型比记数字更重要。阻塞线程式是传统Java的玩法一个请求来了从线程池里拿一个线程全程伺候这个请求直到响应写回才释放线程。比如Tomcat默认最大线程200就意味着同时最多只有200个请求在干活。如果业务里还有一次50ms的数据库调用那一个线程一秒钟只能处理约20个请求——这就是阻塞模型的天花板。线程不是不存在是太“重”了多了之后GC、上下文切换的成本会把CPU吃干。事件循环是Netty和Vert.x的路线少量线程一般等于CPU核心数循环处理所有事件某个请求的代码不能长时间占用线程必须把阻塞操作改成异步回调线程才能腾出来去处理下一个请求。好处是线程少内存省连接数海量也能扛代价是代码风格反直觉一行阻塞调用就可能毁掉整个吞吐。协程是Go的路线语言级别支持的“轻量级线程”一个goroutine初始栈只有几KB调度器把几万个goroutine放到几百个系统线程上跑。写起来像阻塞式代码跑起来却有接近事件循环的并发能力。Java 21的虚拟线程本质上也是这个思路只是调度器在JVM内部成熟度还在爬坡。有了这个底子后面看到数据就能“知其所以然”差距差在哪里根源是什么而不是只留一个“谁快谁慢”的印象。3. 压测环境设计坏数据比不测更危险3.1 硬件、版本和压测工具压测环境长这样被测服务器和压测客户端分开两台物理机8核16G同一机房同一网段避免CPU抢跑和网络抖动影响数据。JVM统一用OpenJDK 21G1垃圾收集器最大堆内存统一设4G。HTTP接口压测用wrk长连接场景用自研小工具因为它能更精确控制连接数和心跳频率。每轮压测持续3分钟前30秒预热让JIT把热点代码编译到位取最后两分钟的统计数据。每个场景压三轮取中位数避免单次波动干扰。wrk命令大概是这样的wrk -t8 -c100 -d180s -H Connection: keep-alive --latency http://10.0.3.10:8080/purejson为什么要把客户端和被测端分开因为压测客户端本身也会吃CPU如果把两个跑在一台机器上你测量的其实是“被压机器压测机器”混合后的结果数字会严重失真。这是很多人第一次压测就踩的坑。3.2 三组有代表性的业务场景我没有直接拿生产流量去压生产流量不可控无法复现而是设计了三组可重复的业务场景纯计算接口接收一个POST请求反序列化一个约300字节的JSON做几个字段校验再序列化回去返回。模拟接入层最基础的“透传校验”逻辑。这是CPU密集为主考验框架本身的调度和JSON序列化能力。阻塞依赖接口在业务代码里加一个固定50ms延迟的模拟下游调用比如调用Redis或查询一个慢接口。对阻塞式框架用普通线程sleep模拟对响应式框架用异步调度器模拟。它模拟的是“业务链路里拖慢速度的那个依赖”是压测里最能拉开框架差距的场景。长连接场景服务端提供WebSocket接口设备连接后维持在线服务端每隔10秒推送一条心跳消息。连接数分别压到1万、3万、5万几个档位看谁先崩。这三个场景分别对应高并发业务最常见的三种形态计算密集、I/O阻塞密集、连接密集。3.3 我修正过的几个“脏数据”来源第一轮压测没跑几轮就发现数据很怪同一个框架两次结果差了50%。排查下来是几个典型问题日志没关。业务里随便打一行info日志日志框架在并发一高就变成全局锁竞争谁跑都慢。我直接把被测服务的日志级别调到WARN业务里触发日志的路径全部走常量判断跳过。序列化库不一致。有的框架默认用Jackson有的默认用别的接口里的JSON序列化实现你不统一结果就是在比序列化库不是比框架。我统一给被测服务注入Jackson序列化器全部共用同一套配置。JVM参数不一致。Spring Boot和Vert.x如果不显式指定-Xmx启动后会按容器内存弹性伸缩看起来“默认”实际上堆大小不同GC行为差一大截。我统一固定-Xmx4g并打开-XX:AlwaysPreTouch堆内存全部预先触摸避免压测过程中一边分配一边卡停。连接复用没控制。wrk默认开长连接复用而有些压测工具会反复建新连接两者对服务端压力模型差异极大。我全部指定HTTP/1.1 keep-alive连接复用并固定并发连接数。修完这些同一场景的三轮数据基本稳定在±5%以内这时候数字才值得看。3.4 GC和CPU监控怎么接跑压力的同时我用脚本在被测机器上记录两条辅助数据jstat看GCpidstat看CPU。jstat -gcutil pid 1000 gc.log pidstat -u -p pid 1 cpu.logGC数据尤其重要。高并发压测时如果某个框架的Young GC特别频繁或出现Full GCQPS曲线会周期性崩塌P99也会被拉得极难看。这往往是框架自己的内存分配模式造成的和你的业务代码关系不大却是选型里很容易被忽略的隐性成本。4. 实测数据吃 CPU、吃 I/O、吃连接数的场景差异巨大4.1 纯计算型接口没有想象中的悬殊先看纯计算接口。这个接口没有外部依赖就是代码和JSON序列化在跑wrk并发分别从100提到500框架100并发 QPS100并发平均延迟100并发P99500并发 QPS500并发平均延迟500并发P99CPU使用率Spring BootTomcat约1.2万8.0ms28ms约1.4万34ms72ms78%Spring BootWebFlux约2.1万4.7ms22ms约2.5万20ms64ms71%Vert.x约2.3万4.3ms19ms约2.9万17ms55ms68%Gonet/http约3.2万3.1ms12ms约3.8万13ms42ms62%Spring Boot虚拟线程约1.6万6.2ms21ms约1.9万26ms58ms80%这个结果是不是和很多人想象的不一样WebFlux没有“碾压”Spring BootGo也没有比Vert.x高一倍差距在2倍上下没有出现数量级差异。原因很简单纯计算场景CPU是主要瓶颈非阻塞模型节省的是“线程调度和切换开销”但JSON序列化本身就是CPU密集活这一块大家都得老实干活。但注意P99那列WebFlux的P99明显比Spring Boot高。明明平均延迟更低为什么P99反而更大因为事件循环在并发陡增时有比较明显的尾部抖动某个时间片的任务多个别请求就会等很久传统线程池模型排队反而均匀一些虽然整体吞吐低但“谁都别想太快”。这个细节在决策时很重要——如果你的业务有SLA要求P99比平均延迟更值得盯。另外可以顺带验证一下平均延迟和QPS基本满足“并发数除以QPS等于平均延迟”的关系比如500并发除1.4万QPS约等于35ms和表格里的34ms对得上。这说明压测数据没有明显的系统性失真。4.2 带阻塞型依赖的接口差距瞬间拉大第二个场景业务逻辑里加了一个50ms固定延迟的阻塞调用模拟真实的Redis或下游服务。这里直接用600并发压因为要让阻塞式框架的线程池先撞上墙框架600并发 QPS平均延迟P99延迟稳定表现Spring BootTomcat约3.6k166ms430ms线程池打满大量请求排队等待Spring BootWebFlux约9.4k64ms141ms线程稳定依赖异步调度Vert.x约9.0k67ms138ms线程稳定长尾略明显Gonet/http约9.5k63ms120ms协程调度平稳Spring Boot虚拟线程约8.7k69ms210ms虚拟线程数量膨胀仍可控看Spring BootTomcat的QPS断崖式下跌。600并发、50ms阻塞延迟Tomcat默认200个线程理论挂载上限就是200 / 0.05秒 ≈ 4000 QPS实测3.6k就贴着这个天花板。阻塞式模型在这个场景里根本不是因为框架不够快而失败纯粹是并发模型的上限给算死了剩下的请求全在队列里排队等线程。WebFlux和Vert.x之所以能翻到两倍以上不是因为它们“更快”而是因为它们不把线程浪费在等待50ms上600个并发请求可以同时挂载在事件循环上只需要定时器回调。Go又在这个基础上稳了一截goroutine的调度开销比JVM的响应式回调调度小P99也更好看。这里有个重要启示如果你的业务里有一大堆“慢依赖”高并发下阻塞式框架的坑是结构性的调线程池参数只能缓解没法根治。线程池加到2000也许能扛一会儿但每多一个线程都在抢内存和CPU最终还是会撞上另一堵墙。4.3 长连接业务Tomcat线程模型的噩梦第三个场景更残酷——长连接。设备接入网关恰恰需要海量长连接实时性要求又高。连接数Spring BootTomcatSpring BootWebFluxVert.xGo1万勉强可运行P99飙到180ms稳定P99 20ms稳定P99 18ms稳定P99 10ms3万连接建立后频繁超时GC高稳定P99 25ms稳定P99 21ms稳定P99 12ms5万直接OOM服务不可用稳定内存约2.3G稳定内存约2.1G稳定内存约0.9GTomcat那个“一连接一线程”的模型在1万连接时就已经把默认线程池吃穿之后要么疯狂拒绝新连接要么线程栈内存爆炸。这是我在生产环境见过最大的软肋很多业务平时QPS不高但连接量一上去传统阻塞Web容器就是先死的那批。WebFlux和Vert.x跑长连接都很稳原因是它们把连接状态放到事件循环里管理空闲连接几乎不占线程占用只在通信缓冲区。Go的优势更极端在内存goroutine栈小5万连接时Go的内存占用只有Vert.x的三分之一多一点。物联网网关这种设备在线率极高、长期挂着连接的服务内存就是真金白银。4.4 那些“反常数据”背后的原理三组数据跑完可以沉淀出一个很朴素的结论在CPU密集场景下框架选型的差距最小在I/O阻塞和长连接场景下并发模型决定了系统性差异。所谓“高并发框架哪个好”必须放在具体业务形态里回答脱离场景谈框架性能都是耍流氓。另外WebFlux和Vert.x的数据非常接近远到不了“碾压”的程度。现实中选谁更多要看团队代码风格和生态。Go则在连接密集和高并发调度场景有明显优势代价是开发效率和周边生态的差距。虚拟线程那次测试最特别——它用阻塞式写法的代码拿到了接近WebFlux的成绩虽然离Go还有距离但已经证明Java官方的并发模型补课是有效的传统Spring Boot应用未来升级Java 21后能白捡一波并发能力。5. 读数据最容易踩的坑我都帮你踩了一遍5.1 只晒平均延迟等于没晒我在第一轮汇报时只给了平均延迟结果被团队老哥一句“平均数掩盖了尾部问题”给打回来了。确实服务器响应时间通常不是正态分布而是长尾分布。少量请求可能因为GC暂停、连接排队、网络抖动拖到几百毫秒这些在平均值里根本看不出来。后面所有场景我都同时记录四个数字平均延迟、P50、P99、P999。决策以P99为主平均延迟只用来判断整体感受。你如果看到P99是平均值的三四倍那说明系统有明显的稳定性隐患如果P99和平均值接近说明系统响应非常规整。5.2 没有隔离客户端的压测测的是客户端我犯过的另一个低级错误是压测机不够用一时图省事让压测客户端和被压服务挤在同一台机器上。跑到高并发时客户端这边的CPU先满了wrk发出的请求反而不稳定数据忽高忽低。后来把压测客户端挪到另一台空机器结果立刻稳定。做压测务必让压测客户端有充足的余量否则你测到的高峰和低谷里有一半是客户端自己的病。5.3 把业务代码的速度错认成了框架的速度最后一个坑最隐蔽——对比框架时业务代码写着写着就不公平了。比如Spring Boot里习惯用ThreadLocal优化上下文传递到Vert.x里大家又习惯用上下文对象透传两种写法性能差异极大。再比如WebFlux里某个人写了个阻塞式的Redis调用整个事件循环被拖死数据全变形。我让所有候选框架的业务实现都遵循“各自生态的最佳实践”并且代码评审后才进压测仓库。这一步是为了确保“框架适配业务”的真实组合性能而不是为了证明某个写法有多烂。5.4 正式压测前先做一次“冒烟测试”大版本压测之前我习惯先用一个极其简单的“Hello World”接口快速验证环境。这个接口没有业务逻辑只返回固定字符串主要作用有三个确认网络链路通不通、确认wrk参数对不对、确认客户端和服务器的时间基准没有明显偏差。如果连这种接口都跑不出稳定数据那后面所有业务级压测都不能要。冒烟测试还有个额外好处它能给出框架自身的“空转开销”基线。后续业务接口的数据和基线对比就能看出业务代码让性能打了多少折扣方便定位瓶颈到底在框架层还是业务层。6. 从数据回到决策我最终没有选“跑分第一”6.1 框架生态和团队技术债怎么计价如果只看跑分Go在这个项目里确实最漂亮。但技术决策不是跑分比赛长期成本里还有生态和团队熟练度这两个大项。Spring Boot的体系是“全家桶”权限、定时任务、配置管理、指标监控、分布式事务甚至内部的一堆代码生成工具都是现成的。设备接入网关虽然边缘接入部分适合非阻塞但周围的管理、报表、审批、数据同步这些“腰部业务”用Spring Boot写起来省太多事。Vert.x和Go在这些领域更像“半成品”不是不能做是每件事你都得自己搭长期维护的人也得有对应的技能储备。技术债的计价方式很简单得估一下“团队里有多少人能把这个框架在生产环境维护三年”。大多数人能维护Spring Boot少数人能维护WebFlux更少人能修Vert.x和Go的线上问题。这个现实因素在选型表里至少值它个几百QPS。6.2 排障能力和可观测性值多少 QPS跑分数据好看不代表线上好排障。这里面有个很实际的教训传统的阻塞式框架一个请求从进到出线程栈上的调用链是完整可还原的一个thread dump就能看到请求卡在哪一行代码这是全链路追踪工具特别有效的基础。但响应式框架里请求被打散成事件在不同线程间流转线程栈上永远只看到当前回调那一小段真正的业务进度得靠全局请求ID一点一点串起来——排查慢请求的难度成倍增加。物联网网关是7x24小时服务半夜一台设备上报异常、一条指令下发超时如果排障要两个小时那再高的QPS也换不回用户信任。我在这次选型里把“可观测性”放在和性能同等的位置要求候选方案必须能接开源的Metrics、分布式链路追踪、日志落盘这套线上诊断流程要在半小时内能完成一次问题定位演练。6.3 我给这次选型定的“最终路线”综合跑分、生态、团队能力和排障成本最终定下来的是混合方案设备接入网关用Go重写因为这一层是三组压测数据差距最大的连接密集和I/O阻塞场景跑分优势和运维边界清晰团队里正好有两个Go功底扎实的老哥可以兜底。附属业务系统维持Spring Boot升级到Java 21把虚拟线程逐步放开白捡一部分并发能力不改变团队现有开发模式。两端之间的消息通道改用异步消息队列削峰填谷让网关层不必承载瞬时全量峰值业务侧也不会被下游慢依赖拖死。这个方案没有选跑分第一的WebFlux或Vert.x去服务全部业务而是让框架去匹配业务形态——该轻的地方让它轻该稳的地方让它稳。选型最大的误区就是以为一个框架必须赢下所有模块其实高并发系统通常是由多种技术拼成的拼图。经过这轮选型我自己的体会很深所谓“跑分选型”最终结论往往不是某张表格里的冠军而是“用什么证据、怎么用证据”这套决策流程。下次团队再有人说“网上说某某框架快”你可以直接把压测报告拍桌上先问一句你先说清楚你说的快是在什么场景、什么并发量、什么延迟口径下的快