Python 并发请求性能优化实战

前言

在自动化测试和日常运维中,我们经常需要从第三方平台(如 Allure、Jenkins)批量拉取数据。面对成百上千条测试用例,如果采用传统的串行请求,程序往往会因为漫长的网络等待而变得极其低效。

本文将基于一个真实的业务场景------"批量提取 Allure 测试报告数据",通过对比串行、多线程与异步协程三种不同的 Python 代码实现逻辑,带你深入理解它们的区别,并找到最适合你的性能优化方案。


场景一:传统串行请求(基础版)

在最初的业务需求中,我们需要从 Allure 报告服务中拉取指定报告的所有测试用例详情。最直观的实现方式是串行执行

核心逻辑

  1. 获取目录树 :首先请求 packages.json,获取整个测试报告的目录结构。
  2. 递归提取 UID :通过递归算法遍历树形结构,提取出所有叶子节点的 uid
  3. 逐个请求详情 :使用 for 循环,拿着 uid 逐个拼接 URL,发起 HTTP GET 请求获取用例详情。

代码亮点与痛点

在这份代码中,开发者展现了良好的工程素养:

  • 常量提取 :将 REPORT_ID 和 URL 模板提取为常量,便于后续维护。
  • 防御性编程 :在 request_get 中加入了 try-excepttimeout,并在递归函数中处理了节点无 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

核心逻辑

  1. 保持原有的数据解析逻辑不变。
  2. 将所有待请求的 URL 提前组装好。
  3. 使用 ThreadPoolExecutor 创建线程池(设置 max_workers=50),通过 submit 批量提交请求任务。
  4. 使用 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 生态中最优雅的解决方案。

核心逻辑

  1. 将同步的 requests 替换为异步的 aiohttp.ClientSession
  2. 引入 asyncio.Semaphore(50) 信号量,严格控制同一时间最多只有 50 个并发请求,防止瞬间打满服务器连接数。
  3. 使用 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 密集型任务
代码复杂度 ⭐⭐ ⭐⭐⭐

总结与选型建议

  1. 日常小工具/脚本 :如果数据量在几十个以内,串行方案完全足够,代码最简单,维护成本最低。
  2. 遗留系统改造/中等并发 :如果现有代码全是同步逻辑,且并发量在百级别,多线程方案是性价比最高的选择,改动小且效果显著。
  3. 高并发/海量数据 :如果面临成千上万条数据的抓取,或者需要构建高性能的 API 网关、爬虫,异步协程方案是不二之选。

💡 最佳实践提示 :在实际生产环境中,无论使用哪种并发方案,务必控制并发上限 (如代码中的 max_workers=50Semaphore(50)),并设置合理的 timeout,以免压垮目标服务器或导致自身程序假死。

相关推荐
陈同学xxx1 小时前
Hermes Agent 接入飞书,在手机上随时聊天
linux·python·飞书
卷无止境1 小时前
Python 中基于 Qt 的 GUI 库授权方式全解析
后端·python
爱喝水的鱼丶2 小时前
SAP-ABAP:ALV字段布局定制全攻略——快速适配业务端个性化展示需求
运维·性能优化·sap·abap·经验交流·alv报表
卷无止境2 小时前
Guardrails.ai:为大语言模型加一道"安全阀"
后端·python
汤姆小白6 小时前
01-环境搭建与项目导览
人工智能·python·机器学习·numpy
向日的葵00612 小时前
langchain的Tools教程(三)
python·langchain·tools
壮哥_icon13 小时前
【Android 系统开发】从掉帧卡顿到丝滑:高频硬件中断(IRQ)的 CPU 亲和性性能优化实战
android·性能优化
千里马学框架13 小时前
Android Framework 新手学习踩坑建议指南
android·智能手机·性能优化·framework·aaos·车载开发·系统工程师
言乐613 小时前
Python实现可运行解密游戏游戏框架
python·游戏·小程序·游戏程序·关卡设计