简历写「QPS 提升 3 倍」,面试官问「怎么压测的」,我卡在并发数怎么定
模拟面试里最常出现的一幕是这样的。
我问:「你简历上写了『优化后 QPS 提升 3 倍』,这个压测是怎么做的?」
对方答得挺快:「用压测工具打,最后跑到 1300 多 QPS。」
我接着问:「并发数怎么定的?」
他愣了一下,说:「就……设了个比较大的值。」
后面聊了什么已经不重要了。「设了个比较大的值」这句话,在面试官耳朵里等于另一句:这个数不是我算出来的,是我碰出来的。
他其实做过优化。后来让他把当时的压测记录翻出来,工具、时长、机器配置都在,唯独没有一栏写并发数。他前后跑了几十次,每次调的是参数大小,不是档位。所以到最后,他能报出来的只剩一个孤零零的数字。
那次之后我把他接下来会被追问的五句话,连同每句话该怎么接,整理了一遍。就是下面五节。
一、「怎么压测的」:他问的不是工具名,是复现条件
先拆一个误解。
面试官问「怎么压测的」,通常不是在怀疑你造假。他想知道的是:这个数字是一次随手跑出来的,还是你在一组固定条件下复现出来的。
随手跑出来的数长这样:找台机器,起个压测工具,参数往大了填,跑 60 秒,把屏幕上那个 QPS 记下来。
复现出来的数长这样:并发档位先定死,观测口径先定死,机器和数据量先定死,然后改一处、跑一次、记一行。
差别在哪儿?前者你只能说出「提升 3 倍」这四个字,后者你能说出「并发 200 那个档位上,从 430 提到 1290」。
(以上均为示例数字,用来说明口径关系。)
第二句能接住追问,第一句接不住。
1300 那个数也不是凭空来的(示例),它在并发 800 的档位上确实跑得到。问题有两个:同一档位的 P99 是 1.8 秒,已经进到排队区了;而且这一档算出来的倍数是 2.5,不是他简历上写的 3 倍。他报的是自己压过的最大值,恰好不是最漂亮的那一档。
二、「并发数怎么定的」:真正的拦路虎在这儿
这句是全篇最难接的一句,也是最多人卡住的一句。
有个公式能帮你开口:
并发数 = QPS × 平均响应时间
比如目标是撑住 1000 QPS,接口平均耗时 150 毫秒(均为示例),那并发就该定在 1000 × 0.15 = 150 上下。你填 800,拿到的数字里有一大半是排队时间。
但别把这个公式当成最终答案,它只给你一个量级起点。原因是响应时间本身就是并发的函数,负载一上去它就变。所以正确的用法是:先按 150 起跑,测出实际的响应时间,再代回公式修正,一档一档往上爬。
这里还有一层容易搞混的东西:压测工具分两种控制方式。
一种是开环,控的是每秒发出多少请求(vegeta 加 -rate=N 就是这种),并发是跑出来的结果,你没法直接设。另一种是闭环,控的是同时挂着多少请求(vegeta 用 -rate=0 配合 worker 数,wrk 用 -c),并发才是输入。
你要回答的是并发怎么定,那就得用闭环那一档。这个区别说出来,本身就是加分项。多数人只用过开环工具,压完报的是 QPS,被问到并发只能去回忆工具的默认值。你能说清自己是按闭环档位一档一档跑的,就已经比多数候选人多走了一步。
梯度爬坡,才是定并发的正解
真正做压测没人只压一个档位,都是从低到高一档一档加,看 P99 在哪一档开始明显抬升。下面这组示例,跑在 4 台机器上(示例环境):
| 并发数 | 实测 QPS(示例) | P99 延迟(示例) | 错误率(示例) |
|---|---|---|---|
| 50 | 128 | 620 ms | 0% |
| 100 | 253 | 700 ms | 0% |
| 200 | 430 | 1.75 s | 0.3% |
| 400 | 520 | 3.6 s | 2.1% |
| 800 | 528 | 6.2 s | 5.8% |
拐点在 200。并发从 100 加到 200,QPS 只涨了七成,P99 却从 700 毫秒跳到 1.75 秒。再往上加,QPS 基本不动了,全是排队。
这个 200 才是你该写进简历的那个档位,不是压测工具里填过的最大值。
用 vegeta 跑梯度,命令长这样
for c in 50 100 200 400 800; do
echo "== concurrency=$c =="
echo "GET http://127.0.0.1:8080/api/chat" \
| vegeta attack -rate=0 -workers=$c -max-workers=$c -connections=$c \
-duration=60s -timeout=30s \
| vegeta report -type=json \
| jq -c --argjson c "$c" '{concurrency:$c,
qps:.rate,
p99_ms:(.latencies."99th"/1000000),
err_rate:(1-.success)}'
done
四个参数各有分工:-rate=0 关掉限速(否则是开环,并发不受你控制);-workers 是起跑的 worker 数,-max-workers 是上限,两者都设成档位值,并发就固定在这一档;-connections 是连接池大小,要大于等于档位值,否则反复建连,测到的是握手开销。
最后那个 jq 里有两处容易踩:.latencies."99th" 的单位是纳秒,得除以 1000000 才是毫秒;错误率没有现成字段,要用 1-.success 自己算。
顺带说一句,-timeout 必须和 P99 一起报。超时阈值设得太紧,长尾请求会被直接掐断,你看到的 P99 其实是「被截断之后的 P99」。
想自己写,Go 版本也不复杂
package main
import (
"fmt"
"io"
"math"
"net/http"
"os"
"sort"
"sync"
"time"
)
// press 在一个固定并发档位上持续压测,输出 QPS / P99 / 错误率
func press(cli *http.Client, url string, concurrency int, duration time.Duration) {
var (
mu sync.Mutex
latency []time.Duration
total int
failed int
)
sem := make(chan struct{}, concurrency) // 并发上限就是这个 buffer
deadline := time.Now().Add(duration)
var wg sync.WaitGroup
for time.Now().Before(deadline) {
sem <- struct{}{} // 拿不到就阻塞,并发自然卡在这一档
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
fail := false
start := time.Now()
resp, err := cli.Get(url)
if err != nil {
fail = true
} else {
// 耗时要含 body 读取,否则和压测工具的口径对不上
if _, cerr := io.Copy(io.Discard, resp.Body); cerr != nil {
fail = true
}
resp.Body.Close()
// 5xx 也是失败,只判 err 会把服务端错误全算成成功
if resp.StatusCode >= 400 {
fail = true
}
}
cost := time.Since(start)
mu.Lock()
total++
if fail {
failed++
} else {
latency = append(latency, cost)
}
mu.Unlock()
}()
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
if total == 0 {
fmt.Printf("并发 %d:无有效样本\n", concurrency)
return
}
sort.Slice(latency, func(i, j int) bool { return latency[i] < latency[j] })
p99 := time.Duration(0)
if len(latency) > 0 {
idx := int(math.Ceil(0.99*float64(len(latency)))) - 1
if idx < 0 {
idx = 0
}
p99 = latency[idx]
}
fmt.Printf("并发 %d:QPS %.0f,P99 %v,错误率 %.2f%%\n",
concurrency,
float64(total)/duration.Seconds(),
p99,
float64(failed)/float64(total)*100)
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: press <url>")
os.Exit(1)
}
url := os.Args[1]
// 不设 Timeout,下游不响应会把 goroutine 全卡死,连 deadline 都判不到
cli := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
// 每 host 默认只留 2 条空闲连接,高并发下测到的是握手开销
MaxIdleConnsPerHost: 800,
},
}
for _, c := range []int{50, 100, 200, 400, 800} {
press(cli, url, c, 60*time.Second)
}
}
这段代码的用处不在压测本身,在于它逼你把「并发档位」写成一个显式参数,每跑一次都得回答一次:这一轮并发是多少。
有个坑我在这里一并说了:失败请求不进分位数统计,所以错误率高的那一档,P99 是偏乐观的。这也是为什么错误率必须和 P99 一起报,单独报一个都站不住。
三、「凭什么说它稳」:看 P99,别看均值
第三句追问追的是稳定性。
均值在这个场景里几乎没有解释力。一千个请求里九百九十个是 100 毫秒,十个是 10 秒(示例),均值还是 199 毫秒,看着挺好,但那十个用户已经跑光了。所以要看 P99,看那个最慢的百分之一有多慢。
三个数要一起报:P99、错误率、超时阈值。缺一个,面试官就有现成的下一句。
超时阈值这一项最常被漏。它和 P99 必须成对出现,单报一个等于没报。
还有一个常在这句上被问倒的点:P99 不能把每台机器各自的 P99 取平均,那个结果没有任何数学意义。要合并原始分布之后再算分位,比如各实例统一上报固定分桶的计数,汇总之后再找分位点。这句话说出来,这一路追问基本就止住了。
四、「这数在你环境里成立吗」:数据落点
第三组口径最容易整段漏掉:机器和数据量。
同样是 1290 QPS(示例),跑在 4 台机器上和跑在 8 台机器上,含金量不一样;压 500 万条数据和压 1000 万条数据,也不是一回事。你不说,面试官要么自己脑补,要么直接问,而问到这一步你再答什么都晚了。
这组条件还有个副作用:它逼你在动手优化之前就把环境固定下来。很多人是先优化、后补测,前后两次压测的机器和数据量都不一样,两个数字根本没法比,最后只能报一个说不清来路的倍数。
写简历时我要求自己写全三个条件:几台机器、什么规格、多少数据量。比如「4 台 8C16G、数据量 500 万条」。加上前面两组的工具、档位、时长、P99、错误率、超时,一共九项条件。
九项看着多,写下来只多一行字,换来的是这一路追问全线封死。
五、「所有档位都是 3 倍吗」:单次只改一处
最后这句是杀招,也是最能看出你有没有真做过的一句。
优化前后必须在同一档位上比,而且每次只动一个变量。下面这组是示例数据:
| 并发档位 | 优化前 QPS(示例) | 优化后 QPS(示例) | 提升后倍数 | 优化前 P99(示例) | 优化后 P99(示例) |
|---|---|---|---|---|---|
| 50 | 128 | 320 | 2.5 倍 | 620 ms | 260 ms |
| 100 | 253 | 640 | 2.5 倍 | 700 ms | 280 ms |
| 200 | 430 | 1290 | 3.0 倍 | 1.75 s | 460 ms |
| 400 | 520 | 1310 | 2.5 倍 | 3.6 s | 920 ms |
| 800 | 528 | 1320 | 2.5 倍 | 6.2 s | 1.8 s |
看明白了吗?那个 3 倍只在 200 档成立,其余档位都是 2.5 倍。
为什么偏偏是 200?因为优化前 200 档已经进排队了,P99 从 700 毫秒跳到 1.75 秒,错误率也开始出现。优化把排队消掉了,平均耗时从 465 毫秒回到 155 毫秒上下,这才多出来那半倍。其他档位两边都没排队(或者两边都饱和了),比值就稳定在 2.5。
所以简历上只写「提升 3 倍」是有风险的,面试官只要问一句「别的档位呢」,你就得改口。写「并发 200 档位下 QPS 从 430 提升到 1290」就没这个麻烦,这句话自带条件,谁问都一样答。
顺带说一句措辞:口语里说的「提升 3 倍」,实际是「提升到 3 倍」。你要是写 430 到 1290,就不会有这个歧义。
五句追问问完,每一句对应一组要报的条件,加起来十二个空:
| 面试官那句追问 | 你要报出来的条件 |
|---|---|
| 怎么压测的 | 工具、单档时长 |
| 并发数怎么定的 | 并发档位、梯度爬坡结果 |
| 这数在你环境里成立吗 | 机器数、规格、数据量 |
| 凭什么说它稳 | P99、错误率、超时阈值 |
| 所有档位都是这个倍数吗 | 同一档位、单次只改一处 |
这十二个空填满了,性能数字这一路追问就到头了。填不满也没关系,知道缺的是哪一格,至少不会在面试现场被打懵。
六、简历上那一行,改法只有一处
给个直接能用的对照。
原来这么写:
优化系统性能,QPS 提升 3 倍。
改成这么写:
在 4 台 8C16G、数据量 500 万条的环境下,按 50/100/200/400/800 五档梯度压测,定位到瓶颈在并发 200 档位;优化后同档位 QPS 从 430 提升到 1290,P99 从 1.75s 降到 460ms,错误率归零(示例数据)。
字数差不多,第二行把三组追问入口全堵上了:并发怎么定的(梯度表)、凭什么说它稳(P99 和错误率)、这个数在哪成立(机器数和数据量)。
写简历时对着下面几条自查,漏一条就补一条:
- 那个倍数是在哪个并发档位下拿的
- 优化前后是不是同一档位、同一数据量、同一批机器
- 报的是 P99 还是均值,超时阈值写了没
- 错误率算不算 HTTP 状态码错误
- 单档压测时长够不够,我一般要求不低于 60 秒
- Agent 类应用有没有换成「会话数 + 单会话完成时长」
最后一条是给转 AI 的同学留的。Agent 一次对话跑 3 到 30 秒(示例),同样的 QPS 背后同时在跑的会话数完全不是一个量级,传统接口那套 QPS 口径搬过去就失真了。把接口那套压测脚本直接套到 Agent 上,跑出来一个数就往简历里写,被问「这个 QPS 是怎么算的」往往接不上。原因是脚本按固定速率发请求,而 Agent 每次的响应时间能差几十倍,固定速率压不出真实的并发。这时候该报的是「同时挂多少个会话、每个会话多久完成、P99 多少」,并发控制也得跟着换成会话维度:
// Agent 场景里真正的并发单位是「同时在跑的会话」
sem := make(chan struct{}, maxConcurrentSessions)
var wg sync.WaitGroup
var mu sync.Mutex
var durations []time.Duration
for task := range taskCh {
sem <- struct{}{}
wg.Add(1)
go func(t Task) {
defer wg.Done()
defer func() { <-sem }()
start := time.Now()
runAgent(t) // 一次可能跑 3~30 秒
mu.Lock()
durations = append(durations, time.Since(start))
mu.Unlock()
}(task)
}
wg.Wait()
说到底,面试官对性能数字的追问,只关心一件事:你有没有把那个数字放进一组条件里。做过优化的人很多,能把条件说全的人不多,后者才是他想筛出来的那个。
你简历上那个性能数字,是随手写上去的,还是能说出它在什么条件下跑出来的?