2026/11/4 20:14:12

MySQL线程池优化高并发:连接数、TPS与压测调优

MySQL线程池优化高并发:连接数、TPS与压测调优 简介《MySQL新增线程池插件说明》面向DBA、后端开发与性能测试人员聚焦高并发请求下MySQL线程创建销毁开销大、性能波动等问题提供线程池插件引入前后的对比测试与稳定性验证思路。压缩包仅含1个PDF文件大小3.26MB正文按测试计划、目标、环境、网络拓扑、软硬件配置、测试数据与代码改造、测试工具、单库及四库负载测试、稳定性测试等模块组织并说明线程池工作原理、安装配置参数及sysbench、JMeter、Performance Schema等工具选型。文档给出可复用的压测策略与执行安排便于读者对照搭建接近生产的环境采集QPS、TPS、响应时间和资源利用率数据判断插件收益与持续高负载下的稳定性。已有446人学习适合需要落地数据库性能优化与压测方案的中高级技术人员参考。1. 线程池不是多开线程MySQL 高并发下的线程模型为什么必须换把 MySQL 线程池插件装上之后最反直觉的一点是服务端线程数没有变多反而被限制在一个常数附近单库吞吐却从 131676 TPS 涨到了 250274 TPS。这是一份数据库优化性能压测方案里记录的对比——同一个 48 核实例、同一张 120 万行的表、同一条主键查询唯一变量是插件开没开。很多人以为数据库优化就是加核、加内存、调大 max_connections可连接一多CPU 使用率上去了TPS 反而掉下来。线程池要解决的就是这个连接数涨、吞吐不涨的怪现象把线程创建销毁和调度排队的成本从每次请求都要付改成一次性付清。搞懂它你才知道高并发下该调的是连接数还是线程数。2. 一连接一线程从 pthread_create 到调度器抖动开销到底在哪2.1 连接数等于线程数这件事本身就是一个隐藏上限MySQL 默认的线程模型叫 one-thread-per-connection直译就是一个连接配一个线程。客户端连上来服务端在接入层分配一个 THDThread Handler Descriptor对象然后用 pthread_create 起一个线程这个线程从头到尾只为这一个连接服务直到连接断开才销毁。这套模型在小规模下非常优雅没有队列、没有调度、没有跨连接的状态共享请求处理路径最短。问题出在规模上。2000 个连接就是 2000 个线程每个线程要占一块栈空间thread_stack 参数控制常见配置是 256KB 到 1MB还要在 OS 的调度器里占一个调度实体。线程不是免费的即使它什么都不干也躺在 runqueue 里等着被调度内核仍要维护它的上下文。方案里那台 48 核 755G 内存的机器看着很宽裕可真正限制你的是 CPU 核数与可运行线程数的比例跟内存基本没关系。这里有个常被误解的点应用侧的连接池只解决了客户端不频繁建连它完全没有改变服务端连接数线程数的等式。连接池把 500 次短连接变成了 500 个长连接服务端依然老老实实起了 500 个线程。换句话说连接池是客户端的一层缓存它压根没优化到服务端的线程模型。2.2 CPU 使用率 69% 里有多少是真正在跑 SQL方案里有个反复出现的指标组合值得盯CPU 平均使用率 69%、I/O wait 很小、90% 响应时间 1ms。乍看很健康但如果把 CPU 时间拆成 user 和 sys 两部分高并发下的 sys 占比往往会被拉高——上下文切换、锁获取、内存分配、系统调用全都算在 sys 里。真实的损耗来自三个方向。第一是调度延迟48 核上如果同时有几百个可运行线程每个线程拿到的 CPU 时间片被切得很碎一次请求在 CPU 上只跑了几十微秒就被换下寄存器现场和缓存行全部作废重新调度回来时 L1/L2 大概率已经冷了。第二是锁竞争线程多了全局的 LOCK_thread_count、连接链表、THD 相关结构上的临界区被争抢原本几十纳秒的操作可能被拉长到微秒级而且是串行化的。第三是内存分配压力每个连接线程都要分配自己的网络缓冲区、结果集缓冲分配器在高并发下也容易成为热点。这些开销有个共同特征——它们跟业务逻辑无关纯粹是为了支撑这么多个线程而付出的固定成本。请求越短这部分固定成本占比越高。方案里测的是主键点查一条 SQL 执行可能只有几十微秒此时线程调度开销几乎和业务处理开销同量级这就是为什么短查询场景对线程模型特别敏感。2.3 用利特尔法则反推为什么线程池后要 400 个并发才能喂饱先看方案里四组负载测试的实际读数场景客户端并发持续时长TPS90% 响应时间CPU网络流量线程池前四库80300s129871≤1ms69%40.125MB线程池前单库80300s131676≤1ms68%41MB线程池后四库100300s171907≤1ms69%52.25MB线程池后单库400300s250274≤2ms67%75.125MB这张表最容易被忽略的一列是客户端并发。线程池前80 个并发就能把单库压到 131676 TPS90% 响应时间 1ms 以内线程池后单库要开到 400 个并发才跑到 250274 TPS而且响应时间从 1ms 变成了 2ms。并发翻了 5 倍吞吐只涨了 90%响应时间翻倍——这看起来是变差了其实是把两种成本换了个位置。用利特尔法则Littles Law粗略估一下稳定状态下系统内并发数 ≈ 吞吐 × 平均响应时间。线程池前131676 TPS × 1ms 大约对应 132 个在途请求线程池后250274 TPS × 2ms 大约对应 500 个在途请求。这个数字说明线程池把请求在服务端排队这件事从线程调度器那里搬到了应用层的任务队列里。排队本身没有消失只是变得可观测、可限流、可调优。原来排队发生在 OS runqueue 里你只能看着 CPU 使用率干着急现在排队发生在插件自己的队列里你能看到队列长度、能看到哪个 group 堵住了这是从黑匣子到仪表盘的升级。反过来看如果只看吞吐不看响应时间很容易得出线程池提升 90%的乐观结论但真正的结论应该是线程池让服务端能用更少的线程承接更高的并发代价是单请求的最坏延迟被拉长尾延迟需要单独盯。3. 线程池插件怎么工作thread group、listener 与 worker 的三层分工3.1 thread group为什么按 CPU 核数切而不是搞一个大池子线程池最核心的结构是 thread group线程组。一个 group 本质上是一组 worker 线程加上一个属于自己的任务队列和一把属于自己的锁。默认情况下group 的数量与 CPU 核数相关配置参数 thread_pool_size 控制的正是这些线程组的数量它并不是总线程数。这个语义混淆是最常见的一类错误很多人把它当成池里最多开多少个线程直接填 50结果在一台 48 核机器上只开了 50 个组、每组按 1 个活跃 worker 加 oversubscribe 计算实际可并行处理的请求数远低于预期队列开始堆积。可靠的用法是让 thread_pool_size 跟核数对齐由每组内部的 worker 数量去控制实际并发。分组的意义在于隔离竞争。如果全库只有一个大池子那么每次有请求入队、每次 worker 取任务都要抢同一把锁核数越多、线程越多这把锁越热最终退化成串行。拆成 N 个组以后每个组只服务一部分连接锁的争用被摊薄到 N 份扩展性近似线性。这是一种典型的分片思路跟哈希表分段锁、连接池分片是一回事。连接和 group 的绑定关系一旦确定就不再随机漂移新连接进来时按某种策略常见是轮转或者按连接标识取模分配到一个 group之后就一直待在这个组里。稳定绑定带来一个好处同一个连接的请求总是落在同一批 worker 上CPU 亲和性和缓存局部性都能得到一定的保持。3.2 listener 与两级队列为什么老连接要比新连接优先每个 group 里都有一个 listener 线程它不负责执行 SQL只负责从网络事件里捞出可读数据、把任务塞进队列、并在必要时唤醒 worker。这个角色很像一个调度员它决定了谁先被处理。队列被设计成两级高优先级队列和普通队列。高优先级队列里放的是已经在这个 group 里处理过的连接发来的后续请求普通队列放的是新分配进这个 group 的连接的第一个请求。worker 取任务时优先清空高优先级队列只有高优先级队列空了才去碰普通队列。这么设计的动机很实在如果只用一个队列一个刚接入的新连接可能排在几百个老请求后面网络缓冲区越堆越满客户端开始重试重试又生成新连接雪崩就是这么起来的。给老请求加优先级等于保证已经在处理的会话不会被新流量饿死系统在高负载下仍然能维持部分请求快速完成而不是所有请求一起变慢。这个取舍在压测里的体现就是 90% 响应时间仍然能压在毫秒级——哪怕平均值已经被最坏的那批请求拉高分位数指标还是好看的。血泪经验是很多团队做容量评估只看平均 RT线程池上线后平均 RT 涨了、P99 涨了但 P50 没怎么动实际用户体验反而更稳定。3.3 stall limit 与 worker 唤醒阻塞的线程怎么不拖垮整组线程池最容易被忽视的机制是阻塞检测。worker 执行的 SQL 可能阻塞——磁盘 I/O、行锁等待、大排序落盘这些都可能在几毫秒到几秒内不返回。如果一个 group 里所有 worker 都卡在阻塞上队列再满也没人取整个组就死了。插件用 listener 做周期巡检判断这个 group 是否处于停滞状态队列里有任务但没有 worker 在推进。一旦超过阈值thread_pool_stall_limit单位是毫秒listener 就会唤醒或创建新的 worker 来接管队列。也就是说线程池里 worker 的上限并不是写死的常数而是在允许的过度并发范围内动态伸缩。这里有个参数值得单独说thread_pool_oversubscribe它定义了每个 group 允许的额外活跃 worker 数量常见默认值是 3。意味着在一个 group 里理想状态下只有 1 个 CPU 密集的 worker 在跑另外最多 3 个可以在阻塞状态中挂着。加起来相当于每组 4 个名额。这个数字不是拍脑袋定的而是经验上的折中——太少容易因为阻塞而饿死队列太多就退回到频繁上下文切换的老路。把 oversubscribe 和 group 数乘一下就能算出这台机器的实际并行处理能力上限。48 核、48 组、每组 4 个名额理论上限接近 192 个同时推进的请求。但绝大多数点查在 CPU 上只跑几十微秒压根到不了阻塞的程度所以真实吞吐远高于这个数字靠的是 worker 快速轮转而不是并发堆积。下面这段配置是把上面这些机制串起来的最小集合[mysqld] # 启用线程池调度替代默认的一连接一线程 thread_handling pool-of-threads # 线程组数量建议与 CPU 核数对齐不是总线程数 thread_pool_size 48 # worker 停滞判定阈值单位毫秒设太小会频繁创建线程设太大会掩盖阻塞 thread_pool_stall_limit 500 # 每个组允许的额外活跃 worker 数控制阻塞容忍度 thread_pool_oversubscribe 3 # 空闲 worker 的回收时间单位秒避免低峰期线程空占资源 thread_pool_idle_timeout 60 # 每个组允许创建的 worker 上限防止异常场景下线程爆炸 thread_pool_max_threads 100000参数之间是有联动关系的。thread_pool_size 决定横向分片数thread_pool_oversubscribe 决定纵向容忍度thread_pool_stall_limit 决定什么时候认为纵向不够用。三者配合不好就会出现两种极端组数太少锁竞争没解决stall_limit 太小短查询高峰时频繁创建线程等于把已经付清的成本又还回去了。3.4 状态观测线程池跑起来之后看什么线程池上线后不能只看 TPS还要看组内的实际状态。多数实现的插件会暴露一组线程池相关的状态变量可以直接查-- 查看线程池整体状态变量名以实际插件文档为准 SHOW STATUS LIKE Threadpool%; -- 关注项 -- Threadpool_threads 当前组内 worker 总数 -- Threadpool_idle_threads 空闲 worker 数 -- Threadpool_connections 当前绑定的连接数 -- Threadpool_stall_count 累计判定为停滞的次数 -- Threadpool_queue_length 当前排队任务数判断逻辑很直接。如果 Threadpool_idle_threads 长期接近 0说明 worker 不够用要么加组要么调 oversubscribe。如果 Threadpool_stall_count 持续增长说明大量请求卡在阻塞上得去查慢 SQL、锁等待或者磁盘 I/O这时加线程只会让问题扩散得更快。如果 queue_length 一直高位不降而线程数也上不去看看是不是 thread_pool_max_threads 触顶了。4. 压测方案本身的设计四段验证怎么把结论做扎实4.1 控制变量为什么这份方案的数据可信一套性能压测最怕的不是数字难看而是数字不可比。方案里做了几件事把变量压到最少软硬件配置固定数据库机与 4 台发压机都是 48 核 755G 内存、基础数据量固定20w、30w、120w、1w 四张表、压测脚本按现网最大请求体改造、参数化数据量按并发和时长准备、把日志级别从默认调整到 error 以减少写日志带来的额外 I/O。日志级别这一项特别容易被忽略。默认日志级别下每条 SQL 都可能触发格式化、写缓冲、刷盘压测打到十万级 TPS 时日志本身就是一个稳定的性能黑洞而且它会随并发线性增长把线程模型带来的差异完全淹没。把级别降到 error等于先把这条干扰项剪掉剩下的差异才归因得到线程池。压测工具用的是 JMeter通过命令行在负载机上跑# 非 GUI 模式压测结果写进 jtl 文件避免图形界面的资源开销影响发压能力 ./jmeter -n -t query_ability_attr.jmx -l logs/run_01.jtl # 压测结束后把 jtl 转成 HTML 报告看聚合报告、响应时间趋势、TPS 曲线 ./jmeter -g logs/run_01.jtl -o report_01/这三步的关键是-n——图形界面本身要吃掉一两个核在 5 台负载机规模下还好单机压测时它会让发压能力提前见顶导致你误判服务端的容量上限。jtl 文件是原始数据报告是二次加工排查异常时一定要回到 jtl 里看具体请求的分布而不是只信 HTML 页面上那个平均值。监控侧用的是 zabbix、JvisualVM 加 Linux 命令采样窗口从压测开始前 5 分钟就启动这样才能拿到基线而不是从第一条请求开始才有数据。这一点很多团队会漏不记录基线后面看到 CPU 稳定在 67%你没法判断这 67% 里有多少是数据库自己贡献的多少是机器上别的进程。方案里后面那条备注就踩在这个点上——数据库机上的其他应用常年占着 3% 的 CPU所有读数都要把这也算进去。4.2 阶梯加压40 一路加到失败找的是拐点不是峰值方案的加压方式是标准的阶梯式从 40 个并发流起步稳定运行 5 分钟后加 40再稳 5 分钟如此往复。每一档都检查五个门槛——CPU 和内存平均使用率不高于 70%、I/O wait 不高于 30%、网络带宽不超过 70%、业务成功率不低于 99.99%、90% 响应时间在 100ms 以内。任何一项破了就停定位问题。这套判据的价值在于它不是找最高 TPS而是找在健康水位下的最高 TPS。很多人压测只盯着 TPS 曲线的顶点可顶点往往出现在资源已经被打满、响应时间开始抬头的区间那个数字在生产上不能用。70% 这条线是有讲究的留 30% 余量给突发流量、给合并操作、给备份任务也留给内核自己的调度周转。I/O wait 卡在 30% 同理再往上说明存储层已经在排队这时候 TPS 再涨也是靠堆积换来的。找到拐点之后还有一步复压按最大能力的请求量再稳跑 5 分钟确认这个容量是可重复的而不是某一瞬间的抖动。这一步其实是在排除偶然因素比如刚好赶上一次 checkpoint 结束、刚好页缓存全热。4.3 四库和单库的差异暴露了瓶颈到底在哪一层把四组负载测试放在一起看会看到一个很明显的非对称四库场景下线程池把 TPS 从 129871 提到 171907涨了约 32%单库场景下从 131676 提到 250274涨了约 90%。同一个插件、同一套硬件为什么差距这么大答案在瓶颈位置上。四库场景下压力被分散到四个实例上单个实例分到的请求量少线程数没到临界点线程池能省的调度开销有限同时 4 台发压机的网络出口和 JMeter 自身的处理能力开始成为新的天花板网络流量只到 52.25MB 就能看出端倪——服务端还没跑满发压端先顶住了。单库场景正好相反所有请求砸在一个实例上线程调度成为最紧的那根弦线程池一上弦松了TPS 直接冲上 250274。这个差异说明一件很实用的事做线程池收益评估要在真正有瓶颈的场景下测。如果拿四库的数据去说服决策很可能得出提升 30%的保守结论如果拿单库的数据又会过于乐观。合理的做法是两个都测然后看你的生产拓扑更接近哪一种。4.4 12 小时稳定性为什么 52 线程只跑出 95000 TPS稳定性测试的设定跟负载测试完全不同52 个并发持续 12 小时TPS 稳定在 95000 左右90% 响应时间 ≤1ms成功率 100%CPU 使用率 60%网络流量 27.5625MB。这组数字比峰值低了一大截有人会问是不是没压到位。这正是稳定性测试该有的样子。它验证的不是能不能跑到极限而是长时间运行会不会劣化。判断劣化的依据是三件事TPS 曲线有没有随时间下滑、成功率有没有出现零星失败、内存和线程数有没有单调增长。如果 12 小时内 TPS 曲线始终保持水平说明线程池没有泄漏worker 的创建回收是闭环的如果有缓慢下滑通常是内存碎片、连接状态累积或者某些资源的引用计数没释放。52 这个并发值也是刻意选的方案里写的是不低于最优容量的稳定业务流——它不是峰值而是留了余量的可持续水位。用峰值跑 12 小时测出来的不是稳定性是耐久性两者目的不一样。4.5 那条32 组线程的补充数据讲的是组数不是越多越好方案末尾有一条容易被跳过的备注改成 32 个组、单库、150 并发5 台发压机各从 10 逐渐加到 50时TPS 达到 240000CPU 使用率 67%继续加并发TPS 和 CPU 的涨幅都不明显而并发数瞬间冲到 225、250 时CPU 使用率反而只有 60%。三个现象各自的解释不一样。TPS 到 240000 后不再涨是因为 32 组在 48 核机器上已经出现了明显的核间不均衡——有的组里 worker 排着队有的组闲着总体上 CPU 还有余量但任务分不到那些核上。CPU 在并发瞬间冲高时反而降到 60%是因为瞬时大量请求到来时线程创建和队列入队这些操作本身要抢锁加上请求还没真正开始执行CPU 采样窗口里看到的更多是等待而不是计算。这条数据最有价值的结论是thread_pool_size 不是越大越好也不是越小越好它要跟核数匹配。48 核配 32 组平均每组 1.5 核天然存在负载不均配 48 组一核一组分摊最均匀。超过核数以后多出来的组不会增加并行度只会增加调度实体和锁的数量。5. 避坑线程池上线前最容易栽的几个跟头5.1 参数语义类名字看着一样含义完全不是一回事把 thread_pool_size 当成线程总数填。现象是配置里写了 100监控上看到的 worker 总数远大于 100或者反过来队列一直堆积但线程数上不去。根因是这个参数在多数实现里表示线程组的数量而不是线程数量真正的单组并发由 oversubscribe 和其他参数共同决定。改法是先确认插件版本里这个参数的确切语义再按 CPU 核数设组数用状态变量验证实际 worker 数。把 stall_limit 理解成worker 最长等待时间。现象是把它设得很小比如几十毫秒压测时线程数剧烈波动CPU 的 sys 占比明显升高。根因在于它其实是判定组内停滞的阈值一旦超过就触发新线程创建设太小等于让插件不停地建线程销毁线程把省下来的成本又花出去了。改法是从几百毫秒起步观察 Threadpool_stall_count 的增长速率只有在阻塞确实频繁时才下调。忽略 thread_pool_max_threads 这个兜底上限。现象是某次慢查询集中爆发后进程线程数暴增内存跟着涨。根因是阻塞检测不断创建新 worker而上限设得过大或者干脆用的是默认大值。改法是按核数 × oversubscribe × 安全系数估一个合理上限宁可让队列堆积触发告警也不要让线程无限膨胀。5.2 场景适配类线程池救不了长查询在线事务里混着大查询上线后尾延迟恶化。现象是 P50 没变、P99 从几十毫秒跳到几百毫秒偶尔出现超时。根因是线程池的前提假设是单个请求在 CPU 上执行时间短一旦有请求跑几秒钟它占着的 worker 名额就一直不释放同组其他请求只能排队等 stall_limit 触发扩容扩容又带来新的竞争。改法是把长查询拆到独立实例或者只读副本上主库的线程池只服务短事务。把线程池当成解决连接数过多的办法。现象是开了线程池连接数依然往上涨最终撞上 max_connections。根因是线程池限制的是服务端线程数不是连接数每个连接依然要占 THD、要占缓冲区。改法是在应用侧做连接池上限控制服务端把 max_connections 当作硬性保护而不是容量目标。短连接场景下收益远低于预期。现象是业务方大量使用短连接每次请求建连断连线程池上线后 TPS 提升很小。根因是连接建立本身的开销认证、TLS 握手、THD 初始化没有被线程池覆盖线程池只优化了请求处理阶段。改法是先在客户端引入连接复用再评估线程池收益否则测出来的差异会被建连成本稀释掉。5.3 压测方法类发压端先撑不住测出来的是自己压测曲线在某个并发点突然变平误判为服务端容量上限。现象是并发加到某个值后 TPS 不再增长响应时间也不涨看起来像是服务端到了极限。根因很可能是发压机自己跑满了JMeter 的线程模型和 GC 都会吃 CPU5 台机器也可能不够。改法是同时监控发压机的 CPU 和网络出口如果发压机先到 70% 以上先加机器再谈结论。只看平均响应时间忽略分位数。现象是报告里平均 RT 只有 2ms看起来很美上线后用户投诉变多。根因是线程池把延迟分布拉长了平均值被大量快速请求压住长尾被掩盖。改法是把 90%、99% 分位数和最大值一起列进验收标准方案里定的90% 响应时间 ≤100ms就是往这个方向靠。测试数据量偏小缓存全命中。现象是压测 TPS 特别高生产上完全复现不了。根因是测试表只有几十万行整张表都在 buffer pool 里压根没有磁盘读。改法是按生产数据量级准备或者至少让工作集明显超过内存容量的合理比例方案里 120 万行这个量级就是有意为之。6. 什么时候不该开线程池一次容量决策的取舍清单线程池的收益来自请求短、并发高、线程调度成为瓶颈这三个条件同时成立。三个条件缺一个收益就会打折缺两个甚至可能为负。纯长事务场景比如批处理、大报表、全表扫描worker 一旦阻塞就是几秒到几分钟线程池的扩容机制会不断造新线程最后既不省资源也不提吞吐还不如让每个连接自己扛着。低并发场景同样没必要几十个连接的情况下 one-thread-per-connection 的路径最短、延迟最低加一层队列只是徒增一次入队出队。连接数长期稳定的场景比如只有几个后台任务在跑也不值得折腾。判断该不该开我一般看三个信号。第一压测中 CPU 使用率上去了但 TPS 不涨且 user 时间占比低于 sys说明时间花在调度和锁上。第二连接数随业务增长线性上升但 CPU 核数是固定的线程数与核数的比值已经超过一个数量级。第三短查询占比高单条 SQL 执行时间在亚毫秒到几毫秒之间。三个信号同时出现线程池的收益基本能兑现只出现一个先去做别的优化。验证方法也很具体同一套硬件、同一份数据、同一个脚本只切换 thread_handling跑两轮阶梯加压把 TPS、90% 分位数、CPU 的 user/sys 拆分、上下文切换次数vmstat 的 cs 列一起拉出来对比。如果 TPS 涨了但 cs 没降说明瓶颈不在调度上线程池只是碰巧撞对了一部分如果 cs 明显下降、TPS 同步上升那才是真正的机制生效。这个对比做完参数怎么调心里就有数了——组数向核数看齐stall_limit 从几百毫秒起步oversubscribe 保持默认观察 stall_count再按实际阻塞情况微调。后面这套动作我养成了一个习惯任何线程模型相关的改动上线前都要在同样的并发下把 cs 和 sys 占比各测一遍不看这两个数就下结论等于闭着眼睛调参。那份压测方案里 12 小时稳定性只跑 52 并发、CPU 稳在 60% 的做法我后来也一直沿用到自己的容量验收里——峰值决定你能接多少稳定水位决定你能撑多久两件事必须分开测。希望帮到你。本文还有配套的精品资源点击获取