问题背景
项目需要通过并发方式对文件上传接口做压力测试,测试时从请求的数据发现这个接口的响应时间明显不正常,并发时最长耗时需要25s,由此逐步分析存在的问题。
问题分析
排除服务端问题
从服务端日志看出,该请求在服务端的实际响应时间约0.1秒,且在注释服务端保存文件相关代码,仅处理请求的情况下,客户端依然高延时,由此可确认此问题与服务端无关;
排除IO问题
起初上传文件接口测试的逻辑是创建一个本地文件,写入内容后将IO作为请求参数传递,这里推测是否并发情况下客户端IO阻塞导致缓慢。
因此修改测试逻辑为手动创建一个IO对象,不对本地文件做操作,此方法未生效,客户端延迟依旧,因此排除由于文件读写导致的IO问题;
尝试不同请求库
首先尝试更新请求库版本,当前使用的是httpx 0.24版本,更新至0.28.1,问题未解决;
尝试更换请求库为requests,问题现象消失 !
这里很奇怪,尝试了requests和httpx在所有请求参数完全一致的情况下,仅有httpx在并发条件下出现请求延迟异常的问题,而项目整体更换请求库成本过高,因此继续分析httpx的问题根因;
根因分析
首先在该请求函数中增加性能分析工具pyinstrument,用于找出阻塞的节点,代码如下:
python
from pyinstrument import Profiler
from pyinstrument.renderers.html import HTMLRenderer
fake_file = BytesIO(f"TEST LOG FOR {ref}".encode("utf-8"))
fake_file.name = f"{ref}.log"
with Profiler(interval=0.001, async_mode="disabled") as profiler:
start_time = time.time()
resp = self.client.post(
"/native/upload",
headers={
"Content-Type": "multipart/form-data; boundary=----WebKitFormBoundarybsdsr23r13Zu0gWI&T*&GDF"
},
files={"file": fake_file}
).raise_for_status()
Path(f".profile/{time.time()-start_time}.html").write_text(
profiler.output(renderer=HTMLRenderer())
)
运行结束后,观察性能报告,发现阻塞点在httpx的build_request函数,耗时函数为guess_type
其实看到这个函数名称就能猜到大概是什么问题了,多半就是在推测文件类型或是编码格式导致耗时 ,这里继续从httpx源码中分析怎么解决这个问题;
先看阻塞位置的代码,看怎样才能使其不执行guess_type:
python
class FileField:
"""
A single file field item, within a multipart form field.
"""
CHUNK_SIZE = 64 * 1024
def __init__(self, name: str, value: FileTypes) -> None:
self.name = name
fileobj: FileContent
headers: dict[str, str] = {}
content_type: str | None = None
# This large tuple based API largely mirror's requests' API
# It would be good to think of better APIs for this that we could
# include in httpx 2.0 since variable length tuples(especially of 4 elements)
# are quite unwieldly
if isinstance(value, tuple):
if len(value) == 2:
# neither the 3rd parameter (content_type) nor the 4th (headers)
# was included
filename, fileobj = value
elif len(value) == 3:
filename, fileobj, content_type = value
else:
# all 4 parameters included
filename, fileobj, content_type, headers = value # type: ignore
else:
filename = Path(str(getattr(value, "name", "upload"))).name
fileobj = value
if content_type is None:
content_type = _guess_content_type(filename)
这里可以看出,在传入的value中不附带content_type的时候,他会调用_guess_content_type来推测这个参数,而value从上级函数中可以看出,对应的是httpx请求中的files参数的value。因此,解决办法就是在这个参数中,除了IO对象外,content_type参数也一并提供。
解决方案
修复后的代码如下:
python
resp = self.client.post(
"/native/upload",
headers={
"Content-Type": "multipart/form-data; boundary=----WebKitFormBoundarybsdsr23r13Zu0gWI&T*&GDF"
},
files={"file": (f"{ref}.log", fake_file, "application/octet-stream")} # 增加content_type
).raise_for_status()
修复后,再次执行压力测试,可见问题已解决;