1. 项目概述:当TPS曲线“躺平”,问题可能出在“思考”上
最近在做一个电商大促活动的全链路压测,用k6模拟用户下单流程。脚本设计上,我采用了Ramping VUs(渐进式虚拟用户)场景,期望随着用户数稳步爬升,系统的每秒事务处理能力(TPS)也能同步线性增长。然而,压测结果却让人大跌眼镜:VU数明明在持续增加,但TPS曲线在爬升到某个点后,就像被一只无形的手按住了,几乎成了一条水平线,完全达不到预期的性能目标。
排查了服务端监控、网络带宽、数据库连接池等一系列常规嫌疑点后,问题依旧。最后,我把目光投向了脚本里那个最不起眼、也最容易被“想当然”设置的参数——思考时间(Think Time)。这次排查经历让我深刻体会到,在性能测试中,尤其是使用像k6这样以脚本驱动、模拟真实用户行为的工具时,一个不合理的思考时间设置,足以让整个压测场景的结论失真,甚至误导我们对系统容量的判断。今天,我就把这个从“坑”里爬出来的过程,以及背后的原理掰开揉碎了讲清楚,希望能帮你避开这个“隐形陷阱”。
2. 核心概念拆解:Ramping VUs、TPS与思考时间的三角关系
要理解问题,我们必须先理清这几个核心概念在k6压测上下文中的具体含义和它们之间的相互作用。
2.1 Ramping VUs:模拟真实用户增长的利器
Ramping VUs是k6中一种非常实用的场景(scenario)类型。它允许你定义虚拟用户(VU)数量随时间变化的规则,例如:在30秒内从0个VU线性增加到100个VU,然后保持100个VU运行2分钟,最后在30秒内降为0。这种模式完美模拟了现实世界中系统负载逐渐上升、达到峰值、再逐渐回落的过程,比如秒杀活动开始、午间订餐高峰等。
它的价值在于,我们可以观察系统在负载变化过程中的表现,而不仅仅是峰值压力下的表现。例如,响应时间(Response Time)是否随着VU增加而平稳增长?系统资源(CPU、内存)的消耗曲线是否健康?TPS是否能跟随VU数同步提升?这正是我们本次压测的核心目标。
2.2 TPS:衡量系统处理能力的黄金指标
TPS(Transactions Per Second),每秒事务数,是性能测试中最关键的指标之一。它直接反映了系统在单位时间内处理业务请求的能力。在我们的电商下单场景中,一个“事务”通常定义为“完成一次完整的下单操作”(包含登录、浏览商品、加入购物车、提交订单、支付等系列请求)。
在理想情况下,当并发用户(VU)数在系统容量范围内增加时,TPS应该随之线性或接近线性增长。当TPS曲线趋于平缓,不再随VU增加而显著上升时,通常意味着系统遇到了瓶颈(Bottleneck)。这个瓶颈可能出现在应用服务器、数据库、缓存、网络等任何环节。
2.3 思考时间:被忽视的“节奏控制器”
思考时间,顾名思义,是模拟真实用户在操作之间的停顿、阅读、思考时间。在k6脚本中,通常使用sleep()函数来实现。例如,用户在提交订单后,可能会查看订单确认页面几秒钟,然后再进行下一步操作。
import http from 'k6/http'; import { sleep } from 'k6'; export default function () { // 1. 浏览商品 http.get('https://api.example.com/product/123'); sleep(Math.random() * 2 + 1); // 随机思考1-3秒 // 2. 加入购物车 http.post('https://api.example.com/cart', {...}); sleep(Math.random() * 1 + 0.5); // 随机思考0.5-1.5秒 // 3. 提交订单 http.post('https://api.example.com/order', {...}); // 支付流程可能没有思考时间 }思考时间的核心作用是降低请求的发送频率,使压测流量更贴近真实用户行为,避免对后端服务发起“机枪扫射”式的无效攻击。然而,它也是一个强大的“限流阀”。每个VU在执行完一个事务(iteration)后,必须等待思考时间结束,才会开始下一个事务。因此,整个压测场景的全局最大TPS,实际上受限于“活跃VU数 / 单个事务平均耗时(包括思考时间)”。
注意:这里有一个关键理解误区。很多人认为TPS只和服务端处理能力有关。但在k6这类客户端模拟工具中,TPS首先受限于客户端(即压测脚本)能多快产生请求。如果每个VU都被漫长的思考时间“阻塞”,那么即使有成千上万个VU,它们大部分时间都在“睡觉”,实际发起请求的VU非常少,TPS自然上不去。
3. 问题现象深度剖析:为什么TPS会“躺平”?
结合我的实际案例,我们来还原一下问题现场。我的压测场景配置如下:
export const options = { scenarios: { ramp_up: { executor: 'ramping-vus', startVUs: 0, stages: [ { duration: '1m', target: 50 }, // 1分钟内增加到50 VU { duration: '2m', target: 200 }, // 再用2分钟增加到200 VU { duration: '3m', target: 200 }, // 保持200 VU运行3分钟 { duration: '1m', target: 0 }, // 1分钟内降为0 ], }, }, };我的脚本中,每个事务(下单流程)的平均服务端处理时间(即所有HTTP请求响应时间之和)约为2秒。但我为了模拟“谨慎的用户”,在关键步骤后添加了较长的思考时间,使得单个事务的总耗时(Think Time + Response Time)平均达到了10秒。
压测结果曲线如下图所示(此处为文字描述):
- VU曲线:完美地按照预设阶段从0爬升至200并保持。
- TPS曲线:在初期随着VU增加从0升至约5,但当VU超过50后,TPS就基本稳定在5左右,无论VU增加到100还是200,TPS都像粘在了5这个数值上,毫无增长。
- 响应时间曲线:始终保持在2秒左右,非常稳定,甚至略有下降(因为服务端压力根本没上去)。
诊断分析:这个现象就是典型的“客户端限制”或“脚本限制”瓶颈。我们来算一笔账:
- 假设系统处理能力无限(响应时间恒定为2秒)。
- 每个事务总耗时 = 响应时间(2s) + 思考时间(8s) = 10秒。
- 那么,单个VU每秒最多能完成 1 / 10 = 0.1 个事务(TPS/VU)。
- 当有50个活跃VU时,理论最大TPS = 50 * 0.1 = 5。
- 当VU增加到200时,理论最大TPS = 200 * 0.1 = 20。
但为什么TPS卡在5不上涨呢?因为在我的场景中,50个VU已经足以“吃满”由思考时间决定的请求产出速率上限。后续增加的150个VU,大部分时间都在排队等待思考时间结束,处于“待命”状态,而非“活跃请求”状态。因此,从服务端的视角看,它始终只承受着大约5 TPS的压力,所以响应时间很稳定。TPS不达预期,根本不是服务端瓶颈,而是我们自己用脚本给自己设定的“天花板”太低了。
4. 系统性排查与优化实战
当遇到Ramping VUs场景下TPS不随VU增长时,可以按照以下流程进行排查。
4.1 第一步:确认瓶颈位置——服务端还是客户端?
这是最关键的一步,方向错了,所有努力都白费。
- 查看服务端监控:检查应用服务器、数据库、中间件的CPU使用率、内存使用率、线程池活跃度、数据库连接池使用率等。如果这些指标都很低(例如CPU<30%),而TPS已经停滞,那么瓶颈很可能不在服务端。
- 分析k6输出指标:
http_req_duration:如果平均响应时间、p95、p99响应时间都很稳定且没有明显上升,甚至随着VU增加而下降,这强烈暗示服务端并未过载。iteration_duration:查看每次迭代(事务)的总耗时。如果这个值很高,且远大于http_req_duration之和,那么多出来的时间就是思考时间(sleep)或脚本逻辑耗时。vus与vus_max:确认VU数是否按预期增长。
- 进行对比测试:
- 测试A:在脚本中注释掉所有
sleep()函数,再次运行压测。观察TPS是否随VU数大幅增长。 - 测试B:大幅缩短思考时间(例如全部改为0.1秒),再次运行压测。
- 如果测试A或测试B的TPS曲线变得“正常”(随VU增长),那么基本可以断定是思考时间设置过长导致。
- 测试A:在脚本中注释掉所有
实操心得:我养成了一个习惯,在正式压测前,一定会先跑一个“零思考时间”的基准测试。这个测试有两个目的:一是摸清系统在“极限轰炸”下的绝对处理能力(虽然不真实,但有参考价值);二是作为对照基准,当后续带思考时间的场景出现异常时,能快速判断问题边界。
4.2 第二步:量化分析并调整思考时间
如果确定是思考时间问题,就需要科学地调整。
计算理论TPS上限:
理论最大TPS ≈ (活跃VU数量) / (平均单事务总耗时)其中,单事务总耗时 = 平均服务端响应时间 + 平均思考时间。 用我的例子算:目标TPS是100,期望活跃VU是200。假设服务端平均响应时间是2秒。那么,允许的平均思考时间 = (VU / 目标TPS) - 响应时间 = (200/100) - 2 = 0秒。这意味着,要达到100 TPS,在200 VU下不能设置任何思考时间。这显然不现实。调整策略:
- 提高目标VU数:如果业务要求必须保留较长的思考时间来模拟真实场景,那么就需要增加VU数量来补偿。公式变形为:
所需VU ≈ 目标TPS * (响应时间 + 思考时间)。若思考时间8秒,响应时间2秒,目标TPS 100,则需要100 * (2+8) = 1000个VU。但要注意,k6运行大量VU对压测机资源(内存、CPU)消耗很大。 - 优化和缩短思考时间:分析业务流程,哪些步骤的思考是必须的?能否缩短?例如,“查看订单详情”的思考时间可以设为3-5秒,而不是10秒。使用随机范围(如
sleep(Math.random()*3+2))比固定值更能模拟真实情况。 - 区分关键事务:在压测脚本中,可能包含多个事务(如浏览、搜索、下单)。对于核心交易链路(如下单),可以设置较短的甚至为零的思考时间,以确保对其施加足够的压力;对于非核心链路,保留较长的思考时间。这需要对脚本进行更精细的设计。
- 提高目标VU数:如果业务要求必须保留较长的思考时间来模拟真实场景,那么就需要增加VU数量来补偿。公式变形为:
4.3 第三步:优化脚本与场景设计
除了调整思考时间,脚本和场景设计的优化也能更有效地利用VU,逼近真实压力。
- 使用
batch请求:对于顺序执行且无依赖的多个请求,可以使用http.batch()并行发送,这能显著减少事务的总响应时间,从而间接降低思考时间的影响。import http from 'k6/http'; export default function () { // 并行获取用户信息和商品信息 const responses = http.batch([ ['GET', 'https://api.example.com/user/profile'], ['GET', 'https://api.example.com/product/123'], ]); // ... 后续处理 sleep(2); // 统一的思考时间 } - 调整Ramping策略:如果目标是测试系统在稳定压力下的表现,可以考虑使用
constant-vus或ramping-arrival-rate执行器。ramping-arrival-rate直接控制每秒迭代次数(即TPS)的爬升,更能直接地达成TPS目标,而不受VU和思考时间耦合关系的影响。scenarios: { target_tps: { executor: 'ramping-arrival-rate', startRate: 10, // 从10次迭代/秒开始 timeUnit: '1s', preAllocatedVUs: 50, // 预分配VU stages: [ { target: 100, duration: '1m' }, // 1分钟内爬升至100次迭代/秒 { target: 100, duration: '5m' }, // 保持100次迭代/秒5分钟 ], }, } - 设置合理的
maxDuration:确保单个迭代的最大时长不会因为网络波动或思考时间而无限延长,影响整体节奏。
4.4 第四步:监控与验证
调整之后,再次运行压测,并关注:
- TPS曲线是否 now follows the VU curve more closely?(是否更贴近VU曲线?)
- 服务端资源指标是否达到预期水平(如CPU使用率升至70%-80%)?
- 响应时间是否在可接受范围内开始有轻微上升?这是系统真正承受压力的迹象。
- 错误率是否在可控范围内?
5. 常见问题与高级技巧实录
在这一部分,我分享几个在排查此类问题中积累的“血泪教训”和进阶技巧。
5.1 误区:思考时间设置得越长越真实?
不一定。过长的思考时间会使得压测效率极低,为了达到目标TPS需要启动海量VU,消耗大量压测机资源,甚至可能先于服务端达到压测机本身的性能瓶颈(如网络连接数、内存)。压测的本质是在有限时间内,对系统施加足够的压力以发现瓶颈。因此,需要在“模拟真实性”和“测试效率”之间取得平衡。一个常见的做法是,在生产环境日志中统计真实用户操作间隔的分布,然后按比例进行适当压缩(例如,将真实世界的平均间隔从30秒压缩到测试中的10秒)。
5.2 陷阱:sleep函数的不确定性
k6的sleep()是异步的,它不会阻塞整个VU线程(因为k6是事件驱动的)。但是,一个VU在执行sleep()期间,确实不会执行下一个迭代。需要注意的是,sleep()的精度受JavaScript事件循环和压测机负载的影响。如果你设置了sleep(0.001)(1毫秒),实际等待时间可能远大于此。在需要高精度节奏控制的场景,避免使用极短的sleep,考虑使用ramping-arrival-rate执行器来精确控制请求速率。
5.3 高级场景:动态思考时间与业务逻辑耦合
有时,思考时间需要根据前一个请求的响应内容动态决定。例如,查询一个列表页,思考时间可能与返回的商品数量正相关。
export default function () { const listResp = http.get('https://api.example.com/products?page=1'); const productList = JSON.parse(listResp.body); // 假设商品越多,用户浏览时间越长,但设置一个上限 const thinkTime = Math.min(productList.items.length * 0.5, 10); sleep(thinkTime); // 然后随机选择一件商品查看详情 const randomProduct = productList.items[Math.floor(Math.random() * productList.items.length)]; http.get(`https://api.example.com/product/${randomProduct.id}`); }这种设计非常真实,但也引入了更大的复杂性。务必在测试报告中说明这种动态逻辑,因为它会导致每次压测的TPS波动,属于正常现象。
5.4 压测机资源成为瓶颈
当你为了补偿长思考时间而大幅增加VU数量(比如超过1000)时,压测机本身(CPU、内存、网络端口)可能先成为瓶颈。表现为:k6输出的RPS(每秒请求数)或TPS上不去,压测机CPU飙升,甚至k6进程崩溃。监控压测机资源是执行大规模压测前的必备步骤。可以考虑使用分布式压测,或者优化脚本、使用更高效的执行器(如shared-iterations)来减少单个VU的资源开销。
排查k6压测中TPS不达预期的问题,尤其是与Ramping VUs和思考时间相关时,需要一个系统性的视角。它不仅仅是调一个sleep()参数那么简单,而是涉及到性能测试目标定义、场景建模、瓶颈分析、脚本优化的全流程。核心要义是理解:在k6的世界里,TPS是由你的脚本逻辑(特别是思考时间)和场景执行器(VU数量与调度策略)共同决定的客户端发射速率,只有当这个发射速率超过服务端处理能力时,我们测出的才是服务端的真实瓶颈。下次当你看到TPS曲线意外“躺平”时,不妨先算一算“客户端天花板”,或许问题就迎刃而解了。