压测 QPS 上不去?别急着调 JVM,我按 6 层排查从 800 干到 12000

🔑 关键词:高并发,QPS优化,性能调优,上下文切换,利特尔法则

📖 摘要:4核8G单机跑1万QPS不是玄学。记录一次从压测800 QPS到12000的真实排查过程:vmstat、火焰图、线程池、log4j2异步、HikariCP参数,每一步的具体数值,以及为什么我只认『P99<300ms前提下的QPS』。

先说结论,省得你看到第三段就跑:4 核 8G 的容器跑一个纯查询 HTTP 接口,QPS 干到 12000 是能做到的。但这事儿跟你的线程池大小关系不大,跟你在哪里排队关系很大。

图片

起点:QPS 卡在 800,CPU 才 35%

去年十一月,我们一个订单查询接口要过大促,运维定的目标是单机 5000 QPS。我拿到的是一个 4C8G 的容器,Java 17 + Spring Boot 3.1 + HikariCP + MySQL,链路简单到不能再简单:网关 → 应用 → 查 Redis → 命中就返回,没命中才打 MySQL。

上线前一周开始压,wrk -t8 -c200 -d60s --latency,QPS 卡在 780~830 之间来回晃,CPU 只跑到 35%,内存用了一半,机器一点都不累,但每秒就这么多请求。

这个数字特别气人。不是「跑不动」,是「它在装死」。

第一轮:我调错了方向

第一反应是 Tomcat 线程池不够。默认 server.tomcat.threads.max 是 200,我直接翻倍到 800,重启再压——QPS 810。

又试了 400、1200,QPS 在 780 到 830 之间晃,像是有个隐形天花板。这时候才想起来上 vmstat 1:

图片

 r  b   swpd   free   buff  cache   si   so    bi    bo      in      cs us sy id wa
12  0      0 120384  21012 412300    0    0     0     0    4201   84022 22 41 36  1

cs 那一列 84022,每秒八万四千次上下文切换,sy 41%。

这不是「线程不够」,是「线程太多,都在抢 CPU」。线程被唤醒、抢锁、超时、再睡回去,CPU 光做调度了,真正干活的时间被切得稀碎。降到 200 线程之后 QPS 反而爬到 1200。

线程数和并发能力不是线性关系。超过核心数之后,每多一个线程都在给调度器加税。 4 核机器上 Tomcat 给 200 已经很宽裕,因为线程大部分时间在等 IO,不是在算。

第二轮:火焰图告诉我真相

抓了个 async-profiler 的火焰图(./asprof -d 30 -e cpu -f out.html <pid>),一眼看出两件事。

图片

一个是 ObjectMapper.writeValueAsString 占了 28% 的 CPU 采样。我们的返回体是嵌套 5 层的 DTO,60 多个字段,Jackson 每次都在反射建序列化器。查代码发现有人手滑在方法里 new ObjectMapper()——这玩意儿设计上就是重量级单例,new 一次要重新扫一遍所有注册的 Module。改回单例 + 加上 @JsonIgnoreProperties,降到 9%。

另一个更离谱:log4j2 的同步 FileAppender。我们打的是完整请求体和响应体,JSON 格式,一条日志 4KB。写完盘才算完,一次 fsync 大概 1-3ms,200 个线程排队等这一个 appender 的锁。改成 AsyncAppender,bufferSize 从默认 1024 提到 8192,blocking=false(缓冲区满会丢日志,内部接口能接受,金融场景别抄),QPS 直接跳到 3000 出头。再把 -Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector 挂上,全异步模式又多了几百。

顺手把 HikariCP 的 maximumPoolSize 从 10 调到 16,connectionTimeout 从 30000 改成 3000 让它快速失败——这条是为了防雪崩,不是为了提 QPS。

利特尔法则:先算清楚你要多少并发

到这儿我想说一个被严重低估的公式:利特尔法则,L = λW。

人话版:并发数 = 吞吐 × 单次耗时。

你要 5000 QPS,单次请求 20ms,那同时在飞的请求是 5000 × 0.02 = 100 个。看起来不多对吧?但那 20ms 里只要有一段是串行的——一次 15ms 的 DB 查询,或者一次抢锁——这 100 个并发就有 100 个线程在这 15ms 里干等。等的时间乘以 100,就是你必须开出来的线程数。

图片

反过来也算得通:你配 200 个线程,单次耗时 50ms,理论上限就是 200 / 0.05 = 4000 QPS。到了 4000 你还想往上走,只有两条路:降单次耗时(这条是对的),或者加线程(这条基本是假的,因为你加线程的同时单次耗时也在涨,两者会互相抵消)。

我们最后能到 12000,靠的完全是降 W:单次耗时从 68ms 干到 3.2ms。缓存命中率从 71% 提到 96%——是的我们发现 29% 的请求在穿透打 DB,因为 key 里带了个每分钟变一次的时间戳,这个 bug 存在了三个月没人发现。

我的独立观点:排队守恒

这一段可能有人不同意,但我还是要说。

高并发优化本质上不是「消除排队」,而是「选择在哪里排队」。

你加线程池,是把请求从内核的 accept 队列(net.core.somaxconn,新内核默认 4096,老内核 128)搬到了 JVM 的 LinkedBlockingQueue;你加 MQ,是把队列从内存搬到了磁盘;你上 Redis,是把计算从数据库搬到了内存;你加 CDN,是把排队从你的机房搬到了离用户 20ms 的地方。

图片

排队没有消失,它换了地方发生。而你真正能选的只有三件事:在哪儿排、排多久、排队期间用户看到什么。 前两个决定 QPS 上限,第三个决定用户会不会骂你。

我们后来把超时从 3s 砍到 800ms,超时就返回一个兜底的空对象,P99 从 1900ms 掉到 210ms——QPS 一点没变,但投诉工单少了 80%。

关于新并发模型的一点冷水

Go 的 goroutine 和 JDK 21 的虚拟线程(Executors.newVirtualThreadPerTaskExecutor())解决的是同一件事:平台线程太贵。Java 平台线程默认栈 1MB,1 万个就是 10GB 虚拟内存;goroutine 初始栈 2KB,虚拟线程是堆上对象,几百字节起。数字上差了三个数量级。

但你要是指望换个并发模型就把 QPS 提上去,多半会失望。我们那个瓶颈压根不在调度上,在磁盘 fsync 和 Jackson 反射上。

调度模型解决的是「能不能扛住 10 万个并发连接」,不解决「每个请求为什么花了 68 毫秒」。 这是两个问题,市面上太多文章把它们混在一起讲。

我自己的排查顺序,从上到下别跳

图片

  1. 先看延迟分布,不看平均值。 wrk --latency,或者接 HdrHistogram,P50/P95/P99/P999 都要。P50 是 12ms 但 P99 是 1900ms 的系统,和平均值 200ms 的系统,是两种完全不同的病。前者八成是锁竞争或 GC。
  2. vmstat 1 看 cs 和 sy。 cs 我一般按每核 1 万来警戒,4 核机器超过 4-5 万就该查了;sy 超过 30% 说明内核在替你干很多活。
  3. pidstat -w -p <pid> 1 看自愿/非自愿上下文切换。非自愿高 = CPU 抢不到;自愿高 = 在等 IO 或锁。
  4. async-profiler 火焰图。 看宽的地方,别猜。猜十次错九次。
  5. 一次只改一个变量。 我们有个哥们同时改了线程池、GC 参数和日志级别,QPS 上去了,然后没人知道是哪个起的作用。回滚的时候踩了大坑。

最后一个反直觉的

压测出来的 QPS 数字本身没什么意义,除非它同时带着延迟和错误率。

因为 QPS 是可以用延迟换的。你把 Tomcat 线程从 200 加到 5000,QPS 或许能从 12000 变 12500,代价是 P99 从 200ms 变成 8 秒——用户早就关页面了,这些请求还在你系统里跑,白白吃 CPU。这就是为什么很多压测报告很好看,一上生产就雪崩。

真正的容量是「在可接受延迟下能扛住的 QPS」。我们最后定的验收线是一条:P99 < 300ms 的前提下,QPS ≥ 8000。 低于这条线,QPS 数字再漂亮也不认。

踩过的坑比写出来的多。比如 DNS 解析那个事——JDK 的 networkaddress.cache.ttl 默认 30 秒,但有些容器基础镜像的安全策略会把它改成 -1 变成永久缓存,你做灰度切流量的时候会死得很惨。以后有空再写。

🏷️ 标签: