《AI 重塑测试 · 90 天实测日记》第 41 篇 · 结论以本机实跑记录为准
先摆代价。
给一个测试平台加「浏览器任务调度 + 资源回收」,看起来只是加一个队列、一个状态机、一个 finally。真实做下来的代价是:多一套状态要维护,多一套异常要处理,多一套验收要写,而且这些都不会自己暴露问题。
不报错的失败最贵。断言失败会红屏;资源泄漏只会让你的机器慢慢变慢,然后某天早上 CI 全挂,你查到的却是一句「测试超时」。
这一篇讲的是这个复杂度值不值得付,以及什么时候别付。
环境:Windows / Python 3.11.8 / Playwright 1.62.0 / Chromium 151.0.7922.34,全部数字实测。
参考:Playwright Browser API、Playwright BrowserContext。
一、代价一:状态机一旦有五个态,就再也退不回三个
先看最朴素的版本:排队 → 执行 → 结束。三个态,很好懂。
然后真实需求一上来:
- 用户点了取消,要区分「正在取消」和「已取消」
- 执行进程失联,要和「正常失败」分开
- 清理要有自己的状态,因为「任务结束了但资源还没放掉」是真实存在的中间态
于是变成五个态:
QUEUED → RUNNING → FINALIZING → TERMINATED
↘ LOST(失联,不可复用)
这个状态机的代价不在写代码,在维护。 每加一个状态,下面这些都要跟着改:
| 要改的东西 | 为什么 |
|---|---|
| 任务列表展示 | 四种终止原因得显示成四种文案 |
| 重试逻辑 | 哪些状态能重试?LOST 能,TIMEOUT 看情况 |
| 告警规则 | LOST 要叫人,TIMEOUT 不用 |
| 验收用例 | 每个状态都要一条对应用例 |
| 状态回写 | 乱序到达的回报要处理 |
我自己的经验是:状态超过四个的时候,就该考虑是不是把两个维度拆开了。 比如把「运行状态」和「清理状态」分两列存,各自三态,展示时组合。这样加一个状态只影响一列。
但注意,这个拆分也有代价——展示层要组合,排查时要多看一处。 不是免费的重构。
二、代价二:并发调度带来的不是速度,是不确定性
大家的直觉是「并发开大点,跑得更快」。这个直觉在浏览器任务上基本是错的。
同时打开的页面数 = 浏览器实例数 × 每个实例的上下文数。
举例:2 个浏览器实例 × 每个 4 个上下文 = 同时 8 个页面。3 个实例 × 8 个上下文 = 24 个页面。
我本机的经验是同时最多开 8~12 个页面,再多会明显变慢。
⚠️ 这条是经验值,不是实测数据。它和你的 CPU 核数、内存、页面重量强相关。我的机器只能代表我的机器。
更麻烦的是放大效应:一条平台任务内部可能自己开了 4 个测试 worker。调度器只按「任务数」限流的话,实际浏览器数就是任务数的 4 倍。
def 实际页面数(任务列表):
return sum(
任务.配置["上下文数"] * getattr(任务, "内部worker数", 1)
for 任务 in 任务列表
)
限流要卡最终页面数,不是任务数。 这一条不讲清楚,你调了半天并发发现瓶颈根本没变。
并发上去了会发生什么
不是「变慢一点」,是行为变得不可预测:
- 同样一条用例,有时 3 秒有时 30 秒 → 超时阈值只能拍脑袋
- 失败率随并发上升 → 你以为是用例不稳,其实是机器被压垮
- 一条慢任务拖垮一批 → 争抢 CPU 和内存
调度系统的复杂度不是为了变快,是为了把不确定性框起来。 如果你的任务量根本填不满机器,那这套东西是纯成本。
三、代价三:资源回收的正确性,你没法靠读代码确认
这是最贵的一块。
大部分人写回收是这么写的:
try:
...
finally:
上下文.close()
浏览器.close()
看起来很对。但 finally 的覆盖范围是正常返回 + Python 异常。它覆盖不到这三种:
- 进程被 kill(不是正常退出,是被强杀)
- 机器断线 / 断电
close()自己抛异常
这三种情况里,前两种根本不会执行到 finally,第三种会让后面的清理全部跳过。
最容易漏的一行
上下文.close() # 关上下文
浏览器.close() # 关浏览器进程 —— 这一行才是关键
只写第一行,Chromium 进程会一直在机器上跑。 我自己错过这一行,结果一条用例跑完机器上攒了 11 个 Chromium 进程。
所以验收必须是外部的
import subprocess
def 数浏览器进程():
输出 = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq chrome.exe"],
capture_output=True, text=True, encoding="utf-8", errors="ignore",
).stdout
return sum(1 for 行 in 输出.splitlines() if "chrome.exe" in 行)
验收标准就一句:任务列表显示「已结束」时,浏览器进程数必须是 0。
代价在这儿:你多了一套「不在单元测试里、在进程表里」的验收。 这套验收没法在 CI 的单元测试阶段跑,只能在集成环境跑,而且它天生有平台差异(Windows 是 tasklist,Linux 是 ps)。
四、代价四:取消和失联是两个完全不同的工程问题
取消是合作式的——用户按下取消,执行进程收到信号,优雅收尾。这条路好做。
失联是没法合作的——心跳断了,你不知道对面在干什么。
处理失联只有两条路:
| 选择 | 代价 |
|---|---|
| 隔离整台执行机 | 浪费一台机器;恢复要人工确认 |
| 直接复用 | 两个任务抢同一份资源;数据串了可能几个月都查不出来 |
第二条路的代价是不可见的,所以大家倾向于选它。这是错的。
隔离的代价是显性的(少一台机器),复用的代价是隐性的(数据错乱)。工程上应该选显性的那个。
顺带一个便宜的保险:占用令牌
def 申请(执行环境, 任务号):
if 执行环境.当前占用 is not None:
raise RuntimeError("槽位已被占用")
令牌 = uuid.uuid4().hex
执行环境.当前占用 = {"令牌": 令牌, "任务号": 任务号}
return 令牌
def 释放(执行环境, 令牌):
当前 = 执行环境.当前占用
if 当前 is None or 当前["令牌"] != 令牌:
return False # 迟到的释放消息,丢弃
执行环境.当前占用 = None
return True
这个只要几十行,能挡住「A 的释放消息迟到,把 B 的槽位误释放」这类问题。
但要清楚它能干什么、不能干什么:它只解决身份核对,不解决进程是否真的停了。拿它当安全网可以,拿它当进程终止的证据不行。
五、一个纯工程账:卡死的 146 秒和 6 秒
这一节跟调度无关,但我想把它放在这里,因为它最能说明「代价藏在哪」。
我写的测试要起一个本地网页服务。单线程写的:
from http.server import HTTPServer
服务器 = HTTPServer(("127.0.0.1", 8000), 处理类)
浏览器打开页面后会保持一条 keep-alive 长连接,这条连接不关,单线程服务器就不会去接下一个请求。于是我那条 requests.get() 永远排不进来。
表现是:测试卡住,一行报错都没有。 本机实测卡到 300 秒工具超时才发现。
改成多线程:
from http.server import ThreadingHTTPServer
服务器 = ThreadingHTTPServer(("127.0.0.1", 8000), 处理类)
27 个用例从 146 秒降到 6 秒。
这个例子说明的不是「要记得用多线程」,是:很多看起来是调度问题、并发问题、资源回收问题的现象,根因在一个不起眼的地方。 你要是不先排除这些,可能会花两周搭一套调度系统,最后发现该加一个 Threading 后缀。
六、什么时候别做这套东西
这是这一篇最重要的一节。
情况一:任务量小,一次跑不完一天的量
判据很简单:把并发限制成 1,队列会不会一直是空的?
如果是空的,调度器、队列、令牌、隔离逻辑全是纯成本。直接串行跑,一个 with 块就完事。
我自己的建议是:先把并发限制成 1 或少量几个槽位,把完整生命周期(申请 → 使用 → 释放 → 验收残留进程)跑通并留下验收记录,再谈扩展。 这条路交付更快,而且那套验收记录以后还用得上。
情况二:只有一个执行机
单机场景下,「调度」退化成「排队」。而排队你用一个列表就能做。
没有多机,就不用设计分布式那套东西。 数据库约束、跨实例原子性、心跳协议——单进程一个字典就够,把复杂度的账省下来。
情况三:任务是一次性的、排障用的
调试一条用例,要什么调度?起一个浏览器,跑,看,退出。
这种场景下最该做的是把退出路径写干净(关上下文、关浏览器),别的都不做。
情况四:页面很轻,且数量少
如果你的用例只是点两下、读一个数,浏览器启动开销可能比用例本身还大。这时候串行 + 复用浏览器实例是最优解,加并发只会抢 CPU。
一句话判据
先问「我的瓶颈是排队还是执行」。 如果瓶颈在执行(用例本身慢),加并发调度帮不上忙,该去优化用例。如果是排队(机器空着但任务在等),那才轮到调度器上场。
七、如果决定做,这几件事别省
按投入产出排序,先做前三件:
- 释放路径写干净:
上下文.close()→浏览器.close()→播放机.stop(),顺序不能反 - 验收看残留进程数,不靠读代码确认
finally存在 - 四种终止原因分开:
COMPLETED/TIMEOUT/CANCELLED/LOST,别合并成「失败」 - 排队原因写具体:带括号写「(当前 8/8 占用)」,别只写「排队中」
- 占用令牌,防迟到释放
- 失联时隔离执行机,不要复用
- 进度拆成排队 / 启动 / 执行 / 清理四段,别只给一个百分比
第 1、2 条是保命的,3~7 条是锦上添花的。 资源不够的时候,先做 1、2、3。
小结
按代价从高到低:
- 状态机复杂度——五个态之后,每个状态都要在展示、重试、告警、验收里各付一次钱
- 并发不确定性——开大并发不是变快,是让超时阈值和失败率都失去意义
- 回收正确性不可读码验证——只能靠外部进程表,验收天然跨平台
- 失联处理——隔离是显性成本,复用是隐性成本,选显性的
而最该记住的是第五节那个数字:146 秒 → 6 秒。 很多你以为是架构问题的现象,根因在一个不起眼的地方。
什么时候别做? 任务量小、单执行机、一次性排障、页面轻——这四种情况下,直接串行跑,把退出路径写干净就行。
下一篇是 Day42《两个任务同时跑,B 的登录态为什么被踢回登录页》,讲另一个隔离维度:多个任务同时登录同一个系统,登录态怎么不串。
本文出自《AI 重塑测试 · 90 天实测日记》系列(第 41 篇),作者「楠风测开」。系列按天记录测试平台的实测过程、本机环境与未验证项,结论以实跑记录为准。