ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Playwright秒杀压测实战:精准定位与网络拦截模拟高并发

Playwright秒杀压测实战:精准定位与网络拦截模拟高并发

做电商压测这些年,我接过不少秒杀场的需求,但真正把 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(headless=False) page = browser.new_page() page.goto("https://example-seckill.com/item/10001") seckill_btn = page.locator("#seckillBtn") # 第一步:等待按钮出现在视口内 seckill_btn.wait_for(state="visible", timeout=60_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( status=200, content_type="application/json", body=json.dumps(body) ) else: route.continue_() with sync_playwright() as p: browser = p.chromium.launch(headless=False) 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(headless=False) context = browser.new_context( user_agent="Mozilla/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(state="visible", timeout=60_000) # 步骤3:等秒杀倒计时到达0,按钮变为“立即抢购” # 这里用 JS 轮询精确控制,避免多等或少等 t0 = time.time() page.wait_for_function( """() => { const btn = document.querySelector('#seckillBtn'); return btn && btn.textContent.includes('立即抢购'); }""", timeout=60_000 ) wait_cost = time.time() - t0 # 步骤4:点击抢购按钮,并采集响应时间 t1 = time.time() seckill_btn.click() # 等待跳转或下单成功标识出现,这里用一个常见的选择器 page.wait_for_selector(".order-success", timeout=15_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_users=20, concurrency=5, target_time="2024-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_workers=concurrency) 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="", encoding="utf-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_dir="videos", trace_viewport=True, )

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(f"P95 点击耗时: {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 配置文件里,秒杀机器人直接读配置跑,再换页面只需要改配置文件,代码一行都不用动。

最后再分享一个小技巧。测试环境和生产环境的页面结构常常不一致,我在脚本里加了一个环境开关,通过环境变量指定是测试库还是生产库,走不同的配置映射。这个细节让我避免了多次"本地能跑、线上就挂"的尴尬,也让压测数据在测试环境和生产环境之间有了可比性。如果你也准备做一个类似的电商压测机器人,建议从第一天起就按这个思路来设计,后面会省下不少时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进