上个月我们做了一次选型评审,场景很典型:一个日活百万的C端查询服务,高峰期峰值预估2万QPS左右,需要给下个版本定框架。团队里两派意见僵持了很久,一派坚持Spring Boot,理由是团队熟、生态全;另一派主张上Netty甚至直接切Go,理由是“高并发下传统Java框架扛不住”。开了三次会都没有结论,我提议别再口舌之争了,拉出来跑数据。
这个决定是对的。两周压测做完,双方都有点沉默,因为数据跟谁的想象都不一样。下面就是那轮压测的完整复盘,写给所有要做技术选型的后端开发和架构师,也写给刚接触高并发、想知道框架性能差异到底怎么量化的朋友。我不会直接告诉你“用XX最好”,脱离业务参数谈结论没有意义。真正有价值的是我验证过的压测方法、指标解读逻辑,以及一批要避开的常见数据陷阱。
1. 高并发选型前,先回答三个问题
很多人上来就问“哪个框架QPS高”,这问题本身就是错的。高并发场景下框架是木桶的一块板,但绝不是桶底。桶底是业务形态、流量模型、团队能力。这三个问题不回答清楚,后面所有压测数据都是空中楼阁。
1.1 你说的高并发,到底是多少量级
“高并发”这个词已经被用烂了。1000QPS也叫高并发,100万QPS也有人张口就来,但两者对应的技术栈完全是两个世界。
我自己心里有一条大致分界线,分享出来供参考:
- QPS 500以下:单体Spring Boot或者任意你熟悉的Web框架就行,主要功夫花在数据库索引和SQL调优上,框架本身几乎没有决策成本。
- QPS 500~5000:开始要考虑缓存层、连接池配置。此时框架仍然不是瓶颈,MySQL性能调优和Redis用得好不好才是主要矛盾。
- QPS 1万~5万:框架的线程模型、序列化方式、GC表现开始进入视野。比如Tomcat默认的200个工作线程,在这种量级下会频繁触发上下文切换,P99开始抖动。
- QPS 10万+:这已经不是“选一个框架”能解决的问题了。要做的是网关层分流、无状态多实例部署、读写分离、消息队列削峰、缓存兜底。这时候框架占整体架构决策的比重很小。
还有一个容易被忽略的维度:如果你的“高并发”主要是长连接IM场景,那QPS就不是重点指标,你要看的是连接数和消息吞吐。热词里“高并发im”说的就是这种情况,我在第五节还会专门提到。说回我们的业务,2万QPS的查询服务,刚好卡在“框架有影响但不起决定作用”的区间。正因为有影响,才值得压测。
1.2 你的业务是IO密集还是CPU密集
这是选型最核心的分水岭,几乎能直接决定框架方向的答案。
所谓IO密集,是指服务大部分时间在等外部资源——等数据库返回、等Redis响应、等外部HTTP接口回包、等文件读取。这种服务CPU其实大部分时间是空闲的,线程基本都在阻塞等待。高并发服务里绝大多数都是这种类型。
所谓CPU密集,是指线程真的在不停计算——做加解密、图像处理、复杂推荐计算。这种服务CPU占用率已经很高,换再花哨的框架也提不了速,唯一有效的办法是多开实例横向拉满。
用生活化的比喻来说:前者像餐厅前台,客人点完单之后,服务员主要时间是在等厨房出菜。这时候能同时接待多少桌客人,取决于你有没有足够多的服务员,对应到系统就是并发处理能力。后者像是在厨房切菜,切菜速度是纯操作时间,多雇几个洗菜阿姨没用,得换刀。
对IO密集业务,线程模型直接决定并发天花板。Java传统的“一请求一线程”模型,创建和切换线程是有成本上限的。Netty、WebFlux、Go、Kotlin协程这类“少量线程处理大量连接”的模型,优势正好体现在这里。这也是很多团队在2万QPS这个量级开始犹豫要不要换框架的真实原因。如果你的高并发服务还要承载AI推理之类的CPU密集任务,那又是另一套逻辑,重点在推理引擎和算子性能,框架本身的优先级要往后放。
1.3 团队会什么,比什么都重要
这个结论在数据表格上看不到,但我必须放在最前面说。我见过不止一个团队,因为某些文章说“XX框架性能碾压”就头脑发热切过去,结果半年后线上出问题没人能定位,绩效一塌糊涂。
高并发框架选型有个很容易忽略的隐性成本:调试和运维能力。Netty自研服务性能确实好,但你的团队是否Hold住内存泄漏排查?Go的goroutine确实轻量,但你的监控体系是否已经覆盖了Go运行时指标?如果你现在的团队全是Java背景,那Spring Boot加JDK虚拟线程可能是个比激进方案更聪明的选择——性能数据不会差太多,但团队心智负担小很多。
这里得单独提一句“若依框架”。它是我见过最流行的Java后台脚手架之一,很多中小团队新项目直接拿它当起点。但你要明白,若依定位是后台管理系统脚手架,不是高并发网关。它的默认配置根本不是为了2万QPS设计的。我见过有人拿若依改的接口直接扛线上流量,压测一跑6000QPS就开始报错。别误会,这不是若依的错,是选型的人没把“开发框架”和“性能底座”分开。这个认知,对后面读数据很有帮助。
2. 搭建可复现的压测环境,数据才有意义
要说框架性能差异,必须建立在同一个实验条件下。很多网上的评测文章数据对不上,核心原因就是压测环境不对等,甚至对比逻辑都是错的。这一章写我踩过的坑,以及最终使用的压测方案。
2.1 压测工具选型:不同工具各有所长
第一件事是选压测工具。我这些年用下来,主流工具大致可以分为四类:
| 工具 | 适合场景 | 我的使用经验 |
|---|---|---|
| wrk | 单机快速压测HTTP接口 | 上手最快,参数简单,适合掌握并发梯度;但对复杂场景脚本支持弱 |
| k6 | 脚本化压测、CI集成 | 支持用JS写复杂场景,输出指标完整,适合团队沉淀压测用例 |
| JMeter | 复杂业务流、分布式压测 | 功能全但配置重,单机性能容易成为瓶颈 |
| ghz | gRPC接口压测 | 如果是gRPC服务选型,它是最趁手的工具 |
我这次选的是k6。原因很简单:想把压测脚本固化到仓库里,之后每次做框架升级或者参数调优都能回归。k6的脚本可读性好,而且指标统计里自带P95和P99,不用自己算分位数。
2.2 控制变量:把配置统一到同一起跑线
压测最大的敌人是配置不对等。举个例子:Spring Boot默认的Tomcat线程池是200,Netty的EventLoop线程数默认是CPU核数的两倍。你不调参直接压,结果反映的完全不是框架能力差异,而是默认配置差异。
我这次统一做了几件事:同机压测,4核8G云主机一台,压测机和被测服务分开部署,避免客户端资源抢占影响数据;统一环境,Java项目统一JDK21,Go统一1.22,Python统一3.12,所有中间件连接配置一致;统一序列化,接口返回一个固定结构的JSON字符串,不引入序列化性能差异;统一预热,每个服务先压10分钟再记录数据,让JIT、连接池、线程池都进入稳定态。
k6脚本可以直接用stages字段来控制并发梯度,我当时是这么写的:
import http from 'k6/http'; import { check } from 'k6'; export const options = { stages: [ { duration: '2m', target: 1 }, { duration: '2m', target: 10 }, { duration: '2m', target: 50 }, { duration: '2m', target: 200 }, { duration: '2m', target: 500 }, { duration: '2m', target: 1000 }, ], }; export default function () { const res = http.get('http://target-server:8080/api/hello'); check(res, { 'status is 200': (r) => r.status === 200 }); }这种控制变量的方式没有太多技术含量,但它直接决定了数据可信度。性能压测没有绝对标准答案,但至少要让“对比结论”在相对公平的规则下成立。
2.3 指标口径:先读RT和P99,再看QPS
压测完输出一堆数字,先看哪个?很多新人上来就盯QPS,觉得压得越多越厉害。但真实业务场景下,用户感知的是延迟,不是吞吐。
RT是单个请求的处理耗时,平均耗时容易被极端值拉偏,所以我更相信分位数。P99的意思是99%的请求在某个耗时以内完成。如果P99是200ms,说明最差的那1%请求也没有超过200ms,这个指标更接近用户真实体感。
QPS是系统在某一并发下每秒钟能处理的请求数。它和RT不是独立变量,两者背后有一个简单的排队论关系:并发数约等于QPS乘以平均响应时间。也就是说,当并发增加,RT被拉长到一定程度后,QPS反而会掉头向下。
我在压测方案里定了一条业务红线:P99不超过300ms。在这个前提下,谁撑住的QPS更高,谁就是更能打的。至于单靠CPU算到冒烟的裸奔QPS,那不是业务指标,只能当宣传材料看。
提示:压测数据出来后,先看P99是否击穿业务红线,再看QPS。QPS是结果,不是目标。
2.4 压测里最容易踩的三个坑
这一节是我做这轮压测前后踩出来的经验。坑一:压测机本身先撑不住了。我用wrk压过一次高并发,压测机CPU先跑到100%,数据曲线一塌糊涂。后来换成两台机器才把问题隔离掉。判断标准很简单,压测的时候先看看压测机的CPU和网络,它必须是那个“轻松发起请求”的角色。
坑二:不预热直接记录数据。Java的JIT编译是运行时逐步优化的,冷启动阶段跑出来的QPS可能只有热机状态的60%到70%。你要是只压30秒就结束,等于拿别人没热身的状态跟人比,结论必然失真。
坑三:连接复用策略不一致。有些压测工具默认走长连接,有些被测服务自己会强制关闭连接,最后变成握手开销的对比,而不是业务处理性能的对比。我这次在HTTP压测里统一开了Keep-Alive长连接,把握手成本排除在外。这三个坑如果在踩点时没处理好,后面所有数据对比都是在安慰自己。
3. 四组框架同场压测:记录与解读
现在到了正文核心:实测数据。再次强调,所有数字都基于我们当时的环境和配置,换个机器、换个参数结论可能有差异,但它给出的量级和趋势是很有参考价值的。
3.1 参选框架与版本
我们最终锁定了四类候选,正好覆盖了当前高并发场景下呼声最高的技术路线:
| 框架 | 版本/运行环境 | 线程模型 |
|---|---|---|
| Spring Boot(传统Servlet) | 3.2 / JDK21 | 一请求一线程(Tomcat线程池) |
| Spring Boot(虚拟线程) | 3.2 / JDK21开启虚拟线程 | 虚拟线程,可轻松创建大量线程 |
| Spring Boot WebFlux | 3.2 / JDK21 | Reactor响应式,少量线程处理高并发 |
| Go Gin | 1.22 | goroutine,语言级轻量并发 |
| Python FastAPI | 0.110 / Python 3.12 | 异步事件循环加线程池混合 |
你可能发现我漏了纯手写Netty。不是漏,是预研阶段我就做了一个小实验,发现手写Netty服务的数据确实好看,但完成同样一个带参数校验、日志、鉴权、统一异常处理的业务接口,开发量是Spring Boot的3倍以上。这种成本放在选型里怎么打分都不划算,所以正文不纳入正式对比,但在3.5小节我会单独贴它的数据,给一个参照系。
3.2 压测方法与业务场景
压测接口设计为一个人为限定的最小业务闭环:接收请求、参数校验、读一次Redis缓存、返回固定JSON。为什么要加Redis这一跳?因为真实的查询服务几乎不可能不带IO,纯内存计算的高并发跟业务场景脱节太远。加一次Redis读取,才能让数据更接近真实可参考状态。
压测流程我严格按固定步骤执行:第一步启动被测服务,预热10分钟;第二步运行k6脚本,按1、10、50、200、500、1000并发逐级压测;第三步每档结束后记录QPS、P99、错误率和内存占用;第四步切换框架,重复同样的流程。这里说明一点,4核8G的机器跑到1000并发时,连接数已经非常夸张,很多框架已经进入过载保护状态。后面这档数据更多是看“过载后的行为表现”,不是看正常吞吐能力。
3.3 核心实测数据
这里给的是并发500这一档的数据,因为这是接近我们业务峰值的关键档位,也是几个框架差距最明显的区域:
| 框架 | QPS | P99(ms) | 内存占用(GB) | 表现 |
|---|---|---|---|---|
| Spring Boot传统Servlet | 约8200 | 62 | 1.4 | 中规中矩,P99开始抬头 |
| Spring Boot虚拟线程 | 约12500 | 38 | 1.6 | 明显优于传统Servlet,配置改动极小 |
| Spring Boot WebFlux | 约13600 | 35 | 1.5 | 与虚拟线程接近,但编码心智负担高 |
| Go Gin | 约15800 | 21 | 0.6 | QPS最高,P99最平稳,内存优势明显 |
| Python FastAPI | 约7200 | 88 | 1.2 | 能扛但延迟偏高,长时间高并发有抖动 |
注意这些数据都是加了Redis读取之后的。换句话说,业务链路一旦带入真实IO,框架之间的差距会被下游延迟稀释。如果只跑纯计算接口,Go的优势会更夸张,但那不是你的真实业务。
3.4 三个让我意外的发现
数据不全在预料之内,至少有三个点让我回去翻了配置和代码确认。
第一,Spring Boot虚拟线程的收益比想象中大。在JDK21下开启虚拟线程只需要改一行配置,压测结果QPS提升了50%以上,P99也更低。原因是传统Tomcat的200个线程在处理Redis等待时被大量阻塞,虚拟线程把这个阻塞成本几乎抹掉了。如果你的团队已经是Java栈,这可能是性价比最高的性能升级手段。不过虚拟线程也不是银弹,它主要对IO密集有效,纯CPU计算场景下没有明显优势。
第二,Go的领先幅度没有某些博客吹得那么大。在加了Redis链路的真实业务模型下,Go比Spring Boot虚拟线程大概高了25%左右,并没有“碾压”。但它有个数据很关键——内存在500并发时只有0.6GB,是Java系框架的一半不到。对大规模部署来说,这直接关系到服务器成本。
第三,FastAPI的稳定性低于预期。单独看QPS7200并不算差,但压到10分钟之后P99出现明显抖动,这和Python的GIL以及GC机制有关。如果业务峰值只有3000到5000QPS,FastAPI完全够用;但到2万QPS这个量级,它显然不是首选。
3.5 补充:手写Netty服务的参照数据
我在预研阶段用一个极简Netty服务做了对照实验,不接Redis,只返回纯内存JSON。并发500时QPS大概到2.6万,P99在12ms左右,数据确实能打。
但注意两点:第一,这个服务没有鉴权、没有参数校验、没有日志采集、没有统一异常处理,相当于一个高性能裸引擎。一旦把这些业务逻辑补上,性能会打不少折扣。第二,从开发到上线,这个裸服务我花了接近两天,而Spring Boot从脚手架到完整业务只花了一个下午。性能差一截是真的,人力成本差数倍也是真的。这也是为什么大厂愿意自研网关,但普通业务团队更愿意在成熟框架上做文章。
4. 把数据变成决策,而不是数据的奴隶
压测数据出来了,最难的部分才刚刚开始:怎么把一堆数字翻译成一个“我们选谁”的结论。如果只看QPS排名,那直接选Go完事,但真正的技术决策从来不是单指标排名。
4.1 先定业务红线,再回头看数据
所有数据解读都要有一个前提:业务能接受的最差延迟是多少?这个数字应该在压测前就定好。我们当时的业务对用户可感知的响应比较敏感,所以我定的红线是P99不超过300ms,同时错误率低于0.1%。在这条红线内,QPS和内存都作为排名依据;一旦越过红线,不管QPS多高都视为不合格。
为什么强调这个?因为高并发服务有一个普遍的过载特征:当并发超过某个临界点,RT会以非线性方式飙升,QPS曲线掉头向下,整体表现为系统雪崩。如果你不看RT只看QPS,可能还在为“压测到1000并发还能抗住”沾沾自喜,实际上P99已经冲到2秒了,真实用户早就开骂了。
4.2 那些看起来漂亮、实际会骗人的数据
压测做多了之后,我对一组数字的第一反应不是“厉害”,而是“它有没有骗我”。常见的几类骗局:
- 纯计算接口压测。不连Redis不连数据库,让大家拼CPU速度。这种数据好看,但和真实业务差着一个身位。
- 压测机与被测服务同机。数据被机器上的资源竞争干扰得一塌糊涂。
- 用默认配置测试。Spring Boot的Tomcat默认200线程,Go的GOMAXPROCS自动吃满CPU,你拿两者直接比,比的完全是两种策略而并非能力。
- 只看平均RT,不看P99。平均值会被长尾拉得不高不低,掩盖了最影响用户体验的那部分问题。
我在这次实测里还遇到一个有趣的情况:先把两个框架都调成“各自认为最优”的配置来对比,结果Spring Boot虚拟线程和Go的差距并不大;但后来我故意用两边默认配置裸跑,差距就拉大了。如果你在社区看到“XX比YY强40%”的结论,先问问他们用的是什么配置。
4.3 性能之外,技术决策还要看什么
基于数据打完分之后,还要过一遍非性能维度。我的判断权重大致如下,仅供参考:
| 维度 | 权重 | 说明 |
|---|---|---|
| 性能达标度 | 30% | 只要达标就是合格线,不必追求极致 |
| 开发效率 | 25% | 同样的需求,谁能在更短时间内稳定交付 |
| 生态成熟度 | 20% | 中间件、监控、文档、社区问题可查性 |
| 运维复杂度 | 15% | 监控覆盖、日志采集、排障工具链 |
| 团队技能匹配度 | 10% | 现有团队能不能持续维护这套技术栈 |
为什么性能只占30%?因为在这个量级下,符合业务红线的框架都不止一个,剩下的差距完全可以用架构手段加实例来抹平。反倒是“团队写起来顺手”“出问题有人会修”“监控体系不用另起炉灶”这些软指标,才是长期维护成本的大头。拿我们这次为例,在业务红线内达标的框架有Spring Boot虚拟线程、WebFlux和Go Gin三个。三者QPS的差距在架构上就是少加一台机器的事,但团队熟悉度的差距却是实打实要花几个月补齐的。
4.4 用决策矩阵收敛结论
我最终做的是打分矩阵。每个候选框架按权重打分,然后求综合分。这个环节不神秘,就是逼着团队把所有顾虑量化,避免最后又靠拍脑袋吵一轮。
结果也很现实:Go Gin性能分最高,但在开发效率、生态、团队匹配上扣了不少分;Spring Boot虚拟线程各项均衡,综合分最高。至于WebFlux,性能不错,但响应式编程的心智门槛让开发效率分掉下去了,最后排第二。我不打算在这篇文章里替你做最终选择,因为每个团队的权重组合不同。但你完全可以按上面这个思路,把你们自己的维度打分填进去。数据放在那里,权重由业务决定,最后的结论才经得起推敲。
5. 选型定下来之后的落地经验和常见坑
选型不是终点,落地才是。这一章写选型确认后我在实施阶段踩过的坑,和几个已经沉淀下来可以直接套用的经验。
5.1 用mini原型回归验证一次
正式切流量之前,我们做了一件花费不大但回报极高的事:搭一个mini原型,连接真实的MySQL和Redis,把真实业务链路中最重的两个接口走一遍压测。这轮压测基本推翻了纯基准测试的一部分结论——框架之间的差距变小了,MySQL连接池和SQL慢查询的占比显著上升。
这不难理解:框架只负责“接客”,真正干活的是下游。MySQL性能调优、连接池大小、索引命中率,在高并发场景下的权重往往比框架本身更高。所以你若只拿框架做纯压测就上线,大概率会踩到“框架没问题,下游先崩了”的坑。回归验证的意义,就是提前把这条链路上最脆的一环暴露出来。
5.2 框架默认参数,值得逐个过一遍
这是老生常谈,但每次选型完都有人栽在里面。我整理了一份高并发下最常见的默认配置坑位:
- Tomcat线程池默认200。在4C8G机器上,这个值要按压测结果调整,我们实测过调到150反而P99更稳。
- Netty的EventLoop线程数默认是CPU核数两倍,IO密集场景基本合理,但如果业务里有少量CPU计算,需要单独开业务线程池避免阻塞EventLoop。
- Go的GOMAXPROCS默认等于CPU核数,在容器环境下要注意它可能识别到宿主机所有核数,导致线程数虚高,需要显式设置。
- 数据库连接池,HikariCP默认10个,高并发下连接池几乎必然成为瓶颈。常规做法是按机器核数估算,起步10到20个再压测微调,绝不无脑开大。
- Redis连接池和外部HTTP连接池同理,所有外部依赖的池子都要纳入压测范围。
这些参数没有标准答案,唯一的正确答案就是你自己的压测结果。把压测脚本和基线配置提交进代码仓库,每次调参后回归对比,参数优化就变成了一件可积累的事。
5.3 监控指标和容量评估一起设计
线上巡检不能等上线后再装。我在上线前就把监控指标和容量评估方案定了下来。关键监控指标清单如下:
- 服务端:QPS、RT分位数(P50/P95/P99)、错误率、活跃连接数;
- 线程池:活跃线程数、队列积压、拒绝次数;
- GC日志(Java系):GC频率、停顿时间;
- 下游依赖:MySQL慢查询数、连接池活跃数、缓存命中率;
- 机器资源:CPU、内存、网络吞吐、文件句柄。
容量评估的思路其实也简单:用压测得到的单实例极限QPS除以2,作为单实例的安全水位,再拿业务峰值QPS除以安全水位,得到最小实例数。为什么要除以2?因为压测环境永远是理想化的,真实流量存在毛刺和突发,而且故障时还要留出流量切换的余量。这个公式粗暴,但比拍脑袋靠谱得多。
5.4 别把限流、降级、熔断放最后
高并发系统的一个残酷现实是:无论框架多快,流量超过容量上限的那一刻,系统都会进入过载状态。所以选型的同时就要把保护机制一起设计。我在这次项目里落地了三个动作:网关层限流,按客户端和接口设置不同阈值;核心接口做降级开关,当依赖的Redis或MySQL抖动时,能快速返回降级数据而不是让请求堆积;下游依赖设置超时和熔断,避免一个慢接口拖死整个服务。
这些和安全水位的容量评估是一套组合拳。框架选型只是给了你一个好底子,但真正的高并发能力,是在底子上长出来的限流策略、监控体系和故障预案。如果你在选型后只想着“框架快就够了”,那大概率会在一次流量高峰里被教育。
最后再分享一个小经验。这轮选型做完之后,我把所有压测脚本、基线配置、指标口径整理成了一个运行文档放在团队仓库里。以后每次框架升级、JDK升级或者线程池调参,都拿同一套脚本回归一遍,直接对比数据。第一次搭建这套流程确实花了点时间,但之后每次调优都省了大量重复劳动,这也是这轮选型留给团队最值钱的东西,比“选了哪个框架”本身更有长期价值。