1. 先搞清楚这几个指标到底在衡量什么
如果你维护过任何一套线上系统,迟早会跟这四个词打交道:QPS、吞吐量、响应时间、并发数。只要业务流量一上来,监控图上最先报警的几乎都是它们;在技术讨论里被混用次数最多的,大概率也是它们。很常见的一幕是:线上接口变慢了,有人说是QPS太高,有人说是并发扛不住,还有人直接说系统吞吐量不行。这几个说法听起来都像那么回事,但又都不够准确,最后容易讨论半天也找不到真正的瓶颈。
这篇内容就围绕这套指标展开,讲清它们各自的定义、之间的定量关系,以及真正提高吞吐量时该按什么顺序动手。适合后端开发、运维以及做架构设计的人参考,也是我踩过不少坑之后整理出来的经验。
1.1 别再把QPS和吞吐量混为一谈
先解决最普遍的一个误解:QPS和吞吐量经常被当成同一个东西,但严格来说它们的口径不一样。
QPS全称是Queries Per Second,最早更多出现在数据库场景里,指每秒执行的查询数。现在业务系统里说到QPS,一般就直接代表每秒请求数,是一个明确的“速率”概念。比如你看到监控上写着“QPS=500”,含义是这一秒有500个请求打到了服务上。
吞吐量(Throughput)是一个更宽泛的概念。它能用每秒事务数(TPS)来衡量,也能用每秒请求数来定义,还可以是每秒处理的字节数或者消息条数。具体用哪种单位,取决于你在衡量哪一层。数据库工程师聊吞吐量时可能看每秒执行的SQL次数,网关看每秒转发的连接数,消息队列看每秒进出多少条消息。哪怕针对同一个系统,“吞吐量”的数字也会因为计量单位不同而不同。
所以更准确的沟通方式是:把QPS理解成吞吐量的一种常见表达形态,二者不能无条件画等号。你在跟别人确认问题时,最好先问一句:“你说的吞吐量是按请求数算,还是按事务数算?”很多线上事故的误判,就是栽在这种口径不一致上。
1.2 响应时间和并发数是彼此的“影子”
响应时间(Response Time)是最直观的指标,一个请求发出去,到完整响应返回,中间消耗的时间就是它。但真正到排障时,只看平均值是不够的。某接口平均响应时间200ms,听着不错,可P99可能已经到了1.5s,这意味着每一百个请求里就有一个会明显超时。对于用户来说,那个超时请求的感受才是真实的。所以评估响应时间要以百分位为准,P95和P99比平均值更有参考价值。
并发数则代表系统在某一时刻正在处理的请求数量。它跟响应时间的关系可以粗略理解成“影子”关系:响应时间越长,同一个线程处理单个请求的周期就越长,在每秒新请求数不变的情况下,同时“在途”的请求自然就越多。数据库连接池打满、线程池排队,本质上是并发请求在资源上堆积了。
很多人以为高并发是“来了很多请求”造成的,其实有不少情况是响应时间变慢,导致系统不得不长时间占用线程和连接,才显出并发很高的样子。一个系统能同时处理多少请求,不完全取决于“来了多少”,而是取决于“每个请求占多久”。
1.3 一次排障里怎么用指标定位瓶颈
举一个典型的排障过程来说明。某天突然有用户反馈页面转圈,监控显示某核心接口的P95响应时间从原来的300ms涨到2s,QPS其实没有明显变化,只是从800升到了900。这时如果把问题归结为“请求量太大扛不住”,很可能就误导了后续方案。
正确做法是按数据逐层排查。先看QPS涨幅,900对系统来说不算突然,排除突发流量。再看平均并发数,用下一章会讲到的公式估算:平均响应时间约1500ms时,在途请求数大约是 900×1.5 = 1350,这个数对线程池和数据库连接池都是压力。然后看数据库监控,发现慢查询数量陡增,某个SQL的扫描行数从几万涨到了几百万,原因是用户一次多选了几个筛选条件,导致索引失效。
到这里瓶颈就清楚了:不是流量压垮了系统,而是一条写得不稳的SQL把响应时间拖长,进而推高了并发占用。优化SQL后,P95回落到350ms,吞吐量和并发数自然都回到了正常区间。这个案例也是全篇最想强调的一个理念:指标之间会互相传导,不要只看表面最高的那个数。
2. 指标之间的定量关系与现场测算方法
只理解定义还远远不够,做性能优化需要有“算账”的能力。系统能支撑多少并发、目标QPS对应的平均响应时间预算应该是多少,这些都能通过一个简单的公式推算。现场没有完整压测工具的时候,也可以靠日志和监控里的有限数据摸出一个大致结论。
2.1 并发数 = QPS × 平均响应时间:这条公式怎么用
在稳定的负载区间内,系统内部的请求满足一个经典关系:在途请求数 = 每秒流入的请求数 × 单个请求的平均处理时间。用字母表达就是 L = λ × W。这里的λ相当于QPS,W是平均响应时间(单位秒)。
举个例子。某个接口平均响应时间是100ms,目标QPS是500。那么系统同一时刻需要容纳的并发请求数为 500 × 0.1 = 50。如果线程池只开了40个线程,必然有10个请求在排队,响应时间会开始劣化;如果数据库连接池只有30个连接,那么剩下的20个请求只能干等连接释放。
这个公式的工程意义有两个方向。正向用:给定目标QPS和可接受响应时间,算资源规模。反向用:监控上看到当前并发数,倒推当前实际处理能力和响应时间是否匹配。比如监控显示服务的活跃线程数长期在200,平均响应时间400ms,那么实际吞吐量大约就是 200 / 0.4 = 500 QPS。如果这个数字远低于业务目标,说明单请求耗时太长,或者资源分配不合理。
注意这只是一个稳态估算,真实系统会有波动。并发数要有余量,不能刚好卡在计算值上,否则一个响应时间的小抖动就会引发排队风暴。通常我会按计算值的1.2到1.5倍去准备线程或连接。
2.2 没有压测环境时的粗略估算
不是所有系统都有条件随时做全链路压测,但线上数据总是有的。我常用的笨办法是结合三类数据估算:
- 从网关或接入层日志,统计最近一小时的请求总量,除以3600,得到平均QPS。
- 从监控面板读该服务的平均响应时间和P99响应时间。
- 从运维监控读进程活跃线程数、数据库活跃连接数,找到并发占用情况。
有了这三个数,代入上一节的公式,能快速判断当前系统的容量余量。例如你观察到QPS峰值是800,平均RT是250ms,那么高峰期系统在途请求约200个。如果线程池上限是400,说明还扛得住;如果线程池已经长期在350附近波动,意味着加一点流量就会开始排队。
这种估算的误差主要来自对“平均值”的依赖。线上QPS和响应时间都有波峰波谷,用简单平均值容易低估压力。我会把峰值QPS和P99响应时间再代入一遍,得到一个相对冒进的并发数,然后以这个数作为容量规划的上界。虽然不够精确,但足够用来判断要不要提前扩容。
2.3 压测时到底该盯哪些数字
有条件压测时,很多人只盯着压测工具最后输出的Requests/sec,看到数字涨了就觉得系统变快了。这样容易忽略两个关键维度:延迟分布和错误率。
同样跑出每秒1000个请求,如果P99从200ms变成800ms,说明系统已经进入排队饱和区,再压下去随时可能雪崩。我会按下面的顺序记录数据:
- 每个阶段的QPS与吞吐量:确认是否还有上升空间。
- 平均RT、P95、P99:观察延迟曲线是否出现突然上扬。
- 超时数、5xx比例、连接错误数:一旦错误率超过0.1%就要停掉这轮压力。
- 服务端资源:CPU、内存、GC、线程数、连接池使用率、磁盘IO等待。
压测目标不是压出一个好看的数字,而是找到系统从“线性增长”到“开始排队”的拐点。在拐点之前,QPS上去、延迟基本平稳;过了拐点,QPS几乎不再涨,延迟和错误却飞速上升。这个拐点对应的并发能力,才是你需要写进容量报告里的参考值。
3. 提高吞吐量的思路:从单请求优化到架构演进
明白了指标关系,接下来进入最实际的问题:怎么把吞吐量提上去。我的基本主张是“按成本从低到高动手”,先用最少的改动把瓶颈解除,不要一上来就搞架构改造。下面这几条思路是按我自己的实践顺序整理的,每一步都有明确目的。
3.1 先减后加:降低响应时间为什么是第一步
提高吞吐量最直接的手段不是猛加机器,而是让单个请求更快结束。从公式 L = λ × W 可以直观看到:平均响应时间W降下来,同样数量的线程和连接就能支撑更高的吞吐量λ。
说到这里我想起一次真实改动。某个订单查询接口原本会把一个月内某店铺所有订单都查出来,再在应用层分页返回。数据量一大,SQL扫描行数以百万计,平均RT到了2s,每秒只能处理几十个请求。改动其实很小:把SQL改成条件分页,先查当前页的ID,再回表查明细;同时只返回前端需要的字段,去掉大字段的传输。改完后平均RT降到180ms,吞吐量直接翻了接近十倍。
这个例子就是想说明:应用层代码、SQL写法、接口协议里隐藏的性能浪费,往往比架构问题更容易拖垮吞吐量。所以在考虑扩容之前,先找找有没有这类“明显不合理”的开销。具体可以检查三类点:是否有全表扫描或索引失效的SQL、是否有不必要的序列化和大字段返回、是否存在串行调用的远程接口。
3.2 让系统同时处理更多请求:线程、连接与IO模型
单请求耗时优化到一定程度后,下一步是提升系统同时处理请求的能力,也就是把并行度做上去。
并行度首先受线程池影响。CPU密集型任务,线程数可以接近CPU核心数,再多只会增加切换开销;IO密集型任务,线程数通常按核心数的2倍起步,具体要看阻塞时间比例。很多团队的线程池设置只有一句话:“把maxThreads调大”,结果调大后吞吐量没涨,响应时间反而恶化,因为上下文切换吃掉了CPU。比较合理的做法是先按经验公式设定一个初值,再通过压测微调。
连接池是另外一个容易忽略的并行度瓶颈。数据库连接、缓存连接、下游RPC连接,任何一个池被打满,都会让请求卡在等待连接的环节。连接池不是越大越好,数据库侧能支撑的连接数、连接获取成本和事务时间共同决定了合理范围。比如一个事务平均占库500ms,如果想支撑500TPS,同一个数据库实例上至少需要约250个连接在同时运行;如果数据库配到了这个连接上限,继续提高并发只能靠缩短事务时间或减少事务数。
IO模型上的选择也会明显影响吞吐量。传统的每请求一线程模型在连接数和读取请求上都会浪费大量内存和切换成本;换成基于事件循环的IO模型后,同样一台机器能支撑的连接数可以高出一个数量级。这不意味着所有系统都必须马上改造,但新项目选型时值得把这笔账算进去。
3.3 缓存、异步和限流:吞吐量稳定性的三件套
并行度做足了,还得保证系统在高流量下“不突然变慢”。这里我有三件实用的工具:缓存、异步、限流。
缓存解决的是重复计算问题。同一份热点数据被并发请求反复读取时,每次都去数据库查并重新加工一遍,既浪费也慢。把热点数据放到缓存里之后,穿透到数据库的请求数量会大幅下降,响应时间也由几十毫秒降到几毫秒,吞吐量自然上一个台阶。缓存有几个常见坑:缓存穿透(查询根本不存在的数据,每次都打底层存储)、缓存击穿(热点key失效瞬间大量请求同时打到底层)、缓存雪崩(大量key同时失效)。针对穿透可以用空值缓存或布隆过滤器;针对击穿可以用互斥更新或逻辑过期;针对雪崩要错开过期时间。
异步解决的则是“慢操作占据请求链路”的问题。比如短信通知、报表生成、外部渠道同步这类动作,如果都在请求里同步执行,用户响应时间会被拖到几百毫秒甚至更久。把这类操作改为异步处理:请求进来后立刻写入消息队列并返回“已受理”,由后台消费任务慢慢执行。这样前端接口的响应时间变短,单位时间内能完成的请求量也随之增加。
限流看起来跟“提高吞吐量”矛盾,实际上它是保护吞吐量的手段。当流量超过系统承载上限时,如果不加控制,所有请求都排队超时,最终正确的是“全部失败”。如果做一层限流,宁可拒绝一部分请求,也要让系统在剩余容量内保持低延迟和稳定吞吐。限流可以采用简单计数、滑动窗口、漏桶或令牌桶等不同算法。令牌桶允许一定程度的突发流量,同时限制平均速率,我更常用。
3.4 什么时候该做架构层改造
代码优化、缓存和异步都做完了,单机容量依然不够时,才轮到架构层改造。常见的路线是:
- 多实例水平扩容:前面挂负载均衡,多台服务共同分担流量。
- 数据层拆分:先读写分离,再把高频数据分库或分表。
- 引入独立中间件:用缓存集群、消息队列、搜索引擎等把压力从核心数据库分摊出去。
水平扩容最大的敌人是“状态”。如果用户会话、临时数据都放在本地内存,扩容后就会出现请求落到不同实例时数据不一致的问题。所以扩容之前要做无状态化改造,或者引入集中式会话存储。数据库拆分更要谨慎,分库分表带来的分布式事务、跨节点查询和主键策略问题会显著增加开发复杂度。我的原则是:能通过缓存和索引解决的,不碰分库;能通过读写分离解决的,不碰分表;分表只对真正持续增长且高频访问的核心数据做。
4. 一个查询接口的吞吐量优化实录
理论铺垫到这里,我用一个模拟场景把完整优化过程串起来。假设有一个交易查询接口,线上压测成绩是:QPS峰值100,P95响应时间800ms,数据库连接池经常打满。目标是把它优化到支撑800以上QPS,同时P95降到300ms以内。整个过程一共四步,每一步都有可复现的操作方法。
4.1 摸底:先用数据确认瓶颈在哪
无论接到什么性能任务,第一件事永远是“看数据”而不是“猜方案”。我先把问题拆成几条线索:
- 用压测工具对接口跑了5分钟固定压力,记录当前QPS、RT分布和失败率。
- 打开数据库慢查询日志,看两个小时内执行时间超过1s的SQL。
- 观察监控面板里服务线程池活跃数、数据库连接池使用率、GC频率。
- 顺手抓了一次线程转储,看看请求卡在哪个方法调用上。
结果很清楚:慢查询日志里出现一条SQL,过滤条件包含一个未加索引的字段,单次执行扫描行数近500万。连接池监控显示使用率长期在95%以上,说明几乎所有请求都在等待数据库连接。线程转储里大量线程阻塞在获取数据库连接的方法上。
到这里我基本确定了优化方向:先消灭这条慢SQL,再看连接池能不能撑到目标QPS。如果SQL不优化,单纯扩大连接池只会让数据库CPU继续飙高,问题不会消失。
4.2 第一步改动:SQL和索引最便宜也最见效
针对那条慢SQL做了三件事。
第一,分析执行计划,确认索引确实没被用上。然后新建了一个联合索引,字段顺序按照查询条件的区分度从高到低排。建完索引后重新看执行计划,扫描行数从500万降到1万级别。
第二,把SELECT中的字段列表收窄,之前是把整行所有列都取出来,其中包含几个大文本列。改成了只查询当前列表页需要的核心字段。
第三,调整了应用层的查询逻辑。原先是一次查出全量结果到内存再分页,改为先查当前页ID,再用主键条件查详细信息,这样数据库端只需要处理当前页的数据量。
这三处改动上线后,单次接口平均RT从800ms降到220ms,P95降到380ms。在同样资源下压测,QPS从100涨到了350左右。这一步改动的成本极低,也只动了SQL和一行查询逻辑,收益却非常明显。
4.3 第二步改动:给热点数据加缓存
SQL优化之后,接口本身已经不慢了,但数据库的查询压力仍然很大。下一个目标是减少真正到达数据库的请求数。
看接口数据的特征,发现查询结果有很强的时效性要求,但对一致性要求不算苛刻,允许最多几十秒的延迟。适合做缓存处理。具体方案是:把当天的热门交易列表按查询条件生成一个缓存key,第一次查询后把结果写入缓存,后续请求先读缓存,未命中才回到数据库。
这里我把缓存穿透和击穿都预防了一下。穿透方面,对于查询结果为空的情况也做了短暂的空值缓存,避免恶意或异常的无效查询绕过缓存直接打数据库。击穿方面,当热点key失效时,不直接在业务代码里同步回源,而是用一个互斥锁保证同一时刻只有一个请求去数据库加载,其余请求短暂等待后读到新缓存。
这一轮优化之后,数据库侧的读QPS从峰值300降到了不到50,接口的P95进一步降到90ms左右。整体吞吐量压测可以稳定跑到650 QPS,而且数据库连接池使用率降到了40%上下。
4.4 第三步改动:让连接池和线程模型匹配业务
到这一步,数据库已经不再是瓶颈,系统重新暴露出新的限制:服务端的线程池设置过小,而且有几个下游远程调用的响应偏慢,拖住了请求线程。
原来线程池最大线程数是120,压测到650 QPS时线程池已经接近打满,活跃线程数长期在110。我在压测过程中逐步上调线程数,分别测了150、200、250三组配置,观察吞吐量和延迟变化。最终在200线程附近取得平衡:QPS在700-800之间,P95在220ms附近;继续加大到250时,CPU上下文切换明显变多,P95反而升高到350ms。
连接池配置也做了调整。数据库连接数从50提高到80,同时把连接获取的超时时间从1s缩短到500ms。这样即使有个别慢SQL或短时抖动,线程不会傻等太久,能更快失败并触发降级逻辑。
这轮调整完成后,压测成绩稳定在800 QPS以上,P95约为220ms,满足预期目标。整个改造过程没有引入新的中间件,也没有调整服务数量,纯粹靠SQL优化、缓存和资源配置调整完成。
5. 常见问题与排查技巧实录
无论指标定义了解得多清楚,实际排障总会遇到一些反常识的现象。下面几个问题是我在最常被问到的场景,每次遇到都有固定套路去处理。
5.1 CPU不高但吞吐量上不去
这类问题最迷惑人。明明CPU还有大量空闲,压测的QPS却上不去,延迟还在涨。一般先怀疑三点。
第一,是不是锁竞争。看线程转储里有没有大量线程处于BLOCKED状态,等待同一个锁。如果有,说明并发请求在争抢共享资源,解决办法是缩小锁范围、改用读写锁或分片锁。
第二,是不是IO等待。数据库连接池、下游HTTP连接池、磁盘IO都会让线程长时间等待。看监控里的连接池使用率和IO等待时间,八成能定位到具体资源。
第三,是不是GC拖后腿。GC日志如果显示频繁发生Full GC,说明堆内存或对象分配有问题。大量短暂对象在新生代不断晋升,会导致GC停顿占用CPU。调整堆大小、优化对象分配,比提升线程数更有效。
5.2 并发数一高响应时间就飙升
这通常是系统进入“饱和拐点”的典型信号。每秒请求量没有暴涨,但响应时间和并发数同时升高,形成了恶性循环。我一般按下面的顺序判断:
先看线程池队列积压情况。队列从0涨到几百时,说明线程处理不过来了。再看线程切换次数和CPU系统态占比,如果系统态占用很高,说明线程太多,切换开销压过了有效工作。最后看GC频率。这三个数据能解释90%的响应时间劣化。
解决手段通常是:要么降低单请求耗时,要么限制入口流量。如果系统已经过载,硬撑没有意义,先限流让队列清空,同时把慢请求降级成快速失败,保护整个系统的不再恶化。
5.3 加了机器吞吐量却没变
听起来很气人,以为水平扩容就能提升吞吐量,结果机器翻倍、监控纹丝不动。这类问题十有八九是“瓶颈不在无状态服务层”。
最常见的是数据库或缓存成为全局瓶颈。无状态服务加到十台,所有请求却还是打到同一个数据库,数据库的CPU和连接数早在三台机器时就已经满了。这种情况下加服务等于原地踏步。要先观测数据访问层,确认数据库连接数、慢查询数和CPU使用率。如果数据库已经满载,扩容服务没有任何意义,应该先扩展数据库的读写能力。
另一个常见原因是负载不均衡。如果新增机器的流量分配不均,可能部分机器还在空闲、部分机器已经过载。检查负载均衡策略是否按实际权重分发,同时确认服务注册发现是否真的把新实例纳入了。
5.4 常见问题速查表
把几个高频场景整理成表格,方便快速对照。
| 现象 | 可能原因 | 优先排查项 | 常用解法 |
|---|---|---|---|
| CPU高、QPS低 | SQL扫描行数过大、死循环、GC频繁 | 慢查询日志、GC日志、执行计划 | 优化SQL、加索引、调整GC参数 |
| CPU低、QPS低 | 连接池满、锁竞争、远程调用慢 | 连接池监控、线程转储、下游耗时 | 调整池大小、减少锁粒度、异步化 |
| 响应时间飙高 | 线程池排队、GC停顿、资源争抢 | 队列长度、GC耗时、系统态CPU | 限流、扩容、优化创建对象频率 |
| 加机器无提升 | 全局瓶颈在数据库或缓存 | 数据层CPU、连接数、缓存命中率 | 读写分离、缓存前置、分库分表 |
| 吞吐量波动大 | 热点key失效、流量倾斜 | 缓存击穿记录、各实例QPS分布 | 逻辑过期、热度均衡、限流降级 |
最后分享一条算不上总结的个人体会:处理性能问题时,我习惯先把所有指标统一到“在途请求数 = 到达率 × 处理时间”这个框架下看一遍,谁的数据明显不合理,谁就是突破口。多数情况下,吞吐量上不去不是某个单一组件不行,而是某个请求或某条链路把自己的资源占用拉高,把整个系统的余量吃掉了。你不需要一开始就懂特别高深的理论,先把慢请求变快,把多余请求挡在门外,再把并发资源调到合适大小,这套组合拳足以解决大多数线上性能问题。