接到内部代号“999999999”的压测专项时,我刚进项目群,群里只有一张截图:某个网关节点在压力机上瞬时跑出的QPS曲线,末尾一长串数字几乎把监控面板撑满。负责人顺手把项目代号改成了九个9,说这就是本次活动的流量峰值模型。一开始我都觉得这数字是键盘乱按出来的,直到完整跟完整个专项才发现,比“能不能抗住峰值”更难的,是搞明白系统到底在什么条件下崩溃、崩溃之前有什么征兆、恢复需要多久。
这篇文章就围绕这个代号“999999999”的极限压测专项展开。它本质上不是一次简单的压测,而是一次面向极端流量场景的全链路容量验证:从压测工具选型、流量模型设计,到内核参数调整、网关线程模型优化,再到踩过的坑和排查实录,我都会拆开讲。如果你正在做网关、微服务或者中间件相关的工作,或者马上要备战大促、活动的系统验收,这篇文章可以作为一份可复用的压测实战参考。
1. 项目背景与目标拆解:9个9到底在测什么
1.1 极限压测不是拍脑袋,是业务峰值倒逼出来的
很多团队对压测的理解停留在“拿工具打一下接口,看看有没有报错”。但真实场景里,流量峰值从来不是一个均匀的曲线,而是瞬间涌进来的尖刺。拿这次“999999999”专项来说,业务侧给到的预测是零点过后第一波流量会呈脉冲式到达,持续几十秒到几分钟,之后快速回落到常态。这种形态最危险的地方在于,系统扩容通常跟不上流量爬升速度,等监控发现CPU已经打满,用户早就开始报错了。
所以我们做这个专项的第一目标不是“压出9亿QPS”,而是验证在极端脉冲流量下,网关集群、下游服务、缓存、数据库连接池、日志链路,每一个环节还能不能各司其职。编号“999999999”更像是一个心理暗示:所有保守估计都要去掉,所有“应该没问题”都得用数据证明。
另外还有一个现实原因:线上不可能随便把流量导到真实集群里去试。一次错误的全量上线,影响的可能就是核心链路。专用压测环境、隔离数据、可控施压,这些只有专项压测能做到。
1.2 项目范围和验收标准要提前定死
这种专项最容易翻车的就是范围不清。开发觉得只要测网关,运维觉得要测全链路,业务又觉得响应时间不能超过某个阈值。我们这次在启动前就和各方拉齐了下面几个模块,全部纳入压测范围:
| 模块 | 压测目标 | 主要关注指标 |
|---|---|---|
| 接入网关 | 验证单节点QPS上限、连接数上限 | CPU、内存、P99延迟、错误率 |
| 业务后端服务 | 验证线程池、数据库连接池是否够用 | 线程池活跃数、连接池等待时间 |
| 缓存层 | 验证热点Key下的读写性能 | 命中率、RT、慢查询 |
| 消息队列 | 验证积压后能否快速恢复 | 消费 lag、堆积量 |
| 日志链路 | 验证全量日志写入是否拖垮业务 | 磁盘IO、写入失败率 |
验收标准也写成了表格,避免事后扯皮:
| 指标 | 目标值 | 说明 |
|---|---|---|
| 整体成功率 | 不低于99.99% | 错误率里包含超时和5xx,不包含主动限流 |
| P99延迟 | 不超过300ms | 从网关入口开始计算,含下游调用 |
| 限流触发比例 | 不超过总请求的1% | 允许限流保护,但不能直接拖垮 |
| 故障恢复时间 | 不超过5分钟 | 压测结束后恢复常态,不需要重启集群 |
这些标准看起来简单,实际执行里每条都够喝一壶。比如P99延迟,很多人只看平均值,结果平均响应时间100ms,P99已经飙到2秒,用户真实体验完全是另一回事。后面我会专门讲指标怎么盯。
2. 压测方案设计:工具选型比压测本身更重要
2.1 为什么开源压测工具差点翻车
压测一开始,我们直接用常见的开源压测工具。单机模式跑小流量没问题,一旦把并发拉高,问题全出在压测端自身:
- 端口不够用:短连接模式下,源端口耗尽,压测机报错Cannot assign requested address。
- 单进程性能上限:有些工具是阻塞IO模型,并发上来之后工具自己先CPU打满,发出去的请求还没到网关就排队了。
- 分布式协调麻烦:用多台压测机虽然能解决单机瓶颈,但汇总数据、同步时间、控制执行节奏都比较费事,尤其是跑长时长稳定性验证的时候,经常出现某台压测机掉线导致整轮数据作废。
后来我们用了一段时间自研压测客户端,核心思路是连接复用加异步发送。在Go语言里用fasthttp或者net/http的Transport,把MaxConnsPerHost调高,开启keep-alive,压测机本身就能轻松跑出几十万QPS。下面是一个简化版的压测代码骨架:
package main import ( "fmt" "net/http" "sync/atomic" "time" ) var ( totalRequests int64 totalErrors int64 ) func worker(client *http.Client, url string, wg *sync.WaitGroup, duration time.Duration) { defer wg.Done() end := time.Now().Add(duration) for time.Now().Before(end) { resp, err := client.Get(url) if err != nil { atomic.AddInt64(&totalErrors, 1) continue } resp.Body.Close() atomic.AddInt64(&totalRequests, 1) } } func main() { client := &http.Client{ Transport: &http.Transport{ MaxIdleConns: 1000, MaxIdleConnsPerHost: 1000, MaxConnsPerHost: 10000, }, Timeout: 10 * time.Second, } var wg sync.WaitGroup for i := 0; i < 64; i++ { wg.Add(1) go worker(client, "http://gateway.example.com/test", &wg, 60*time.Second) } wg.Wait() fmt.Printf("requests: %d, errors: %d\n", totalRequests, totalErrors) }这段代码的关键在于MaxConnsPerHost和MaxIdleConnsPerHost,如果这两个值不调大,底层连接池会频繁创建新连接,压测机自身会出现大量TIME_WAIT,直接影响压测结果真实性。
2.2 流量模型怎么搭才像真实业务
压测不能只按固定QPS匀速打,真实业务的流量是上下起伏的。我们根据业务预测,把压测模型分成了四类场景:
- 脉冲峰值:从低到高在30秒内快速拉升,模拟活动开始瞬间的流量洪峰,最考验系统的自动扩容和限流预案。
- 持续高压:把QPS维持在上限的80%左右,持续跑20到30分钟,重点观察内存泄漏、连接泄漏、GC频率、日志积压。
- 双重高峰:一段时间内出现两次脉冲,中间短暂回落。这种场景最容易暴露缓存穿透问题——第一次高峰后热点Key过期,第二次高峰直接把DB打崩。
- 混合请求:不只是打同一个URL,还要按业务比例混合多种接口,不同接口的超时时间、依赖资源都不一样,单接口压测通过不代表全链路能扛住。
流量模型不是压测脚本里随便写个循环就完事。还要控制请求体大小、是否携带Cookie、URL路径分布,因为我们发现单个大Body请求会把网关的缓冲区打满,和真实的小请求完全不同。
2.3 压测环境隔离与数据构造
“999999999”专项用的是一套独立压测集群,和线上物理隔离,但网络拓扑保持一致。为什么强调网络拓扑?有一次我们在压测环境里省略了某个缓存节点,结果网关直连数据库,压测数据一出,数据库连接池立刻被打满。后来虽然补上了缓存节点,数据结果已经失真。
数据构造同样有讲究。不能只构造几条缓存数据然后高并发读,要把冷热数据比例构造出来,同时保留一部分过期Key,专门验证缓存穿透保护逻辑。压测库里的数据量级也尽量贴近线上,量级太小会导致索引走偏,QPS看起来很高,实际线上根本达不到。
3. 动手压测前,先把系统和网关参数调到不出幺蛾子
3.1 内核层参数:文件句柄、端口范围和连接队列
压测最尴尬的场面不是目标系统崩了,而是压测机先弹出一堆连接失败,大家还以为是网关挂了。排查一圈发现是压测机的文件句柄限制太低,或者端口范围不够。
我们执行前会在压测机上做一套标准配置,下面这段是通用的,可以直接保存成脚本:
cat >> /etc/sysctl.conf <<EOF net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_timestamps = 1 net.core.somaxconn = 65535 net.ipv4.tcp_max_syn_backlog = 65535 net.core.netdev_max_backlog = 65535 fs.file-max = 1000000 EOF sysctl -p ulimit -n 1048576这里要解释几个关键参数:
- ip_local_port_range:本地端口范围,使用短连接测试时,源端口不够就会报错。
- tcp_tw_reuse:开启TIME_WAIT复用,配合tcp_timestamps使用,减少四元组重用时的等待时间。
- somaxconn和tcp_max_syn_backlog:如果压测目标服务器接受新连接的能力不足,即使程序没问题,连接也会在TCP握手阶段被丢掉。
但注意,tcp_tw_reuse只能在连接发起方配合时间戳使用,它改变的是TIME_WAIT超时内的端口重用行为,不是所有场景都适用。我们压测机开,线上服务器保持默认,避免影响真实连接的语义。
3.2 网关线程模型与连接管理:不要一上来就调线程数
网关节点我们用的是常见的多worker进程模型,最开始遇到的问题是连接数上去了,但worker进程的CPU使用率不均衡。压测发现单workerCPU跑到80%,另一个Worker只有20%。后来确认是连接分配不均匀,部分长连接集中在同一个worker上。
解决办法不是盲目加worker进程,而是先看每个worker的连接数分布,调整连接分配策略和keep-alive超时时间。下面是调整后的nginx关键配置片段:
worker_processes auto; worker_rlimit_nofile 1048576; events { worker_connections 40960; accept_mutex off; multi_accept on; } http { keepalive_timeout 30s; keepalive_requests 1000; upstream backend { server 10.0.0.11:8080; server 10.0.0.12:8080; keepalive 1000; } }这些参数里最容易忽略的是upstream keepalive。如果不配,网关和后端服务之间每次请求都会新建TCP连接,压力一大后端机器上全是SYN_RECV,业务超时直接飙起来。keepalive不是越大越好,它决定每个worker进程里空闲连接的上限,要根据后端服务能承载的连接数反推。
后端如果是Java服务,同样要查Tomcat或者undertow的连接器配置。我们这次压测发现,网关层扛住了,但Java服务的acceptCount设置太小,大量连接在操作系统层面排队,最后表现为客户端超时。Tomcat下相对合理的配置类似:
server.tomcat.threads.max=400 server.tomcat.accept-count=2000 server.tomcat.max-connections=20000 server.tomcat.connection-timeout=5000ms这里的坑在于线程数不是越高越好。线程太多会导致上下文切换频繁,GC压力变大。我们一边压测一边观察线程池活跃线程数和等待队列长度,发现活跃线程基本稳定在250到300之间,于是把max定在400,留出一定余量。
3.3 下游Mock服务不能先崩
全链路压测离不开下游服务,但我们不可能把真实业务DB、真实MQ都拿出来压。常见做法是用Mock服务模拟下游。Mock服务看起来简单,实际上是最容易出问题的环节。很多压测项目失败并不是目标系统扛不住,而是Mock服务自己先崩了,导致目标系统出现大量依赖超时,结果全被记在目标系统头上。
Mock服务的核心要求有两点:一是延迟分布要贴近真实,不能所有请求都立即返回;二是要能扛住目标系统转发过来的全部流量。我们直接用类似前面提到的异步HTTP服务来做Mock,把随机延迟控制在1到20毫秒之间,CPU跑满也不怕,因为Mock服务器不存储状态,纯粹回包。
4. 阶梯加压与容量评估:怎么从曲线里读出上限
4.1 阶梯加压:先找到稳定区,再试探崩溃区
压测执行不是一上来就打到目标压力。我们把整个加压过程分成多个阶梯:每秒500 QPS起步,稳定3分钟,观察指标;然后跳到每秒2000,再稳定;再5000,再稳定。一直加下去,直到找到某个临界点,QPS不再上升反而下降,或者错误率开始快速增加。
这个阶梯的过程,本质是给系统一个自适应的机会。很多系统在流量平缓上升时能够自动扩容,但如果压力瞬间拉满,扩容还没触发,系统就已经崩了。阶梯加压可以观察到自动化策略的生效时间。
当时跑出来的曲线大致是这样:单节点在每秒8000 QPS以内表现稳定,CPU在55%左右,P99在120毫秒以内;到每秒1.2万QPS时,CPU升到75%,P99开始上升到250毫秒;到每秒1.5万QPS时,错误率突然窜到5%,P99超过1秒。
这个现象说明1.2万到1.5万之间就是单节点的性能拐点。我们并没有继续把单节点压到极限,而是在1万QPS附近停下来,因为生产环境还需要留出至少30%的冗余。
4.2 关键指标怎么盯:平均值是骗人的
压测过程中监控面板上指标特别多,但我们真正盯死的只有几个:
- 错误率:包括连接错误、超时、5xx,任何一个超过0.1%就要看原因。
- P99/P95延迟:比平均值重要得多,P99上涨往往意味着线程池或连接池开始排队。
- GC频率和耗时:Java服务重点看Full GC,年轻代GC频繁说明对象分配速率很高。
- TCP连接状态:重点关注TIME_WAIT、CLOSE_WAIT、SYN_RECV的数量。CLOSE_WAIT堆积基本是应用没有正确关闭连接,属于代码问题;SYN_RECV堆积则需要看半连接队列是否溢出。
- 日志写入速率:如果磁盘IO超过80%,日志大概率已经成为瓶颈。
这些指标要放在同一个时间轴上对比,单独拿出来看都说明不了问题。有一次我们只看到P99上涨,以为代码有锁竞争,查了半天,结果发现是同时刻GC日志在写一个大文件,磁盘IO被日志线程吃掉了。指标之间没有相互印证,排查方向就会跑偏。
4.3 容量评估:用压测数据反推节点数
压测结束后的容量评估是“999999999”专项最重要的交付物。核心公式其实很简单:
所需节点数 = 预估峰值QPS / (单节点实测QPS x 冗余系数)
举例:单节点在P99稳定、错误率达标的前提下,实测QPS上限是1万,目标峰值是100万,冗余系数取0.7。那么节点数就是100 / (1 x 0.7) 约等于143个节点。这里冗余系数0.7意味着每个节点只用到70%的能力,剩下30%留给突发流量和单点故障场景。
冗余系数不是拍脑袋定的,主要看业务容忍度。如果系统具备秒级自动扩容能力,系数可以到0.8;如果扩容需要人工介入,建议保守一点取0.6。我们最后给出的建议是145个节点,并且用这组数据重新压了一轮,整体P99稳定在200毫秒以内,才算真正验收通过。
5. 压测实录:五个把团队折磨到深夜的问题
5.1 压测机先崩了:你以为在压目标,其实在压实测机
第一次全链路压测,我们压了不到5分钟,监控显示目标系统压力没上来,压测端倒是先报了一堆端口分配错误。
排查过程其实不复杂:先看压测机的连接数,发现大量短连接处于TIME_WAIT状态。因为压测脚本最初没有复用连接,每个请求都新建TCP连接,测试结束后四元组进入TIME_WAIT,端口都被占住了。后来把客户端改成强制连接复用,同时调大了端口范围,问题立刻消失。
这个坑的教训是:压测结果如果出现大面积“连接失败”,优先检查压测机自己的网络状态,不要第一个怀疑目标服务器。压测工具产生的异常连接状态,会直接污染整个测试数据。
5.2 连接风暴:网关CLOSE_WAIT堆积
第二个问题出在网关到后端服务这一层。压测中我们看到网关节点上的TIME_WAIT不多,但CLOSE_WAIT从几十涨到几万,随后接口超时开始蔓延。
CLOSE_WAIT表示对端(后端服务)已经关闭连接,但本端(网关)还没有完成关闭流程。最常见的触发原因是网关把请求转发给后端后,没有及时读取或关闭响应流。当时我们排查了后端服务,发现它在高并发下主动关闭了一部分连接,但网关侧由于连接池回收逻辑不完善,没能及时处理这些半关闭连接。
解决方案有两步:一是网关到后端的连接池配置加上了idle超时和异常连接清理逻辑;二是后端服务调大了连接接受队列,减小主动断开连接的概率。这类问题在低并发时很难暴露,只有连接数到达一定量级才会触发。
5.3 下游服务雪崩:线程池队列爆了
第三轮压测,我们把流量拉到峰值附近。目标网关指标还算平稳,但下游某个核心服务的线程池队列突然开始迅速增长,紧接着该服务出现大面积超时,网关的重试机制又把流量重新打过来,越滚越大,最终变成雪崩。
查看线程池监控才发现,活跃线程数其实没有到max,但是队列长度已经超过10万。因为该服务的线程池配置合理,但拒绝策略是CallerRunsPolicy,也就是说线程池满了之后调用线程自己来执行任务。调用线程本身是网关的worker线程,这样一来网关的响应也被拖住了,整体延迟被拉爆。
修复方式是调整线程池队列容量以及拒绝策略。更重要的是,我们在网关层加上了快速失败机制:当下游超时比例超过阈值时,网关不再无限重试,而是直接返回降级结果,给下游留出恢复时间。这一改动在下一轮压测中起到了关键作用,雪崩没有再发生。
5.4 日志把磁盘写满:全量日志在高压下是灾难
压测进行到第15分钟,告警突然弹出,压测环境中的两个节点磁盘使用率超过90%。一开始还以为是压测生成的临时数据,登录上去才发现是应用的全量访问日志。为了快速定位问题,压测期间我们把日志级别从INFO调整到了DEBUG,加上请求体比较大,每分钟产生几个GB的日志,磁盘很快就满了。
这类问题在真实业务里非常常见。日志系统在高并发下看似微不足道的写入量,会被请求量放大几百倍。我们后来把压测期间的日志策略改成:INFO级别只记录关键链路,RequestBody默认不打印,日志输出改成异步写盘,并且给日志文件单独挂载了一块磁盘,和业务数据物理隔离。这样即使日志量爆炸,也不会把系统盘占满。
5.5 监控延迟导致误判:数据没回来,大家都慌了
第五个问题不算故障,但比故障更影响判断。有一轮压测结束,监控大屏上的错误率还在持续上升,团队差点把已经跑完的压测当成失败案例。后来发现,监控系统采用定时聚合,数据采集存在一分钟左右的延迟,压测已经结束了,后台还在处理堆积的指标。
这件事提醒我:压测时的监控数据延迟和业务延迟一样重要。我们为专项压测单独搭了一套实时性更高的监控通道,数据采集周期调整到5秒,关键指标直接写入独立的时序库。之后每一次加压,都能在十几秒内看到真实反馈,压测效率提升非常明显。
| 问题现象 | 直接原因 | 解决方法 |
|---|---|---|
| 压测机端口耗尽 | 短连接没有复用 | 客户端开启keep-alive,调大端口范围 |
| 网关CLOSE_WAIT堆积 | 连接池回收不完善 | 调优连接复用和空闲清理 |
| 下游线程池队列暴涨 | 队列策略导致调用线程阻塞 | 调整拒绝策略,加快速失败 |
| 磁盘写满 | DEBUG日志输出过多 | 异步日志、关键链路采样、独立磁盘 |
| 监控数据滞后 | 聚合周期过长 | 专项压测使用短周期监控通道 |
6. 可直接复用的压测配置与命令速查
6.1 压测脚本骨架
如果你不想从零写压测客户端,可以直接参考下面这个思路:外部传入并发数、压测时长、目标URL,内部用协程池和连接复用发起请求,每隔一段时间打印QPS和错误率。核心代码在2.1节已经给出,实际使用时只要把worker数量和压测时长参数化就行。
我单独提一个易错点:压测脚本的输出频率不要太高。每10秒输出一批QPS数据足够,输出太密集会在压测机上产生大量小文件写入,影响施压端性能。
6.2 内核与连接层配置速查
下面是整个专项下来沉淀的一套配置,适用于大多数基于TCP的网关和Web服务压测:
- 文件句柄:ulimit -n 至少到1048576。
- 内核参数:参考3.1节,注意tcp_tw_reuse只配置在施压端。
- 网关keep-alive:连接数上限、空闲时间、单连接请求数三个参数要联动调整。
- 后端线程池:线程数、队列大小、拒绝策略必须做组合验证,不能单看线程数。
- 监控通道:压测期间缩短聚合周期,关键指标实时刷新。
6.3 最后一个小建议:压测不只是测上限,还要测恢复
“999999999”专项做完了,我印象最深的不是那根漂亮的QPS曲线,而是压测结束后的一次额外演练:突然把压测流量撤掉,观察系统能不能自动恢复。
很多系统能扛住压力,但撤掉压力后连接池和线程池不会立刻回收,大量连接处于半开状态,导致恢复期依然有错误请求。如果恢复时间过长,下一次流量高峰再打过来,系统会带着一身问题重新接入,结果只会更糟。
所以我建议每一个压测专项都加上“止血和恢复”验证。压测结束后,至少留出10分钟的观察窗口,看CPU是不是回落到正常水位、线程池活跃数是否回落、错误率是否归零。这个数据和压测峰值数据一样值得写进报告。
回头再看“999999999”这个代号,它从一开始就不只是一个数字。九亿QPS当然不是真实目标,但每一次压测执行,我们都是在问同一个问题:当所有东西都按最坏情况发生的时候,系统还能不能稳定地给出回答。做了这么多轮压测,我最大的体会是,极限压测的核心价值从来不是证明系统有多强,而是在真正的流量洪峰到来之前,把系统最脆弱的那一面找出来,然后亲手修好它。