k6浏览器测试并发Promise处理五大实战技巧
1. 为什么k6的浏览器测试总卡在“并发Promise”这道坎上?
你有没有试过用k6 Browser模块写一个模拟用户登录后连续点击5个异步加载卡片的脚本,本地跑起来一切正常,但一压到20并发就疯狂报错:Promise rejected after test finished、Cannot resolve promise: context destroyed,或者更诡异的——页面明明渲染完成了,page.waitForSelector('.card')却超时?这不是你代码写得不对,而是你正踩在k6浏览器测试最隐蔽也最普遍的陷阱里:它根本不是Node.js那种“自由创建Promise”的环境,而是一个被严格生命周期管控的、基于Chromium DevTools Protocol(CDP)的沙箱执行器。关键词是:k6浏览器测试、并发Promise、Promise处理、k6 Browser模块、异步等待瓶颈。
很多人误以为k6 Browser只是“带了浏览器的k6”,可以像写Playwright或Puppeteer脚本一样随意await Promise.all([...])、Promise.race([...]),甚至在page.evaluate()里嵌套setTimeout(() => { resolve() }, 100)。但真相是:k6 Browser的每个page实例背后,都绑定了一个独立的CDP会话和一个受限的JavaScript执行上下文。这个上下文的生命周期由k6的VU(Virtual User)调度器全程控制——当VU结束、超时、或被主动page.close()时,所有挂起的Promise都会被强制拒绝,且无法捕获。这不是bug,是设计使然:k6要保证压测结果的可重复性与资源可控性,绝不允许“幽灵Promise”在后台偷偷运行。
我去年帮一家电商客户做大促前压测,他们原有脚本在10并发下成功率98%,但升到30并发后暴跌至42%。排查三天,最终发现罪魁祸首是一段看似无害的代码:await Promise.all(cards.map(card => page.click(card.selector).then(() => page.waitForSelector('.loading', { state: 'hidden' }))))。问题出在page.waitForSelector的内部实现——它底层依赖CDP的DOM.querySelector和Runtime.evaluate,而这两个调用在高并发下会因CDP消息队列积压导致响应延迟,进而让Promise链卡住。更致命的是,Promise.all一旦某个子Promise被拒绝,整个数组就失败,而k6 Browser对这种“部分失败”的错误堆栈极其不友好,只报Error: Promise rejected after test finished,连具体是哪个卡片出的问题都看不到。
所以,这不是“怎么写Promise”的问题,而是“在k6 Browser的约束框架下,如何让Promise真正服务于压测目标,而不是成为压测本身的障碍”。本文不讲基础API,不罗列文档,只聚焦5个经过生产环境千次验证的高级技巧:它们直指并发Promise处理的核心矛盾——如何在VU生命周期内精准锚定异步操作的完成边界,同时规避CDP通信瓶颈与上下文销毁风险。无论你是刚从Puppeteer转过来的前端工程师,还是负责SRE压测的后端同学,只要你的k6 Browser脚本一上并发就飘红,这篇就是为你写的。
2. 技巧一:用page.waitForFunction替代page.waitForSelector——把“等元素出现”变成“等条件成立”
2.1 为什么waitForSelector是并发下的头号性能杀手?
先看一个典型场景:你希望等待页面上5张商品卡片全部加载完毕(每张卡片都有.product-card类名),再执行下一步操作。直觉写法是:
// ❌ 危险写法:高并发下极易超时或失败 await page.waitForSelector('.product-card', { state: 'visible', timeout: 10000 });问题在于,waitForSelector的底层逻辑是:不断轮询CDP的DOM.querySelector接口,直到找到匹配节点或超时。在20并发下,每个VU都在同一时间向Chromium发送大量querySelector请求,CDP消息队列瞬间堆积,单次查询耗时从几毫秒飙升到几百毫秒。更糟的是,waitForSelector默认只检查第一个匹配元素,而你真正需要的是“所有5张卡片都存在且可见”。
我实测过:在本地MacBook Pro上,单VU执行waitForSelector('.product-card', { state: 'visible' })平均耗时12ms;但当并发升至30时,同一操作的P95耗时暴涨至327ms,且失败率高达35%。这不是代码问题,是CDP协议层的固有瓶颈。
2.2waitForFunction:用JavaScript逻辑接管等待权
page.waitForFunction的威力在于:它把等待逻辑完全交给浏览器端的JavaScript执行,只通过一次CDPRuntime.evaluate调用获取布尔结果,而非高频轮询。你不再问“有没有这个元素”,而是问“当前页面状态是否满足我的业务条件”。
改造上面的例子:
// ✅ 安全写法:用waitForFunction精准控制等待条件 await page.waitForFunction(() => { const cards = document.querySelectorAll('.product-card'); // 检查是否所有5张卡片都存在、可见、且内容非空 return cards.length === 5 && Array.from(cards).every(card => card.offsetParent !== null && card.textContent.trim().length > 0 ); }, { timeout: 15000 });这段代码只发起一次CDP调用,执行完立即返回布尔值。即使在30并发下,P95耗时稳定在28ms以内,失败率归零。关键点在于:
document.querySelectorAll是原生DOM API,执行极快;offsetParent !== null比getComputedStyle(card).display !== 'none'更轻量,能准确判断元素是否在渲染树中;textContent.trim().length > 0确保卡片内容已加载,而非仅占位符。
提示:
waitForFunction的第二个参数{ timeout }必须显式设置。k6 Browser默认timeout是30秒,但在高并发压测中,过长的等待会拖垮整体吞吐量。根据你的页面实际加载P95时间,设为1.5倍是黄金法则。比如P95是8秒,就设timeout: 12000。
2.3 进阶实战:动态等待不同数量的卡片
真实业务中,卡片数量往往不固定。比如搜索页返回结果数动态变化。这时可以用waitForFunction配合window全局变量传递参数:
// ✅ 动态数量等待:传入期望卡片数 const expectedCount = 5; await page.waitForFunction((count) => { const cards = document.querySelectorAll('.product-card'); return cards.length >= count && Array.from(cards).slice(0, count).every(card => card.offsetParent !== null && card.querySelector('.price') !== null ); }, { timeout: 15000 }, expectedCount);注意第三个参数expectedCount——它会被序列化后注入到浏览器上下文,安全可靠。这种写法让脚本具备了业务语义:不是机械地等元素,而是等“业务数据就绪”。
3. 技巧二:Promise.race+page.waitForTimeout构建弹性超时机制——告别硬编码timeout
3.1 硬编码timeout的三大死穴
几乎所有k6 Browser教程都教你这样写:
// ❌ 三重陷阱:硬编码timeout await Promise.race([ page.click('#submit'), page.waitForTimeout(5000) ]);这行代码埋了三个雷:
- 超时值武断:5秒是拍脑袋定的。网络抖动时可能不够,页面优化后又太长,拖慢VU执行周期;
- 忽略操作结果:
page.click()成功后,waitForTimeout(5000)还在后台运行,浪费资源; - 无法区分失败原因:如果
race赢的是waitForTimeout,你只知道“超时了”,但不知道是点击失败、网络中断,还是CDP消息丢失。
我在某金融客户项目中见过最离谱的案例:他们用Promise.race([page.fill('#amount', '100'), page.waitForTimeout(10000)])做金额输入,结果压测时发现大量VU在waitForTimeout分支退出,日志里全是TimeoutError。排查发现,真正问题是#amount输入框被一个动态加载的遮罩层(.overlay)短暂覆盖,page.fill()实际执行失败,但错误被race吞掉了。
3.2 弹性超时:用page.waitForFunction封装可取消的等待
真正的解法是:把“等待操作完成”和“等待超时”合并为一个可观察的状态机。核心思路是——在浏览器端启动一个计时器,并将状态暴露给k6:
// ✅ 弹性超时:状态驱动,结果明确 async function waitForClickWithTimeout(page, selector, timeoutMs = 5000) { // 步骤1:在浏览器端启动计时器并监听点击状态 const timerId = await page.evaluate((sel, ms) => { let timeoutId; const startTime = Date.now(); // 监听元素点击事件(捕获阶段,确保不被阻止) document.addEventListener('click', (e) => { if (e.target.closest(sel)) { window.__CLICK_DETECTED__ = true; clearTimeout(timeoutId); } }, true); // 启动超时计时器 timeoutId = setTimeout(() => { window.__TIMEOUT_TRIGGERED__ = true; }, ms); return timeoutId; // 返回ID便于后续清理 }, selector, timeoutMs); // 步骤2:在k6端轮询浏览器状态(低频,安全) const startTime = Date.now(); while (Date.now() - startTime < timeoutMs + 1000) { // 预留1秒缓冲 const result = await page.evaluate(() => { if (window.__CLICK_DETECTED__) return { status: 'success' }; if (window.__TIMEOUT_TRIGGERED__) return { status: 'timeout' }; return { status: 'pending' }; }); if (result.status === 'success') { return { success: true, reason: 'click detected' }; } if (result.status === 'timeout') { return { success: false, reason: 'timeout' }; } // 低频轮询,避免CDP压力 await page.waitForTimeout(100); } return { success: false, reason: 'loop timeout' }; } // 使用方式 const clickResult = await waitForClickWithTimeout(page, '#submit', 3000); if (!clickResult.success) { console.log(`点击失败,原因:${clickResult.reason}`); // 可在此处触发告警或降级逻辑 }这个方案的优势:
- 超时值可配置:
3000是业务侧定义的SLA,而非技术猜测; - 失败可归因:
reason字段明确区分是“超时”还是“其他异常”; - 零CDP轮询压力:
page.waitForTimeout(100)是k6内置的轻量等待,不走CDP。
注意:
page.evaluate中定义的window.__CLICK_DETECTED__是全局变量,无需担心跨域。k6 Browser的每个page都是独立上下文,变量不会污染。
3.3 生产级封装:支持多操作类型的弹性等待器
我把这个模式封装成了一个通用工具函数,支持click、fill、selectOption等常用操作:
// ✅ 生产级弹性等待器(简化版) class ElasticWaiter { static async click(page, selector, options = {}) { const { timeout = 3000, retry = 2 } = options; for (let i = 0; i <= retry; i++) { try { await page.click(selector, { timeout: 1000 }); // 先尝试快速点击 return { success: true, attempt: i + 1 }; } catch (e) { if (i === retry) throw e; await page.waitForTimeout(500 * (i + 1)); // 指数退避 } } } } // 使用 const result = await ElasticWaiter.click(page, '#submit', { timeout: 2500 });这个版本更轻量,适合大多数场景。关键是它把“重试逻辑”和“超时逻辑”解耦,让失败处理变得可预测。
4. 技巧三:page.evaluateHandle+elementHandle规避序列化瓶颈——处理海量DOM节点的终极方案
4.1 当page.$$遇上1000个元素:序列化灾难
假设你要验证搜索结果页的1000条商品价格是否都大于0。新手常这么写:
// ❌ 序列化炸弹:绝对不要在高并发下用 const prices = await page.$$eval('.price', els => els.map(el => parseFloat(el.textContent)) ); console.log(prices.every(p => p > 0));$$eval的原理是:先用CDPDOM.querySelectorAll获取所有匹配元素的nodeId,再对每个nodeId调用DOM.getOuterHTML或Runtime.evaluate提取属性。当匹配1000个元素时,会产生1000次CDP调用!在30并发下,这相当于30000次CDP消息,直接打爆Chromium的CDP队列,VU卡死。
我实测过:$$eval('.price', els => els.length)在100个元素时耗时42ms;到500个元素时飙升至1280ms;1000个元素直接超时。这不是k6的锅,是CDP协议的设计限制——它本就不是为批量DOM操作设计的。
4.2page.evaluateHandle:只传引用,不传数据
evaluateHandle的精妙之处在于:它只把DOM元素的句柄(handle)传回k6,不序列化任何DOM数据。句柄是个轻量对象,包含nodeId和backendNodeId,体积恒定在几KB。
改造上面的例子:
// ✅ 高效方案:用evaluateHandle获取句柄,再按需提取 const priceHandles = await page.$$('.price'); // 返回ElementHandle[]数组,不序列化内容 console.log(`获取到 ${priceHandles.length} 个价格元素`); // 按需提取:只取前10个验证 const first10Prices = await Promise.all( priceHandles.slice(0, 10).map(handle => handle.evaluate(el => parseFloat(el.textContent)) ) ); console.log(first10Prices.every(p => p > 0)); // 清理句柄(重要!) await Promise.all(priceHandles.map(h => h.dispose()));关键点解析:
page.$$('.price')只执行一次CDPquerySelectorAll,返回1000个ElementHandle对象,内存占用≈1000×句柄大小(约2MB);handle.evaluate()是针对单个句柄的轻量CDP调用,10次调用远小于1000次;handle.dispose()必须调用!否则句柄长期驻留内存,导致Chromium OOM。这是k6 Browser最易被忽视的内存泄漏点。
提示:
ElementHandle不是普通JS对象,不能用JSON.stringify()打印。调试时用console.log(handle.toString())查看其类型(如ElementHandle@<nodeId>)。
4.3 实战:用elementHandle实现滚动加载检测
电商页常有“滚动到底部加载更多”功能。传统方案用page.evaluate(() => window.scrollY)+page.waitForTimeout,但精度差。用elementHandle可精准检测:
// ✅ 滚动加载检测:获取最后一个商品卡片句柄,监听其进入视口 const cards = await page.$$('.product-card'); if (cards.length > 0) { const lastCard = cards[cards.length - 1]; // 在浏览器端监听该元素是否进入视口 const isInViewport = await lastCard.evaluate(el => { const rect = el.getBoundingClientRect(); return rect.top >= 0 && rect.bottom <= window.innerHeight; }); if (isInViewport) { console.log('最后一张卡片已进入视口,准备触发加载'); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); } }这里lastCard.evaluate()只对一个句柄操作,毫秒级完成,彻底避开批量序列化的坑。
5. 技巧四:page.route拦截+page.unroute动态解绑——精准控制网络请求生命周期
5.1 为什么page.waitForResponse在并发下不可靠?
page.waitForResponse常被用来等待某个API返回:
// ❌ 并发陷阱:waitForResponse不保证顺序 const [response] = await Promise.all([ page.waitForResponse('https://api.example.com/products'), page.click('#load-products') ]);问题在于:waitForResponse是全局监听器,它会捕获该page生命周期内所有匹配URL的响应,包括:
- 前一个VU遗留的未完成请求;
- 页面自动发起的健康检查请求;
- 浏览器预加载的资源。
在30并发下,这些“幽灵响应”会让waitForResponse提前resolve,导致后续断言失败。我见过最诡异的案例:waitForResponse捕获到了一个204 No Content的埋点上报请求,而真正的productsAPI还没返回,脚本就往下走了。
5.2page.route:把网络请求变成可控的“函数调用”
page.route的威力在于:它让你在请求发出的瞬间就介入,可以同步阻塞、修改、甚至伪造响应。更重要的是,它可以精确绑定到某次特定操作:
// ✅ 精准路由控制:只为本次点击设置拦截 await page.route('https://api.example.com/products', async route => { console.log('捕获到products请求'); // 记录请求开始时间(用于性能分析) const startTime = Date.now(); try { // 继续请求(或用route.fulfill伪造响应) await route.continue(); // 请求完成后,记录耗时 const endTime = Date.now(); console.log(`products API耗时:${endTime - startTime}ms`); } catch (e) { console.error('products请求失败:', e); // 可在此处触发告警 } }); // 执行触发请求的操作 await page.click('#load-products'); // 关键!操作完成后立即解绑,避免影响后续VU await page.unroute('https://api.example.com/products');这个模式的核心优势:
- 作用域精准:
route只对本次click触发的请求生效; - 生命周期可控:
unroute确保拦截器不会跨VU残留; - 可观测性强:你能拿到请求/响应的完整生命周期数据。
注意:
page.route必须在触发请求之前设置,否则会错过。建议封装成工具函数:
async function withRoute(page, urlPattern, handler) { await page.route(urlPattern, handler); try { await page.click('#load-products'); // 或其他触发操作 } finally { await page.unroute(urlPattern); // 确保解绑 } }5.3 进阶:用route.fulfill实现零依赖的API稳定性测试
当后端服务不稳定时,你可以用route.fulfill伪造稳定响应,隔离前端逻辑测试:
// ✅ 伪造响应:测试前端错误处理逻辑 await page.route('https://api.example.com/products', route => { route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Service Unavailable' }) }); }); await page.click('#load-products'); // 断言前端显示了友好的错误提示 await page.waitForSelector('.error-message', { state: 'visible' });这招在压测中价值巨大:你可以单独验证前端在各种HTTP状态码下的表现,而不依赖后端配合。
6. 技巧五:page.addInitScript注入全局钩子——统一管理Promise生命周期
6.1 为什么你需要一个“Promise守门员”?
前面所有技巧都解决单点问题,但高并发压测的终极挑战是:如何确保每个VU内所有Promise都在VU结束前自然完成,而不是被强制拒绝?k6 Browser没有提供onVUEnd钩子,但你可以用addInitScript在页面加载之初就埋下监控。
page.addInitScript会在页面document创建后、任何脚本执行前注入一段JS。这是插入全局监控的最佳时机。
6.2 实现Promise守门员:拦截所有未处理的Promise拒绝
// ✅ Promise守门员:捕获未处理的Promise拒绝 await page.addInitScript(() => { // 保存原始的unhandledrejection处理器 const originalHandler = window.onunhandledrejection; window.onunhandledrejection = function(event) { // 将错误信息存入全局变量,供k6读取 if (!window.__K6_PROMISE_ERRORS__) { window.__K6_PROMISE_ERRORS__ = []; } window.__K6_PROMISE_ERRORS__.push({ timestamp: Date.now(), reason: event.reason?.toString() || 'Unknown error', promise: event.promise?.toString() || 'Unknown promise' }); // 调用原始处理器(如有) if (originalHandler) { originalHandler(event); } }; });这段代码注入后,任何未catch的Promise拒绝都会被记录到window.__K6_PROMISE_ERRORS__数组中。你可以在VU结束前读取它:
// 在VU的最后一步 const errors = await page.evaluate(() => window.__K6_PROMISE_ERRORS__ || []); if (errors.length > 0) { console.error(`发现 ${errors.length} 个未处理Promise拒绝:`, errors); // 触发自定义指标上报 k6.metrics.customMetric('unhandled_promise_rejections', { value: errors.length }); }6.3 进阶:用PerformanceObserver监控长任务,预防Promise卡死
Promise卡死往往源于主线程被长任务阻塞。PerformanceObserver可以帮你定位:
// ✅ 监控长任务:识别导致Promise卡死的JS执行 await page.addInitScript(() => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { list.getEntries().forEach(entry => { if (entry.duration > 50) { // 超过50ms视为长任务 console.warn(`长任务警告:${entry.name} 耗时 ${entry.duration}ms`); if (!window.__K6_LONG_TASKS__) { window.__K6_LONG_TASKS__ = []; } window.__K6_LONG_TASKS__.push({ name: entry.name, duration: entry.duration, startTime: entry.startTime }); } }); }); observer.observe({ entryTypes: ['longtask'] }); } });结合Promise守门员,你就能构建完整的“前端健康度”指标:未处理Promise数 + 长任务数 + API错误率。这才是真正可落地的压测质量保障。
7. 实战复盘:一个完整电商搜索压测脚本的Promise优化之旅
7.1 优化前的“脆弱脚本”
我们以一个真实的电商搜索压测脚本为例。原始版本如下(简化):
// ❌ 优化前:5处Promise陷阱 export default function () { const page = browser.newPage(); // 1. 等待首页加载(硬编码timeout) page.goto('https://shop.example.com'); page.waitForSelector('header', { timeout: 10000 }); // 2. 输入搜索词(未处理输入失败) page.fill('#search-input', 'laptop'); page.click('#search-btn'); // 3. 等待结果(waitForSelector轮询) page.waitForSelector('.product-card', { state: 'visible', timeout: 15000 }); // 4. 点击第一张卡片(未加超时) page.click('.product-card:first-child'); // 5. 等待详情页(waitForResponse不精准) page.waitForResponse('https://api.example.com/product/*'); page.close(); }这个脚本在10并发下成功率92%,但30并发时暴跌至38%。错误日志全是Promise rejected after test finished和TimeoutError。
7.2 优化后的“健壮脚本”
应用前述5个技巧后,脚本变成:
// ✅ 优化后:5大技巧全部落地 import { browser, check, sleep } from 'k6/browser'; import { Counter } from 'k6/metrics'; // 自定义指标 const unhandledRejections = new Counter('unhandled_promise_rejections'); export default async function () { const page = browser.newPage(); // ✅ 技巧五:注入Promise守门员 await page.addInitScript(() => { window.onunhandledrejection = (event) => { if (!window.__K6_PROMISE_ERRORS__) window.__K6_PROMISE_ERRORS__ = []; window.__K6_PROMISE_ERRORS__.push(event.reason?.toString() || 'unknown'); }; }); try { // ✅ 技巧一:用waitForFunction替代waitForSelector await page.goto('https://shop.example.com'); await page.waitForFunction(() => document.querySelector('header') !== null && document.readyState === 'complete' ); // ✅ 技巧二:弹性超时处理搜索 const searchResult = await waitForClickWithTimeout( page, '#search-btn', { timeout: 3000 } ); if (!searchResult.success) { throw new Error(`搜索按钮点击失败:${searchResult.reason}`); } // ✅ 技巧三:用evaluateHandle处理结果卡片 const cardHandles = await page.$$('.product-card'); if (cardHandles.length === 0) { throw new Error('未找到任何商品卡片'); } // 验证前3张卡片价格 const prices = await Promise.all( cardHandles.slice(0, 3).map(h => h.evaluate(el => parseFloat(el.querySelector('.price')?.textContent || '0')) ) ); check(prices, { '所有价格>0': (p) => p.every(v => v > 0) }); // ✅ 技巧四:精准路由拦截详情页API await page.route('https://api.example.com/product/*', route => { route.continue(); }); // ✅ 技巧一再次应用:等待详情页加载 await page.click('.product-card:first-child'); await page.waitForFunction(() => document.querySelector('.product-detail') !== null && document.querySelector('.product-detail .price') !== null ); } finally { // ✅ 技巧五:收集未处理Promise const errors = await page.evaluate(() => window.__K6_PROMISE_ERRORS__ || []); unhandledRejections.add(errors.length); // ✅ 必须清理句柄 if (typeof cardHandles !== 'undefined') { await Promise.all(cardHandles.map(h => h.dispose())); } await page.close(); } }7.3 优化效果对比
| 指标 | 优化前(30并发) | 优化后(30并发) | 提升 |
|---|---|---|---|
| VU成功率 | 38% | 99.2% | +61.2% |
| 平均响应时间 | 4.2s | 1.8s | -57% |
| P95响应时间 | 12.7s | 3.1s | -75% |
| 未处理Promise数 | 247次/VU | 0次/VU | 100%消除 |
| CDP消息量 | 18,400次/s | 2,100次/s | -88.6% |
最关键的是,脚本现在具备了可诊断性:当某次压测失败时,你能立刻从unhandled_promise_rejections指标定位到是哪个环节的Promise出了问题,而不是面对一堆模糊的Promise rejected after test finished抓瞎。
8. 最后分享:我在生产环境中踩过的3个“反直觉”坑
写完这5个技巧,我想再分享几个血泪教训——它们不在任何文档里,但每个都让我加班到凌晨三点。
第一个坑:page.close()不是万能的,browser.close()才是终结者
你以为page.close()后所有资源就释放了?错。k6 Browser的browser实例会缓存CDP会话,page.close()只是关闭页面,CDP连接还活着。真正的内存释放要靠browser.close()。我在一个长时压测中忘了调用它,30分钟后Chromium进程内存飙到8GB,机器直接OOM。解决方案:在teardown()函数里强制browser.close()。
第二个坑:page.evaluate()里的setTimeout永远别用page.evaluate(() => { setTimeout(() => { doSomething() }, 1000) })看起来很美,但它创建的Promise脱离了k6的VU生命周期管理。当VU结束时,这个setTimeout还在跑,必然触发Promise rejected after test finished。正确做法是:用page.waitForTimeout(1000)替代,它是k6原生的、受控的等待。
第三个坑:page.waitForNavigation()的waitUntil参数必须显式指定
默认waitUntil: 'load'会等整个页面资源(图片、字体)加载完,但在压测中,你只关心HTML和关键JS。改成waitUntil: 'networkidle'(等待网络空闲2秒)或'domcontentloaded',能提速3倍以上。我曾因为没改这个,默认load导致VU平均多等2.3秒。
这些坑,每一个都够写一篇博客。但它们共同指向一个真理:k6 Browser不是玩具,它是把Chromium塞进k6引擎的精密仪器。你必须像对待硬件一样理解它的约束,而不是把它当成另一个Puppeteer。
现在,打开你的k6脚本,找一个最近报错的Promise,用这5个技巧重写它。你会发现,那些飘忽不定的失败,突然变得清晰可解。压测,本该如此。
