线程可视化的理解和QPS


结合场景,线程可视化理解

场景:SpringBoot + Tomcat 默认

假设要根据userId查用户信息,通过调接口/getUser。前端在发起接口请求开始,到接口查到数据返回给前端这个过程中,线程是怎么执行的。

一个前端请求发起 → 进入后端接口 → 直到数据返回给前端,全程独占 1 个线程,中间不管是查库、等接口、等 Redis,这个线程都会一直被占着,啥也不干干等着,直到整个请求结束,线程才会被释放。

对比虚拟线程是怎么解决这个浪费问题的。


一、完整流程:前端请求 → 后端返回,线程到底怎么跑?

前提

  • 后端:SpringBoot + Tomcat(默认同步模型)
  • 接口:/getUser?userId=1001
  • 逻辑:Controller → Service → Mapper/MyBatis → MySQL 查询 → 返回数据

全程线程执行链路(一步不落)

1. 前端发起 HTTP 请求

浏览器发送请求到服务器,到达Tomcat 服务器

2. Tomcat 分配线程(线程开始占用)

Tomcat 内部有一个工作线程池(默认核心线程 20,最大 200),请求进来后:

  1. Tomcat 从线程池里拿出一个空闲的平台线程
  2. 把这个请求全权交给这个线程负责
  3. 从这一刻起,这个线程就被当前请求独占了,直到请求结束

3. 线程执行 Controller 代码

@GetMapping("/getUser")
public User getUser(Long userId) {
    // 线程执行到这里
    return userService.getUserById(userId);
}

线程正常跑,无阻塞。

4. 线程执行 Service,走到 MyBatis 查 MySQL(关键阻塞点)

public User getUserById(Long userId) {
    // 执行JDBC查询MySQL
    return userMapper.selectById(userId); 
}

**这里发生 IO 阻塞!**线程发起数据库查询后,必须等 MySQL 网络返回数据,这段等待时间:

  • 线程不消耗 CPU
  • 线程啥也不干,原地空等
  • 线程依然被当前请求独占,不释放
  • Tomcat 线程池里的这个线程,被占死了,不能处理其他请求

5. MySQL 返回数据,线程继续执行

数据库查到数据返回,线程苏醒,继续封装 User 对象。

6. 线程把结果返回给 Tomcat,Tomcat 响应前端

线程将结果交给 Tomcat,Tomcat 把 HTTP 响应写回浏览器。

7. 请求结束,线程释放(占用结束)

直到这一刻,这个线程才被释放回 Tomcat 线程池,重新变成空闲状态,才能接收下一个前端请求。


二、最关键的问题:为什么说这种模式浪费资源?

举个直观的例子:

  • 一次/getUser请求总耗时:300ms
  • 其中真正 CPU 计算耗时5ms(封装参数、处理逻辑)
  • 其中阻塞等待耗时295ms(等 MySQL 返回)

=> 98% 的时间,线程都是空占着不干活!

如果此时有300 个并发请求

  1. Tomcat 线程池最大 200 个线程
  2. 200 个线程全被占满,都在等数据库
  3. 剩下 100 个请求只能排队等待
  4. 接口响应变慢、甚至超时

这就是传统平台线程最大的痛点IO 阻塞时,线程资源被白白浪费


三、虚拟线程是怎么解决这个问题的?(对应你之前的疑问)

开启 SpringBoot 虚拟线程后:spring.threads.virtual.enabled=true

流程变了,但「请求绑定线程」的逻辑没变

  1. 前端请求进来,Tomcat 分配虚拟线程(而非平台线程)
  2. 虚拟线程依然全程绑定这个请求,直到返回
  3. 执行到查 MySQL 阻塞时:
    • JVM 把这个虚拟线程挂起
    • 立刻释放底层的平台线程
    • 这个平台线程马上可以去处理其他虚拟线程的请求
  4. MySQL 返回数据后:
    • JVM 唤醒虚拟线程
    • 再找一个空闲平台线程继续执行
  5. 请求结束,虚拟线程销毁

核心区别

  • 传统平台线程:阻塞时占着坑不拉屎,线程池很快满
  • 虚拟线程:阻塞时让出平台线程,几个平台线程就能处理成千上万并发请求

四、一句话终极总结

  1. 同步 Web 接口下1 个前端请求 = 独占 1 个线程,从请求进来一直到响应返回,线程才释放。
  2. 中间所有 IO 阻塞(查库、Redis、第三方接口),线程都在空等,资源浪费极大。
  3. 虚拟线程不改变「请求绑定线程」的模型,只是阻塞时自动释放平台线程,彻底解决线程池耗尽、吞吐量上不去的问题。

在实际项目场景中,通常会统计QPS,结合线程的逻辑,如何解释QPS。

一、先给 QPS 一个落地定义(项目里实际用的)

QPS = Queries Per Second,直译:每秒查询数放到 SpringBoot Web 项目里,就是:服务器每秒能成功处理完、并返回给前端的 /getUser 请求数量

简单说:1 秒内,有多少个查询用户的请求从头到尾跑完了,这个数字就是 QPS


二、结合「请求独占线程」的逻辑,QPS 是怎么算出来的?

先回顾核心前提:1 个前端请求 = 独占 1 个线程,从请求进来 → 响应返回,线程才释放

QPS 的大小,直接由两个核心因素决定:

  1. 可用线程总数(Tomcat 线程池最大线程数)
  2. 单个请求的平均耗时(RT)

核心公式(项目压测必用)

理论最大 QPS ≈ 工作线程数 ÷ 单个请求平均耗时 (秒)

/getUser接口举实例

假设:

  • Tomcat传统平台线程池:最大线程 = 200
  • /getUser 平均耗时(RT)= 300ms = 0.3 秒(其中 295ms 在等 MySQL,纯阻塞)

代入公式:理论 QPS = 200 ÷ 0.3 ≈ 666

meaning:服务器极限情况下,每秒只能处理 666 次查询用户的请求。超过这个数,请求就会排队、响应变慢、甚至直接超时。


三、为什么 IO 阻塞会直接拉低 QPS?(核心痛点)

你的/getUser接口:

  • 真正 CPU 干活时间:5ms(封装参数、组装对象)
  • 线程空等 DB 时间:295ms(阻塞)

=> 线程 98% 的时间都在闲着,但又占着线程不释放这就导致:

  • 200 个线程被占满
  • 每秒只能处理 666 个请求
  • CPU 利用率很低、线程资源被浪费,QPS 死活上不去

这就是IO 密集型接口(查库、调第三方、Redis) 的典型 QPS 瓶颈。


四、虚拟线程是怎么把 QPS 拉上去的?

开启虚拟线程后,线程模型变了:虚拟线程阻塞(等 DB)时,会立刻释放底层平台线程

假设:

  • 平台线程只需要20 个(足够调度)
  • 虚拟线程可以无限开(上万级别)
  • 单个请求 RT 还是 300ms

此时:

  • 线程不再是瓶颈
  • QPS 不再受线程数限制
  • 极限 QPS 逼近MySQL / 网卡 / Redis的硬件上限

同样的/getUser接口:QPS 能从 600+ 直接涨到 几千~上万


五、项目里真实的 QPS 场景(你一定会遇到)

场景 1:低并发

  • 并发请求:50
  • QPS:≈160
  • 线程池空闲,接口响应飞快

场景 2:压测到极限

  • 并发请求:300
  • 线程池被占满(200 个)
  • 100 个请求排队
  • RT 从 300ms 涨到 1s+
  • QPS 达到峰值 666 后,不再上涨,甚至下降

场景 3:开虚拟线程后

  • 并发请求:10000
  • 平台线程仅 20 个,被极致复用
  • RT 稳定 300ms
  • QPS 轻松破 5000+

六、一句话总结 QPS 和线程的关系

  1. QPS 是结果:代表接口每秒的处理能力
  2. 传统平台线程:请求独占线程 + 阻塞不释放 → 线程数是 QPS 的硬天花板
  3. 虚拟线程:阻塞时释放平台线程 → 打破线程瓶颈,QPS 逼近硬件极限
  4. /getUser这类IO 密集型接口,QPS 低的根本原因就是:线程被白白浪费在等待上
SpringBoot
知识点
多线程