前言
在自动化测试和日常运维中,我们经常需要从第三方平台(如 Allure、Jenkins)批量拉取数据。面对成百上千条测试用例,如果采用传统的串行请求,程序往往会因为漫长的网络等待而变得极其低效。
本文将基于一个真实的业务场景------"批量提取 Allure 测试报告数据",通过对比串行、多线程与异步协程三种不同的 Python 代码实现逻辑,带你深入理解它们的区别,并找到最适合你的性能优化方案。
场景一:传统串行请求(基础版)
在最初的业务需求中,我们需要从 Allure 报告服务中拉取指定报告的所有测试用例详情。最直观的实现方式是串行执行。
核心逻辑
- 获取目录树 :首先请求
packages.json,获取整个测试报告的目录结构。 - 递归提取 UID :通过递归算法遍历树形结构,提取出所有叶子节点的
uid。 - 逐个请求详情 :使用
for循环,拿着uid逐个拼接 URL,发起 HTTP GET 请求获取用例详情。
代码亮点与痛点
在这份代码中,开发者展现了良好的工程素养:
- 常量提取 :将
REPORT_ID和 URL 模板提取为常量,便于后续维护。 - 防御性编程 :在
request_get中加入了try-except和timeout,并在递归函数中处理了节点无uid的异常情况。
然而,它的致命痛点在于"串行等待"。
假设 packages.json 返回了 100 个用例 UID,每次网络请求耗时 0.1 秒。那么仅获取用例详情就需要 100 * 0.1 = 10秒。如果用例达到 1000 个,程序将整整阻塞 100 秒!在此期间,CPU 几乎处于闲置状态,全部时间都在等待网络 I/O。
python
# -*- coding: utf-8 -*-
import requests
# 提取为常量,便于维护
REPORT_ID = "12870038"
BASE_URL = f"http://10.132.1.127:8123/allure/{REPORT_ID}"
PACKAGE_URL = f"{BASE_URL}/data/packages.json"
CASE_URL_TEMPLATE = f"{BASE_URL}/data/test-cases/{{}}.json"
# 递归提取所有测试用例的UID,增加异常处理
def get_case_uid(data):
uids = []
if data.get('children'):
for child in data['children']:
uids.extend(get_case_uid(child))
else:
if 'uid' in data:
uids.append(data['uid'])
else:
print(f"警告:该节点无children且无uid: {data}")
return uids
def request_get(url):
try:
resp = requests.get(url, timeout=10)
return resp.json()
except Exception as e:
print(f"获取package数据失败: {e}")
return None
def main():
package_data = request_get(PACKAGE_URL)
if not package_data:
print("无法获取package数据,程序退出")
return
# 提取所有UID
uids = get_case_uid(package_data)
if not uids:
print("未找到任何测试用例UID")
return
num = 0
for uid in uids:
data = request_get(CASE_URL_TEMPLATE.format(uid))
if data and isinstance(data,dict) and 'fullName' in data:
print(data['fullName'])
num+=1
print(f"共找到用例:{num} 个")
if __name__ == '__main__':
import time
st = time.time()
main()
print(f"共耗时:{time.time()-st}")
场景二:多线程并发(进阶版)
为了打破串行等待的瓶颈,我们引入了 Python 标准库 concurrent.futures 中的 ThreadPoolExecutor。
核心逻辑
- 保持原有的数据解析逻辑不变。
- 将所有待请求的 URL 提前组装好。
- 使用
ThreadPoolExecutor创建线程池(设置max_workers=50),通过submit批量提交请求任务。 - 使用
as_completed动态获取已完成任务的结果。
代码亮点与痛点
- 亮点 :代码改动极小,只需将
for循环替换为线程池提交逻辑即可。as_completed保证了哪个请求先返回,就先处理哪个,最大化利用并发能力。 - 痛点 :虽然 I/O 密集型任务可以绕过 GIL 的限制,但 50 个线程同时运行会带来不可忽视的内存开销和上下文切换成本。如果并发数开到几百上千,系统资源可能会成为新的瓶颈。
python
# -*- coding: utf-8 -*-
import requests
from concurrent.futures import ThreadPoolExecutor,as_completed
# 提取为常量,便于维护
REPORT_ID = "12870038"
BASE_URL = f"http://10.132.1.127:8123/allure/{REPORT_ID}"
PACKAGE_URL = f"{BASE_URL}/data/packages.json"
CASE_URL_TEMPLATE = f"{BASE_URL}/data/test-cases/{{}}.json"
# 递归提取所有测试用例的UID,增加异常处理
def get_case_uid(data):
uids = []
if data.get('children'):
for child in data['children']:
uids.extend(get_case_uid(child))
else:
if 'uid' in data:
uids.append(data['uid'])
else:
print(f"警告:该节点无children且无uid: {data}")
return uids
def request_get(url):
try:
resp = requests.get(url, timeout=10)
return resp.json()
except Exception as e:
print(f"获取package数据失败: {e}")
return None
def main():
package_data = request_get(PACKAGE_URL)
if not package_data:
print("无法获取package数据,程序退出")
return
# 提取所有UID
uids = get_case_uid(package_data)
if not uids:
print("未找到任何测试用例UID")
return
case_urls = [CASE_URL_TEMPLATE.format(uid) for uid in uids]
max_workers = 50
num = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(request_get, url) for url in case_urls]
for future in as_completed(futures):
result = future.result()
if result and isinstance(result, dict) and 'fullName' in result:
print(result['fullName'])
num += 1
print(f"共找到用例:{num} 个")
if __name__ == '__main__':
import time
st = time.time()
main()
print(f"共耗时:{time.time()-st}")
场景三:异步协程(终极版)
面对海量并发,asyncio + aiohttp 是目前 Python 生态中最优雅的解决方案。
核心逻辑
- 将同步的
requests替换为异步的aiohttp.ClientSession。 - 引入
asyncio.Semaphore(50)信号量,严格控制同一时间最多只有 50 个并发请求,防止瞬间打满服务器连接数。 - 使用
asyncio.gather(*tasks, return_exceptions=True)批量收集结果。return_exceptions=True是关键,它确保了即使某个请求抛出异常,也不会导致整个任务列表中断。
代码亮点与痛点
- 亮点 :极致的性能与极低的资源消耗。协程是用户态的轻量级线程,切换成本极低。单线程事件循环模型彻底避免了多线程的锁竞争和上下文切换问题。
- 痛点 :代码复杂度较高,需要理解
async/await语法、事件循环以及信号量的概念。对初学者有一定的学习门槛。
python
# -*- coding: utf-8 -*-
import asyncio
import aiohttp
# 提取为常量,便于维护
REPORT_ID = "12870038"
BASE_URL = f"http://10.132.1.127:8123/allure/{REPORT_ID}"
PACKAGE_URL = f"{BASE_URL}/data/packages.json"
CASE_URL_TEMPLATE = f"{BASE_URL}/data/test-cases/{{}}.json"
# 递归提取所有测试用例的UID,增加异常处理
def get_case_uid(data):
uids = []
if data.get('children'):
for child in data['children']:
uids.extend(get_case_uid(child))
else:
if 'uid' in data:
uids.append(data['uid'])
else:
print(f"警告:该节点无children且无uid: {data}")
return uids
# 异步获取package数据,增加错误处理和超时
async def get_package(session, url):
try:
# aiohttp.ClientTimeout(total=10),表示整个请求(包含连接和读取)的总超时时间为 10 秒。
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
resp.raise_for_status()
return await resp.json()
except Exception as e:
print(f"获取package数据失败: {e}")
return None
# 带信号量的异步请求,参数传递信号量,增加超时
async def safe_fetch(session, url, semaphore):
# 使用信号量作为异步上下文管理器
async with semaphore:
try:
# aiohttp.ClientTimeout(total=10),表示整个请求(包含连接和读取)的总超时时间为 10 秒。
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
response.raise_for_status()
return await response.json()
except Exception as e:
print(f"Error: {e}")
return {}
# 2. 定义主协程,用于编排任务
async def main():
# 创建信号量,限制最大并发数
semaphore = asyncio.Semaphore(50)
async with aiohttp.ClientSession() as session:
# 异步获取package数据
package_data = await get_package(session, PACKAGE_URL)
if not package_data:
print("无法获取package数据,程序退出")
return
# 提取所有UID
uids = get_case_uid(package_data)
if not uids:
print("未找到任何测试用例UID")
return
# 构造所有测试用例URL
case_urls = [CASE_URL_TEMPLATE.format(uid) for uid in uids]
# 批量异步请求
tasks = [safe_fetch(session, url, semaphore) for url in case_urls]
results = await asyncio.gather(*tasks, return_exceptions=True) # # 4. 使用 return_exceptions=True 确保所有任务都能执行完毕
num = 0
for res in results:
# 过滤掉 None (请求失败) 和 Exception (任务异常)
if isinstance(res, dict) and 'fullName' in res:
print(res['fullName'])
num+=1
print(f"共找到用例:{num} 个")
# 4. 启动事件循环(Event Loop)
if __name__ == "__main__":
import time
st = time.time()
asyncio.run(main())
print(time.time() - st)
核心对比总结
实测性能对比 (测试样本:791 条用例)
- 串行方案 :耗时 98.0s (基准线)
- 线程池方案 :耗时 4.0s (性能提升 24.5倍 )
- 异步协程方案 :耗时 2.3s (性能提升 42.6倍 )
综合维度对比
| 对比维度 | (串行 requests) | (多线程 ThreadPool) | (异步协程 asyncio) |
|---|---|---|---|
| 执行模型 | 逐个等待,阻塞主线程 | 线程池并发,受 GIL 影响但 I/O 等待时释放 | 单线程事件循环,非阻塞 I/O |
| 资源开销 | 极低 | 中等(线程创建与上下文切换) | 极低(协程切换开销小) |
| 并发控制 | 无 | max_workers 参数 |
Semaphore 信号量(更精细) |
| 异常处理 | 基础 try-except | 依赖 Future 内部捕获 | return_exceptions=True 全局兜底 |
| 实测耗时 | 98.0s | 4.0s | 2.3s |
| 适用场景 | 数据量极小 (<50条) | 中等规模并发,传统同步代码改造 | 大规模高并发 I/O 密集型任务 |
| 代码复杂度 | ⭐ | ⭐⭐ | ⭐⭐⭐ |
总结与选型建议
- 日常小工具/脚本 :如果数据量在几十个以内,串行方案完全足够,代码最简单,维护成本最低。
- 遗留系统改造/中等并发 :如果现有代码全是同步逻辑,且并发量在百级别,多线程方案是性价比最高的选择,改动小且效果显著。
- 高并发/海量数据 :如果面临成千上万条数据的抓取,或者需要构建高性能的 API 网关、爬虫,异步协程方案是不二之选。
💡 最佳实践提示 :在实际生产环境中,无论使用哪种并发方案,务必控制并发上限 (如代码中的
max_workers=50或Semaphore(50)),并设置合理的timeout,以免压垮目标服务器或导致自身程序假死。