恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Playwright秒杀压测实战:精准定位与网络拦截模拟高并发
首页
资讯中心
/
Playwright秒杀压测实战:精准定位与网络拦截模拟高并发
Playwright秒杀压测实战:精准定位与网络拦截模拟高并发
发布时间:2026/10/16 17:38:35
做电商压测这些年我接过不少秒杀场的需求但真正把 Playwright 用到极致、跑出有价值结果的还是一次双十一级的促销模拟。接到任务时需求方就一句话帮我们看看秒杀系统扛不扛得住。但等真正上手拆解才发现传统压测工具能测吞吐量却模拟不了真实用户的手速、页面动态加载和接口异常而这些恰恰是秒杀场景最容易出问题的地方。最后我选了 Playwright Python 这套组合用网络拦截加精准元素定位硬是把压测机器人搭了出来效果超出预期。这篇文章不打算给你讲一堆理论概念而是把项目里踩过的坑、验证过的方案、最终可落地的代码原原本本分享出来。无论你是刚入门自动化测试的新人还是在做接口压测想往浏览器端深挖的老人只要你对秒杀、抢购这类高并发场景的测试感兴趣今天这套实战思路都值得从头到尾跟一遍。1. 秒杀压测的底层逻辑与技术选型1.1 秒杀场景的三大测试难点秒杀场景和普通电商页面压测的最大区别在于它的用户行为和系统状态都高度集中在一个极短的时间窗口内。我拆解下来难点主要集中在三个维度。第一个难点是真实用户行为的模拟。用户不是单纯对着接口发高并发请求他们需要先登录账号、访问商品详情页、盯着倒计时、在未开始状态等上几分钟然后在按钮变为立即抢购的那一瞬间点击下单。这个链条里任何一环缺失压测得出的结果都对不上真实情况。用接口工具压测只能模拟到请求层完全没有页面渲染和用户交互层面的耗时最后测出来的响应时间和真实体验差别非常大。第二个难点是动态状态的识别。秒杀页面上有太多东西在变化倒计时数字每秒在跳按钮从灰色禁用变成红色可点库存条随购买进度实时更新有时候还有验证码弹层、公告弹窗穿插进来。传统自动化测试脚本如果只写死一个定位器到点根本找不到目标元素更别说快速执行点击操作了。第三个难点是并发的时间同步。真实秒杀是成千上万个用户在同一秒、同一毫秒量级集体操作测试机器人必须能精准控制准时开抢这个瞬间而不是随机访问。这就要脚本能精确对齐秒杀开始时间并且在一个极短的时间片内把大量并发任务同时触发。方案没选好的话光这个并发同步就能让压测数据的可信度直接腰斩。1.2 Playwright凭什么能扛起压测大旗聊到这里你可能会问都是做压测为什么不用现成的 LoadRunner 或者 JMeter我也用过一段时间它们的优势在于协议层的高并发模拟但问题在于它们模拟的用户是虚拟的没有真实的浏览器行为对前端页面动态渲染、按钮状态切换、防抖校验这些机制完全无感。而秒杀这种场景恰恰前端交互占了很大比重我们必须有一个能跑真实浏览器内核的工具。Playwright 刚出现时我第一反应是它就是个自动化测试框架跟 Selenium 差不多后来用了才发现它的设计思路完全是冲着现代复杂 Web 应用去的。它最打动我的三点一是对动态内容的原生等待机制不用像 Selenium 那样满屏写 sleep二是网络拦截功能能在请求发出前或者响应返回前做拦截修改这对于模拟服务端异常、网络延迟简直是量身定做的功能三是多浏览器、多上下文的设计天然适合并发场景的模拟。还有一个很实在的优势是 Playwright 的 trace viewer 功能可以录制每一步的页面状态、网络请求和浏览器控制台日志这对事后排查秒杀机器人的翻车现场特别有用。简单说秒杀压测需要的是一个能高度模拟真实用户、能精确控制网络行为、又方便追踪现场的工具Playwright 在这几个维度上都占优势。1.3 环境搭建与基础架构设计这个项目用的是 Python 3.10 Playwright安装起来很简单一条命令就能装好核心库然后再安装浏览器内核即可。我建议用虚拟环境管理依赖避免后续跑并发任务时出现版本冲突的问题。# 创建虚拟环境 python -m venv seckill_env source seckill_env/bin/activate # Windows: seckill_env\Scripts\activate # 安装 Playwright pip install playwright # 安装 Chromium 内核秒杀压测我推荐用 Chromium性能相对更稳 playwright install chromium整个压测机器人的架构我分成了四层调度层负责时间同步和并发触发执行层负责创建浏览器上下文并驱动页面操作拦截层负责处理接口故障模拟和响应修改数据层负责收集每轮压测的响应时间、成功率、异常信息。这四层各自独立后续想加场景或者换页面只需要改执行层其他三层都能复用。实际写代码时我没有把逻辑一股脑塞进一个文件而是拆成core.py核心执行、interceptor.py拦截逻辑、runner.py并发调度、reporter.py数据统计这样维护起来轻松太多。2. 元素定位在时间赛跑中精准抢到按钮2.1 动态页面的三种定位陷阱秒杀页面的 DOM 结构我见过不少几乎每个平台都有不同的套路但归纳下来有三类陷阱是高频率踩坑的。第一类是动态 ID 和 Class。很多前端框架为了渲染性能会给元素生成随机后缀的 ID今天这个按钮叫btn_abc123明天就变成了btn_xyz789。你要是把 ID 写死在脚本里第二天跑直接扑空。解决思路很简单优先用文本内容、属性组合、DOM 位置关系去定位而不是死磕某个动态 ID。第二类是按钮状态切换。秒杀开始前按钮是灰色未开始开始时切换成红色立即抢购抢完后变成已抢光。同一个元素不同状态下可能有多套样式类甚至多套文本。如果你只定位一次就点击大概率会点在不可点击的 DOM 节点上。正确做法是等按钮文本或者 class 属性变化到目标状态再触发点击。第三类是倒计时和库存数字的持续更新。倒计时通常每秒重写一次文本有时候前端框架还会用虚拟 DOM 做局部更新导致节点被替换。如果你在循环里反复查找同一个元素引用刷新前后拿到的可能已经不是同一个 DOM 节点了继续操作就会报错。这种情况下每次操作前重新查询当前状态的元素是最稳妥的。2.2 状态轮询与显式等待的实战技巧Playwright 的自动等待机制比我用过的 Selenium 成熟很多但有一点要明白自动等待只解决元素存在或者元素可见这类基础条件它不知道你业务上要的按钮可点到底是什么状态。所以秒杀场景里我用的核心技巧是组合等待代码上分了两步来做。第一步是用定位器锁定元素等待它进入可见状态。第二步才是关键调用page.wait_for_function去轮询页面上某个 JS 表达式的结果直到按钮文本、class 或者某个 data 属性满足抢购条件。这样能确保脚本卡在正确的抢购瞬间而不是在页面还在倒计时的时候就急着点了。from playwright.sync_api import sync_playwright import time with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example-seckill.com/item/10001) seckill_btn page.locator(#seckillBtn) # 第一步等待按钮出现在视口内 seckill_btn.wait_for(statevisible, timeout60_000) # 第二步等待按钮文案变为“立即抢购”或># 提取按钮在 canvas 中的坐标位置 box page.eval_on_selector( #seckill-canvas, (el) { const canvas el.getContext(2d); // 这里配合业务代码读取 canvas 上按钮的绘制区域 return {x: 120, y: 300, width: 180, height: 60}; } ) # 用真实鼠标移动和点击模拟用户行为 page.mouse.move(box[x] box[width] / 2, box[y] box[height] / 2) page.mouse.click(box[x] box[width] / 2, box[y] box[height] / 2)还有一个很实用的小技巧遇到页面不断重渲染导致元素引用失效时报错 element is not attached to the DOM 时不要去手动休眠而是直接用page.reload()重新加载页面再等状态。这个方法虽然简单粗暴但在某些内存回收有问题的页面上比反复重试定位器要稳定得多。3. 网络拦截把接口故障注入压测流程3.1 拦截请求与修改响应的完整实现秒杀压测不能只测一切正常的路径服务端库存扣减异常、超卖提示丢失、接口超时这些故障场景同样需要覆盖到。通过故障注入才能发现前端在异常情况下的表现是否合理。Playwright 的page.route就是干这个的它能拦截符合条件的请求然后选择继续请求、中断请求、伪造响应或者先发出去再修改响应内容。实际实现时我会把所有需要拦截的接口路径预先配置好比如秒杀令牌接口、下单接口、库存查询接口对应不同接口挂不同的拦截处理函数。其中用得最多的操作是修改响应体因为能很逼真地模拟服务端返回库存不足、返回用户被限流这类业务层异常而不是单纯地让请求失败。import json from playwright.sync_api import sync_playwright def mock_route_handler(route): response route.fetch() body response.json() # 修改秒杀令牌接口的返回把库存改为 0模拟已抢光 if seckill/token in route.request.url: body[data][stock] 0 body[data][status] sold_out route.fulfill( status200, content_typeapplication/json, bodyjson.dumps(body) ) else: route.continue_() with sync_playwright() as p: browser p.chromium.launch(headlessFalse) context browser.new_context() page context.new_page() page.route(**/seckill/**, mock_route_handler) page.goto(https://example-seckill.com/item/10001) # 等待按钮状态变化当前场景应该显示“已抢光”而不是“立即抢购” page.wait_for_function( () document.querySelector(#seckillBtn).textContent.includes(已抢光) )这段代码里route.fetch()真的是个宝藏方法它会把原始请求发出去并把真实响应拿回来你再基于真实数据进行修改后fulfill。相比自己手工构造一个完全新的响应这种方式保留了服务端真实返回的字段结构只是改了业务核心数据前端渲染不会因为缺字段而出错。我在用的时候也踩过坑如果手动构造的 JSON 里漏了某个字段前端直接白屏反而干扰了对故障本身的判断。3.2 模拟服务端异常与网络劣化修改响应体只能模拟业务异常另一个很有用的操作是直接中断请求。这在模拟服务端宕机、接口 500、网关异常时非常有效。做法很简单在 route 处理函数里直接调用route.abort()浏览器侧表现就是请求失败前端如果有错误兜底提示就能被完整暴露出来。def abort_route_handler(route): if seckill/order in route.request.url: # 模拟下单接口彻底不可用 route.abort() else: route.continue_() page.route(**/*, abort_route_handler)这里的拦截就是一个故障注入武器我也常用它去做前端容错测试。比如秒杀页通常有多个用于加载商品图的 CDN 资源平时大家都不在意可一旦 CDN 挂了图片加载不出来页面排版错乱按钮被挤到视口外用户根本没法抢购。我压测时就会拦截所有静态资源请求然后观察页面重排后的按钮状态和位置看看前端团队有没有对这类降级情况做处理。3.3 拦截的高性价比玩法延迟注入相比直接修改响应数据和中断请求延迟注入是秒杀场景里最容易出效果、也最容易被忽略的手段。真实用户的网络环境千差万别有的在弱网环境下加载页面接口响应要好几秒才回来这和本机内网环境下瞬时返回的数据完全是两码事。我们测出来的压测指标如果只是基于快网络得出的放到生产环境里没有参考意义。延迟注入的实现思路是拦截到指定接口请求后等待设定好的延迟时间再把响应返回给浏览器。代码里只需要在 route 处理函数内执行time.sleep()再continue_()就行。def delay_route_handler(route): if seckill/token in route.request.url: time.sleep(3) # 模拟接口在弱网环境下的 3 秒延迟 route.continue_() else: route.continue_() page.route(**/*, delay_route_handler)我建议在压测报告里明确标注当前用的是哪种网络条件。比如同样的秒杀流程在模拟 50ms 延迟和 1500ms 延迟两种场景下前端倒计时结束后按钮的可点击时间点会完全不同用户能否在窗口期内完成提交结论可能截然相反。用拦截做延迟注入不需要额外搭建任何网络模拟环境代码量只有几行投入产出比很高。还有一个 CDP 层面的方案可以说说。Playwright 底层的 Chromium 支持 DevTools 协议里的网络模拟可以在浏览器层面直接限制带宽和延迟。我一般在需要更真实的弱网体验时会在启动上下文时用context.new_cdp_session(page)来设置网络条件但这种方法只能整体降速不像 route 拦截那样可以针对单个接口做精细化操作所以日常我优先用路由拦截。4. 完整实现一个可运行的秒杀压测机器人4.1 核心代码实现与逐段解析在开始压测之前我的规划是这样的先用无头模式做一轮冒烟测试确认整个场景能走通统计脚本里的关键耗时项再用有头模式跑一轮完整流程把 trace 录制下来用于问题追踪。下面这段代码是精简整理后的核心流程保留了秒杀场景最关键的部分。import json import time import threading from datetime import datetime from playwright.sync_api import sync_playwright def run_one_concurrent_user(user_id): 单个并发用户的一次完整抢购流程 with sync_playwright() as p: browser p.chromium.launch(headlessFalse) context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, viewport{width: 1280, height: 800} ) page context.new_page() # 步骤1访问秒杀页面 page.goto(https://example-seckill.com/item/10001) # 步骤2等待秒杀按钮出现 seckill_btn page.locator(#seckillBtn) seckill_btn.wait_for(statevisible, timeout60_000) # 步骤3等秒杀倒计时到达0按钮变为“立即抢购” # 这里用 JS 轮询精确控制避免多等或少等 t0 time.time() page.wait_for_function( () { const btn document.querySelector(#seckillBtn); return btn btn.textContent.includes(立即抢购); }, timeout60_000 ) wait_cost time.time() - t0 # 步骤4点击抢购按钮并采集响应时间 t1 time.time() seckill_btn.click() # 等待跳转或下单成功标识出现这里用一个常见的选择器 page.wait_for_selector(.order-success, timeout15_000) click_cost time.time() - t1 # 收尾记录结果 return { user_id: user_id, success: True, wait_cost: round(wait_cost, 3), click_cost: round(click_cost, 3), time: datetime.now().isoformat() }这段代码基本概括了秒杀机器人的完整行为但我还是想拆开说几个关键点方便你照着改。context.new_context()里我手动指定了 user_agent 和 viewport这不是为了什么花哨的目的而是因为不少电商页面会给无头浏览器返回一个不同的前端渲染版本导致定位结果失真。加上这两项能让页面尽量按正常 Chrome 环境的路径去渲染和加载。wait_for_function是我整个脚本里最核心的一步它就是秒杀机器人能掐点的关键。实际跑的时候页面会有到点前的一瞬间按钮文本从即将开始变成立即抢购这行代码能精确捕捉到变化瞬间误差控制在毫秒级。比手动 sleep 到某个时间点可靠得多因为脚本运行时的网络开销和渲染时间会动态变化固定 sleep 很难掐准。点击之后的等待我选了.order-success这个选择器作为抢购成功的标志。不同平台肯定不一样有的是跳到收银台页面有的是弹窗提示恭喜您抢到了。建议在做冒烟测试时先确认成功标识的选择器再正式跑并发。4.2 并发设计与压测数据采集单用户脚本跑通后核心问题就变成了怎么同时触发几十上百个用户。我研究过两种方案。第一种方案是用多线程同时启动多个 Playwright 浏览器上下文每个上下文隔离 cookie 和状态相当于每个用户一个独立会话。这个方案胜在简单直观线程数控制好基本不会出乱子。第二种方案是异步多页面在一个浏览器实例里开多个标签页模拟多个用户但隔离性相对弱一点某些共享资源的场景下容易互相干扰。所以实际项目里我选择了第一种。下面是一个简化的并发调度器结构用线程池控制并发度避免一下子把本机内存吃满from concurrent.futures import ThreadPoolExecutor import csv import time def run_load_test(total_users20, concurrency5, target_time2024-01-15 10:00:00): 并发启动模拟用户对准目标时间开抢 results [] target_ts datetime.strptime(target_time, %Y-%m-%d %H:%M:%S).timestamp() # 所有线程先阻塞等目标时间到再同时释放 barrier threading.Barrier(concurrency) def user_task(uid): # 等待目标时间 while time.time() target_ts: pass barrier.wait() result run_one_concurrent_user(uid) results.append(result) with ThreadPoolExecutor(max_workersconcurrency) as executor: futures [executor.submit(user_task, i) for i in range(total_users)] for f in futures: # 这里是简化写法正式代码建议收集异常 f.result() # 写结果到 CSV方便后续分析 with open(seckill_result.csv, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnames[user_id, success, wait_cost, click_cost, time]) writer.writeheader() writer.writerows(results) return results这里有个设计细节我前后调整过好几轮就是精准同时开始。秒杀压测最忌讳的就是用户点击时间点不齐数据移位。我当时用了 Python 的threading.Barrier让每个线程都先死等到目标时间点然后一起放开执行点击。虽然 Python 里线程调度的精确度跟真正的硬实时系统比不了但实测下来同一批线程触发点击的偏差能控制在几十毫秒内对压测来说这个精度足够。在采集数据时我只记录了几个关键指标用户 ID、是否抢购成功、等待按钮从禁用到可点的时间、点击到出现成功页面的耗时、最终时间戳。这些数据看起来简单但秒杀结果的评估基本就是看着几个指标分析出来的。我还建议把每一轮的完整 trace 录制文件保存下来方便后面复盘时定位是哪一步出了问题context browser.new_context( record_video_dirvideos, trace_viewportTrue, )4.3 报告分析与指标解读数据跑完后直接用标准库写读取 CSV 并做简单统计。我这里习惯计算几个硬指标总成功数、成功率、平均点击耗时、P95 点击耗时响应时长从低到高排序后95% 的请求落在该值以内、失败分布类型。这几个指标里P95 比平均耗时更能反映真实体验。为什么这么说秒杀场景是极端并发平均耗时会掩盖一批很慢的请求的真实情况可能大部分用户在 800ms 内完成了点击但有个别用户等了 8 秒平均数就会被拉高看起来还行P95 能直接把这类尾延迟暴露出来。import pandas as pd df pd.read_csv(seckill_result.csv) success_rate df[success].mean() p95_click df[click_cost].quantile(0.95) avg_click df[click_cost].mean() print(f成功率: {success_rate:.2%}) print(f平均点击耗时: {avg_click:.3f}s) print(fP95 点击耗时: {p95_click:.3f}s)图表我一般会用 matplotlib 生成点击耗时的分布图和成功率变化的折线图。压测结果跑出来之后需求方最关心的就是到底有没有超卖页面卡不卡接口有没有报错指标有了图表能直接展示变化趋势比贴一堆数据更直观。5. 实操踩坑记录与观点输出5.1 五大高频问题排查速查表项目做多了遇到的问题基本都集中在几个点上。我整理了一张速查表这些都是我实际跑项目时反复遇到的。问题现象排查思路解决方案页面打开了按钮一直定位不到页面版本有差异或元素被动态渲染优先用文本内容定位别用动态ID确认是否在 iframe 里点击按钮后页面没有反应有遮罩层拦截事件或按钮并未真正可点用page.locator检查遮罩层隐藏或 JS 直接触发 click 事件并发高了之后浏览器内存暴涨浏览器上下文创建太多资源未释放控制并发度跑完一个用户立即context.close()网络拦截不生效请求走了 Service Worker 或协商缓存路由page.route设置前先关闭缓存中间件页面显示异常但脚本不报错前端对异常数据有兜底处理检查 console 日志录制 trace 排查渲染过程第二项里有一个很烦人的坑用seckill_btn.click()明明返回成功了页面却没有任何反应查下来发现实际被屏蔽在某个遮罩层里。Playwright 会自动滚动元素到视口并检查可交互性但碰上透明遮罩它判断的是顶层元素能否接收事件不会去区分是不是透明。如果你也遇到这类现象建议点击前定位遮罩层的 class 或 id判断它是否 visible若是就隐藏掉再点击。5.2 对压测工具定位的反思做完了整个秒杀压测项目我最大的感受是工具只是手段核心还是对业务场景的理解。Playwright 再强也得你明白要等什么状态、拦什么接口、改什么数据才发挥得出价值。从技术选型上看这个方案的价值在于把协议层压测和真实浏览器行为两条链路打通了。传统压测工具测服务端单点能力很合适但测不了前端交互链路Selenium 能模拟浏览器但并发和网络控制能力偏弱。Playwright 站的位置刚好是中间既能模拟真实浏览器又提供不错的并发和拦截能力。对像我这样不需要几百万 QPS、只需要验证几百个真实用户并发表现的团队来说是性价比最高的方案。当然它也有局限比如单机并发受限于浏览器实例的资源消耗开太多 goroutine 或线程会让系统负载飙升。要模拟上万级用户并发还是得分布式调度脚本或者改用更底层的协议压测。这个边界得心里有数。5.3 后续可以扩展的方向这个秒杀压测机器人做出来后基础上还是有很多扩展空间的。第一个方向是接入 CI/CD 流水线把压测作为发版前的预发布检查项每次迭代后自动跑一个小规模的冒烟级压测提前发现页面交互层面的回归问题。第二个方向是把压测结果采集回来后自动生成标准报告并推送到团队群节省人工整理数据的时间。第三个方向是沉淀成一套场景配置库以后不管接到多少种电商页面只需要配置接口路径、按钮选择器、成功标志这几个参数就能快速套用。我在实际使用中发现做得越通用后期维护成本反而越低。脚本最开始的时候我把页面上所有定位器都写死在代码里换一个平台就要复制脚本改一遍维护成本高到怀疑人生。后来抽了一层页面描述配置把按钮选择器、倒计时选择器、成功标识、接口前缀全部放到 JSON 配置文件里秒杀机器人直接读配置跑再换页面只需要改配置文件代码一行都不用动。最后再分享一个小技巧。测试环境和生产环境的页面结构常常不一致我在脚本里加了一个环境开关通过环境变量指定是测试库还是生产库走不同的配置映射。这个细节让我避免了多次本地能跑、线上就挂的尴尬也让压测数据在测试环境和生产环境之间有了可比性。如果你也准备做一个类似的电商压测机器人建议从第一天起就按这个思路来设计后面会省下不少时间。