上个月踩的那个坑
接手一个数据清洗接口,Python 写的,容器 4C8G。逻辑很蠢:从 Redis 读一次配置(约 2ms),调一次外部风控 API(约 30ms),然后写 MySQL(约 15ms)。压测单线程 QPS 21,P99 大概 62ms。
我第一反应就是上线程池。ThreadPoolExecutor(max_workers=64) 一把梭,QPS 直接冲到 480,P99 从 62ms 涨到 1.8s。当时觉得无所谓,P99 嘛,反正 QPS 上去了。结果第二天风控那边的人来找我,说你们请求量是平时的 20 倍,他们那边已经开始限流了。
后来把线程数降回 16,QPS 掉到 310,P99 回到 90ms 以内。再后来做了一件事:把那个 30ms 的风控调用结果按用户 ID 缓存进 Redis,TTL 5 秒。这一步做完,线程数降到 8,QPS 反而是 620。
这个事情给我最大的教训是:调线程数之前,先问一句这个等待时间能不能消掉。 大部分人(包括我)的第一反应是加线程,因为加线程不用改业务逻辑,一行代码的事。但它本质上是拿延迟和下游容量换吞吐,能撑多久取决于上游什么时候翻脸。
阻塞系数才是那个该算的数
后来我把那个老公式翻出来重新算了一遍:最优线程数 ≈ 核数 × (1 + 等待时间 / 计算时间)。
拿我那个接口套一下。单次请求总耗时 47ms,其中计算部分(JSON 序列化、参数拼装、打日志)大概 3ms,等待 44ms。比值是 14.7,核数是 4,算出约 63。这跟实测的 64 已经很接近了——说明 64 这个数字本身没选错,错的是我用它的前提。
但这里有个很坑的地方:这个公式假设等待是真的在睡觉。 如果等待是「线程在抢锁」或者「线程在等连接池」,那它就不是睡觉,是在空转。我那个服务后来就撞上了——64 个线程里同时只有 10 个能拿到 MySQL 连接(HikariCP 默认 maximumPoolSize 就是 10),剩下 54 个全卡在 getConnection() 上。
所以加了线程之后,一定要顺手看一眼连接池、下游限流阈值、Redis 连接数这些地方。不然就是你把闸门开大了,下游的管子还是那么细,水全积在中间。
IO 密集和 CPU 密集,差距比你想的大
在 8C16T 的机器上跑一段纯计算任务(100 万个对象的 SHA-256 加字段提取),Java 版本,实测数据大概是这样:
| 线程数 | 耗时(秒) | 加速比 | CPU 利用率 |
|---|---|---|---|
| 1 | 84.2 | 1.00x | 100% |
| 4 | 22.1 | 3.81x | 99% |
| 8 | 11.6 | 7.26x | 98% |
| 16 | 11.3 | 7.45x | 76% |
| 32 | 12.8 | 6.58x | 53% |
8 个线程的时候加速比已经很接近理想值了。到 16 线程,耗时只快了 2.6%,但 CPU 利用率从 98% 掉到 76%——多出来的那些线程没干活,在互相争抢 CPU 时间片。再到 32 线程,反而比 16 线程慢了 13%,这就是上下文切换的代价开始显形了。
对比一下前面那个 IO 密集的服务,4 核开了 64 个线程还能有正收益。差别在于:IO 密集的线程大部分时间在睡觉,操作系统把它们换出去不心疼;CPU 密集的线程一秒钟都在消耗时间片,多一个线程就多一份调度开销。
顺便说一句,如果你用 Python,纯计算场景就别折腾线程了。GIL 会把你的并行度锁死在 1,该用多进程就用多进程,concurrent.futures.ProcessPoolExecutor 或者直接上 multiprocessing。用 threading 跑 CPU 密集任务,除了让代码变复杂之外没有任何好处。
混合负载才是真正难调的那种
线上大部分服务既不是纯 CPU 密集也不是纯 IO 密集。比如一个网关,路由匹配和鉴权是纯 CPU,转发给后端是纯 IO。你要是用一个线程池同时扛两件事,就会出现:CPU 密集的那部分把线程占住了,IO 部分拿不到线程。两边的 P99 一起变差,你盯着监控也不知道该给谁加线程。
比较土但有效的做法是拆池子。CPU 密集一个池,core size 直接等于核数;IO 一个池,core size 按阻塞系数算。两个池的队列分开、监控分开、拒绝策略也分开。代价是线程总数变多,内存占用会上去一点(Java 默认每个线程 1MB 栈,可以调 -Xss256k 压下去)。
我之前一直觉得拆池子是多此一举,直到有次大促,一个 PDF 生成的任务把线程池占满了,导致同一进程里的订单查询接口全部超时。那次之后我才老老实实拆开。
一个可以照抄的调参流程
- 先测单次任务的耗时,拆成 CPU 时间和等待时间两块。Java 用 JFR 或者定时采样
Thread.getState(),Python 用py-spy dump看线程栈。这一步不做,后面全是猜。 - 套阻塞系数公式算个初值。把它当起点,别当答案。
- 从初值的二分之一开始往上调,每次乘 1.5,观察三个指标:QPS、P99、CPU 利用率。CPU 到 70%~80% 就停手,再往上大概率是负收益。
- 同步检查下游。连接池大小、下游限流阈值、对方接口文档里写的 QPS 上限。这一步最容易漏,也最容易出事。
- 上线后加个监控,盯线程池的 activeCount 和 queue.size()。队列持续不为 0 就是个信号,说明你现在的线程数扛不住这个量。
几个我具体踩过的坑
无界队列。new LinkedBlockingQueue<>() 不传容量,默认是 Integer.MAX_VALUE。任务堆积不会报错,等 OOM 的时候你才发现队列里躺了几十万个任务。建议传一个有界队列,饱和策略用 CallerRunsPolicy 或者干脆抛异常。
Spring 的 @Async 默认用的是 SimpleAsyncTaskExecutor,看名字像是池,其实是每次调用新建一个线程,根本没有池化。要么配 spring.task.execution.pool.core-size,要么自己注入一个 ThreadPoolTaskExecutor。这个坑我踩过两次,第一次是没配,第二次是配了但配在了错误的配置文件里。
只调了业务线程池,忘了 Tomcat 的工作线程。Tomcat 默认 max-threads 是 200,如果你的业务池比这个大,那说明前面的调优全白做了,请求根本进不来。
最后说两句
线程数这个东西没有普适值。同一个服务,换台机器、改条 SQL、加个缓存,最优值就变了。我现在更倾向于把它当成一个跟着监控走的参数,而不是调一次就写死在配置文件里。
还有个感受:绝大部分「加线程就好了」的场景,本质上是下游有个慢东西没解决。加线程有效,但那是借来的时间,迟早要还。