系统并发与 QPS 上限:从 200 个线程到每秒几万请求

图形验证码限流优化:从 3 秒阻塞到毫秒级拒绝的踩坑记录

一、从一次事故说起

图形验证码故障复盘时,我反复提到一句话:"Tomcat 的 200 个工作线程被占满了"。可能有同学会有疑问:这 200 是哪来的?是所有服务器共享的吗?我们这套系统到底能扛多少并发、多少 QPS?

本文把这条线一次讲透:先弄懂 Tomcat 线程池是什么,再算出我们 6 服务 × 4 实例的系统的理论并发和 QPS 上限,最后看 Redis 缓存能把天花板抬高多少。

二、Tomcat 线程默认多大

Spring Boot 内置的 Tomcat 有一套默认配置,四个数字要分清:

配置项 默认值 干什么的
server.tomcat.threads.max 200 工作线程上限,决定"同时处理中"的请求数
server.tomcat.threads.min-spare 10 启动时预创建的空闲线程,忙起来再逐步加到 200
server.tomcat.max-connections 8192 最多能同时保持的 TCP 连接数(挂着的连接,不是处理的请求)
server.tomcat.accept-count 100 线程用光后,请求排队的长度上限

类比成饭店前台就很好记:min-spare=10 是开张时先站 10 个接待员;max=200 是店里最多养 200 个接待员;8192 是门口最多能站 8192 个客人;100 是店内最多允许 100 个人排号等着。修改方式是在 application.yml 里配 server.tomcat.threads.max: 500。

(1)那server.tomcat.threads.max 工作线程有上限吗?

先说结论:代码层没有硬编码上限,但现实世界有一串天花板------真正限制你的是内存和操作系统,不是 Tomcat 配置本身。

(2)配置层的"上限"

server.tomcat.threads.max 在 Spring Boot 里绑定到 int 类型,对应 Tomcat 的 maxThreads。Tomcat 源码里默认值 200、最小值 1,往上没有硬校验------理论上你可以配到 2147483647,Spring Boot 也会接受。

但这个数字没有任何意义,因为在配到几万之前,现实已经把你拦住了。

(3)现实层的四个天花板

天花板 具体限制 数量级
线程栈内存 每个 Java 线程默认占 1MB 栈内存(-Xss 可调),是堆之外的 native 内存 默认堆 4G 的机器,线程到几千个就可能 OOM
操作系统线程数限制 Linux 的 ulimit -u(max user processes),常见默认 1024~4096 一个 JVM 的线程数超不过这个
maxConnections 关联 NIO 模式下连接上限是 max-connections(默认 8192),线程再多也服务不了更多连接 8192 是连接数天花板
性能拐点 线程数超过 CPU 核数几倍后,吞吐不升反降(上下文切换、GC 压力) 几十核的机器,几千线程就是拐点

最典型的翻车方式是 OutOfMemoryError: unable to create native thread------不是堆满了,是线程栈把 native 内存吃光了,堆监控完全看不出来。

(4)更重要的一个认知:调大 ≠ 变强

Tomcat 默认 200 线程,对绝大多数 REST 场景是够的,甚至偏多。原因:

  1. NIO 模式下,真正决定连接能力的是 maxConnections(8192),线程只是"处理连接上活跃请求"的工人。你把线程调到 5000,但 maxConnections 还是 8192,多出来的线程闲着,还白白吃内存、增加切换开销。

  2. 线程数的意义是"并行等 I/O" 。如果请求大多在等数据库、等 Redis(I/O 密集型),加线程能提升一点并发;如果请求是纯计算(CPU 密集型),加线程纯属负优化。业界经验公式:线程数 ≈ CPU 核数 × (1 + 等待时间/计算时间),按这个算,很多服务 50~100 线程就够。

(5)什么时候才需要调大

就两种情况,且都要先排除别的因素:

  • 慢客户端/长轮询/流式响应:一个连接长时间占着线程,200 不够用;

  • 大量阻塞式调用 (同步等第三方、等锁):线程在干等,需要更多线程来摊。但这其实是"代码该改异步"的信号------治本,而不是靠调大 maxThreads 兜底。

对应我们之前聊的验证码事故:那次的教训反过来也成立------200 线程被阻塞式 tryAcquire 占满是"代码问题放大线程问题",解法是改非阻塞,而不是把线程调到 500。调大线程只是延迟爆雷,阻塞没消除,500 个线程一样会被占满。

三、是一台服务器默认的线程吗

不是。200 是一个 JVM 进程的线程,不是物理服务器的。

TypeScript 复制代码
┌───────────────────────────────────────────────┐
│                物理服务器(一台机器)             │
│                                               │
│  ┌──────────────┐   ┌──────────────┐          │
│  │   实例 A     │   │   实例 B     │    ...    │
│  │  (JVM 进程)  │   │  (JVM 进程)  │           │
│  │              │   │              │           │
│  │ Tomcat 200   │   │ Tomcat 200   │           │
│  │ HikariCP 50  │   │ HikariCP 50  │           │
│  └──────────────┘   └──────────────┘          │
└───────────────────────────────────────────────┘

一栋楼(物理服务器)里有 N 家公司(实例),各公司有自己独立的前台团队(Tomcat 线程池),互不共享。实例 A 忙到 200 个线程全占满,实例 B 线程全闲着,也帮不了 A------除非负载均衡把请求匀过去。

部署 3 个实例就有 3 个 200,总共最多 600。这个"200"是进程维度的,和操作系统线程、和物理机核数都没有直接关系。

四、Tomcat 线程和"线程池"是什么关系

Tomcat 本身就是个线程池 ,内部叫 StandardThreadExecutor,管理的就是那 200 个线程。所以"Tomcat 线程"不是和"线程池"并列的两个东西,而是"一个池子里的工人"。

真正容易混淆的是另一个池子:业务线程池 ,代码里自己 new 的 ThreadPoolExecutor,比如 Executors.newFixedThreadPool(10)。两个池子分工不同:

TypeScript 复制代码
请求进来 → Tomcat 线程池(前台接待员)
                │
                │  处理到慢环节(发短信、调三方接口、算报表)
                │  把任务丢给后厨
                ▼
          业务线程池(后厨厨师,如 10 个线程)

关键坑在丢出去的姿势:只有"丢进去不等结果"(异步,不调 future.get())Tomcat 接待员才真正抽身去接下一个请求;如果是同步等后厨做完再走,接待员照样被占着。

验证码事故就是这个坑的教科书案例:tryAcquire(3, TimeUnit.SECONDS) 让 200 个接待员原地干等 3 秒,前台人手占满,整个饭店(同进程所有接口)都转不动。

五、谁是并发上限

把请求从进来到处理完画成一条链路:

复制代码
  
TypeScript 复制代码
    客户端请求
        │
        ▼
  ┌──────────────────┐
  │ Tomcat 连接器      │  最多挂 8192 个连接
  └──────────────────┘
        │
        ▼
  ┌──────────────────┐
  │   等待队列         │  最多排 100 个
  └──────────────────┘
        │
        ▼
  ┌──────────────────┐
  │   工作线程池       │  默认最多 200 个同时处理  ← 并发上限在这
  └──────────────────┘
        │  处理中调用
        ▼
  ┌──────────────────┐
  │   业务线程池       │  请求内部的异步任务(如 10 个线程)
  └──────────────────┘

8192 个连接可以挂着,但只有 200 个能同时"真正处理";多出来的最多 100 个排队;再多的直接拒绝或超时。

结论一句话:请求级并发上限 = Tomcat 工作线程数(默认 200) 。业务线程池是嵌套在请求内部的第二层并发,它决定"单个请求里异步任务能并行跑多少",不决定系统能同时接多少请求。

六、系统级,假如:6 服务 × 4 实例的各层并发上限

实例总数 = 6 × 4 = 24 个,各层上限一起算:

层 计算 并发上限
Tomcat 请求线程 24 × 200 4800
数据库操作并发 24 × 50(HikariCP 单实例池) 1200
MySQL 连接 配置上限 4190

注意:MySQL 的 4190 > 1200,数据库端不是瓶颈,这个配置是合理的。真正的瓶颈是 HikariCP 总和 1200------它决定了"同一时刻最多只有 1200 个请求在真正执行 SQL"。

七、并发和 QPS 的换算(Little's Law)

并发是"同时处理中的请求数",QPS 是"每秒完成的请求数",两者靠响应时间换算:

TypeScript 复制代码
QPS = 并发数 ÷ 平均响应时间

直观例子:1 个线程处理 1 个请求要 100ms,那它每秒最多完成 10 个,就是 10 QPS。200 个线程、每个请求 100ms,单实例就是 2000 QPS。

反过来看这张表,体会"响应时间决定天花板":

单请求平均耗时 单实例 200 线程的理论 QPS
10ms 20000
100ms 2000
500ms 400

所以并发上限 200 不等于 QPS 上限 200。响应越快,同样线程数能完成的 QPS 越高 ------这也是为什么优化慢 SQL、加缓存的收益是乘法级的。

八、场景 A:全走 MySQL(不缓存)

每个请求都要拿一个 HikariCP 连接,所以并发上限被卡死在 1200:

平均响应时间 理论 QPS
300ms(慢 SQL、大查询) 1200 ÷ 0.3 ≈ 4000
100ms(常见 OLTP) 1200 ÷ 0.1 ≈ 12000
50ms(轻查询) 1200 ÷ 0.05 ≈ 24000

全走 DB 时,理论天花板大约每秒 1 万左右(按 100ms 基准)。Tomcat 的 4800 在这里用不满------1200 先到顶。

九、场景 B:走 Redis 缓存

走缓存时请求不再占用 DB 连接,瓶颈从"1200 个 DB 连接"抬回到"4800 个 Tomcat 线程",响应时间也从 100ms 级降到 1~5ms 级。两个因素同时变好,QPS 是乘法级提升:

场景 并发上限 单请求耗时 理论 QPS
全走 MySQL 1200(HikariCP) 100ms ≈ 1 万
走 Redis 缓存 4800(Tomcat) 5ms 数万 ~ 十万级

但这里要泼三盆冷水,新瓶颈会转移:

  1. Redis 单点是新的咽喉。24 个实例全打同一个 Redis,单实例 Redis 的实用上限大约 5~10 万次操作/秒,会先于 Tomcat 到顶。要再上就得 Redis 集群分片。

  2. 理论值只是天花板。真实压测到几万 QPS 时,先撞上的往往是 GC 停顿、日志磁盘 IO、网络带宽、锁竞争这些"看不见的墙"。典型现象:理论算出来 5 万,压测到 8000 就被 GC 卡住。

  3. 缓存命中率决定一切 。上面的数字默认"全命中"。一旦穿透(缓存雪崩、热点 key 失效),请求打回 MySQL,上限立刻跌回场景 A 的 1200 并发。所以高并发系统真正要盯的是命中率和穿透防护(空值缓存、布隆过滤器),而不是纯算 QPS。


注意,使用redis性能优化时避坑 :接口性能优化:Redis 缓存序列化踩坑记录:@Cacheable 撞上 Stream.toList()

十、结论

场景 瓶颈 理论 QPS(100ms 基准)
全走 MySQL HikariCP 1200 连接 ≈ 1 万
走 Redis 缓存 Redis 单点 5~10 万次/s 数万级,实际靠压测

三个要点:

  1. 请求并发上限看 Tomcat 的 200(单实例),QPS = 并发 ÷ 响应时间,响应时间才是真正的杠杆;

  2. 全走 DB 时系统卡在 HikariCP 的 1200,理论约 1 万 QPS,MySQL 4190 不是瓶颈不用动;

  3. 走缓存能上一个大台阶,但新瓶颈在 Redis 单点和应用自身资源,最终数字别信理论,上线前用 JMeter 或 hey 压一轮,把"平均响应时间"换成压测实测值再定容量。

最后提醒一个配置陷阱:HikariCP 单实例 50、24 实例共 1200,离 mysql最大连接数4190 很远没问题;但如果哪个服务单独调大到 200,服务实例增加,全系统总和会逼近甚至超过 4190,MySQL 开始报 Too many connections。4190 是全系统共享的预算,不是每个服务独立的额度。

相关推荐
FYKJ_20101 小时前
springboot游泳馆管理系统79222-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·mysql·django·课程设计
闲云自留地3 小时前
主库倒下 30 秒自动扶正:MySQL MHA 高可用实战
数据库·mysql
hasty14 小时前
policy.xml 在,不代表策略生效:ImageMagick 补丁给安全配置验收的提醒
xml·mysql·安全
ly768917 小时前
Redis 大 Key 与热点 Key 生产治理:发现、拆分、限流与本地缓存的组合策略
redis·限流·本地缓存·大key·热点key
ShineWinsu19 小时前
对于Redis:AOF持久化的解析
linux·数据库·redis·缓存·面试·持久化·aof
晚风叙码19 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
仍然.19 小时前
Redis---集群
数据库·redis·缓存
做运维的阿瑞20 小时前
SQL 面试高频:用户、商品、订单三表查询全解析
数据库·sql·mysql
hweiyu0021 小时前
Redis命令:HSCAN
redis·缓存