FastAPI与Flask性能对比:异步框架为何在特定场景下反而不如同步?
1. 一个反直觉的性能测试结果最近在几个技术社区和群里看到不少关于FastAPI和Flask性能对比的讨论风向几乎是一边倒FastAPI凭借其异步特性性能碾压Flask。很多文章和教程都在强调对于需要高并发的API服务FastAPI是“不二之选”。然而在一次内部技术选型的基准测试中我们却得到了一个令人大跌眼镜的结果在一个典型的CRUD场景下FastAPI的实际请求处理性能竟然不到Flask的一半。这个结果显然与主流认知相悖。FastAPI基于Starlette天生支持异步理论上在处理I/O密集型任务时应该如鱼得水。而Flask是同步框架在并发请求时容易阻塞。问题出在哪里是测试方法不对还是我们对这两个框架的理解有偏差为了搞清楚这个问题我花了几天时间设计了一系列对比测试深入代码层面去分析最终找到了问题的根源。这篇文章我就来详细拆解这次“性能翻车”事件带你看看在特定场景下FastAPI的“性能强者”光环是如何失效的以及我们该如何理性地看待框架的性能对比。2. 测试环境与基准场景设计要对比性能首先得有一个公平、可复现的测试环境。盲目地跑一个“Hello World”然后下结论是毫无意义的我们必须模拟真实的生产场景。2.1 环境搭建与工具选型我们搭建了一个尽可能干净的测试环境。服务器是一台标准的云主机配置为4核8G。为了避免虚拟化层和操作系统调优的干扰我们使用了最新的Ubuntu LTS版本并关闭了不必要的后台服务。框架版本的选择至关重要我们使用了当时最新的稳定版FastAPI 0.104.1搭配Uvicorn 0.24.0作为ASGI服务器。这是FastAPI生态的标准搭配。Flask 2.3.3搭配Gunicorn 20.1.0作为WSGI服务器并配合gevent 22.10.2这个协程库来实现异步I/O。这是Flask在生产环境中获得高并发的经典方案。数据库方面我们选择PostgreSQL 14并使用asyncpg作为FastAPI的异步驱动psycopg2作为Flask的同步驱动配合gevent的monkey patch使其协程友好。网络层面所有服务均部署在同一台机器上使用localhost通信以排除网络延迟的影响。压力测试工具我们选择了老牌且可靠的wrk。它轻量、高效能产生稳定的压力并且输出结果清晰。我们也会用ab(ApacheBench) 进行交叉验证。测试脚本的核心是模拟一个典型的“用户查询个人资料并更新最后登录时间”的API。2.2 核心测试用例同步数据库操作这是整个测试中最关键的部分也是颠覆认知的起点。我们设计的接口非常简单接收一个用户ID。根据ID从数据库查询用户信息SELECT * FROM users WHERE id $1。更新该用户的last_login字段为当前时间UPDATE users SET last_login NOW() WHERE id $1。返回查询到的用户信息不含密码。在Flask中这是一个标准的同步视图函数使用psycopg2执行SQL。在Gunicorn中我们使用geventworker来处理并发其原理是当当前请求在等待数据库I/O时worker会挂起该请求转而处理其他请求从而实现“伪异步”或“协程异步”。在FastAPI中我们则“理所当然”地使用了异步视图函数并使用了asyncpg来执行异步的数据库查询和更新。代码如下所示简化版# FastAPI 版本 from fastapi import FastAPI import asyncpg app FastAPI() async def get_db_conn(): # 假设已有连接池 return await asyncpg.connect(...) app.get(/user/{user_id}) async def get_user(user_id: int): conn await get_db_conn() try: # 异步查询 user await conn.fetchrow(SELECT * FROM users WHERE id $1, user_id) # 异步更新 await conn.execute(UPDATE users SET last_login NOW() WHERE id $1, user_id) return dict(user) finally: await conn.close()# Flask gevent 版本 from flask import Flask, jsonify import psycopg2 from gevent import monkey; monkey.patch_all() app Flask(__name__) def get_db_conn(): # 同步连接 return psycopg2.connect(...) app.route(/user/int:user_id) def get_user(user_id): conn get_db_conn() try: cur conn.cursor() # 同步查询但会被gevent优化 cur.execute(SELECT * FROM users WHERE id %s, (user_id,)) user cur.fetchone() # 同步更新 cur.execute(UPDATE users SET last_login NOW() WHERE id %s, (user_id,)) conn.commit() return jsonify(dict(zip([desc[0] for desc in cur.description], user))) finally: conn.close()测试命令我们采用wrk -t4 -c100 -d30s http://localhost:8000/user/1。意思是使用4个线程模拟100个并发连接持续压测30秒。第一次测试结果令人震惊Flask Gunicorn gevent每秒请求数RPS约为1250。FastAPI Uvicorn每秒请求数RPS约为520。FastAPI的性能竟然只有Flask的40%左右这完全不符合预期。我们首先怀疑是连接池的问题于是为两者都加上了连接池asyncpg.Pool和psycopg2.pool.SimpleConnectionPool结果趋势依旧。问题显然不在基础设施上。3. 深入剖析为什么“异步”反而慢了面对这个结果我们必须深入框架和运行时的内部去寻找答案。性能瓶颈通常出现在CPU、I/O或者框架开销上。通过py-spy等性能分析工具进行采样我们发现了关键所在。3.1 同步数据库驱动在异步上下文中的代价这是最核心的原因。在我们的FastAPI代码中虽然视图函数是async def数据库调用也用了await但这里存在一个巨大的认知陷阱asyncpg是一个纯异步驱动但它与数据库的交互本身在操作系统层面仍然是一个同步的系统调用比如read/writesocket只不过这个调用是非阻塞的。当await conn.fetchrow(...)执行时事件循环Uvicorn使用asyncio会将当前任务挂起注册一个在socket可读时的回调然后去处理其他任务。当数据库返回数据后事件循环收到通知恢复该任务。这个过程涉及多次Python层与事件循环之间的上下文切换task suspension/resumption。而在Flaskgevent的方案中psycopg2在gevent.monkey.patch_all()之后其底层的socket操作被替换成了gevent提供的非阻塞版本。当执行cur.execute()时如果数据没准备好gevent会将当前greenlet微线程挂起并切换到另一个greenlet。这个切换过程完全在用户态进行不涉及事件循环的回调注册与通知机制开销远小于asyncio的任务切换。简单类比gevent像是组织一群工人greenlet在一个车间里协作当一个工人等物料I/O时他直接把手头工作放下另一个工人立刻顶上。而asyncio像是一个工头事件循环管理所有工人工人每次等物料都要先报告工头工头记下来等物料到了再叫他的名字。后者的管理开销明显更大。在我们的这个密集I/O连续两次数据库查询且每次I/O耗时并不长的场景下asyncio上下文切换的开销竟然超过了I/O等待本身的时间导致了性能的劣化。这就像是你为了买一杯咖啡开车去咖啡店I/O只花了5分钟但找停车位、排队付款框架开销花了10分钟。3.2 Gunicorn与Uvicorn的Worker模型差异第二个重要因素是服务器的工作模型。Gunicorn gevent我们使用的是gunicorn -k gevent -w 4。这里启动了4个worker进程每个进程内通过gevent可以处理成千上万的并发连接。Worker进程数是根据CPU核心数设置的充分利用了多核。Uvicorn默认情况下Uvicorn是单进程单线程的靠一个asyncio事件循环来处理所有请求。虽然可以通过--workers 4启动多个工作进程但每个进程内部仍然是单个事件循环。在我们的测试中为了公平Uvicorn也使用了4个workeruvicorn main:app --workers 4。但问题在于asyncio事件循环对于CPU密集型操作并不友好。我们的视图函数虽然主要是I/O但在解析请求、构造响应、序列化JSON时仍然需要CPU。当多个请求同时在单个事件循环中竞争CPU时间时会引入额外的调度延迟。而Gunicorn的多个worker进程是独立的操作系统进程由操作系统内核直接调度在多核CPU上的并行度更好。3.3 框架本身的开销对比第三个因素是框架自身的额外开销。FastAPI提供了强大的功能如自动请求验证、序列化、OpenAPI文档生成等。这些功能在带来便利的同时也增加了每个请求的处理路径长度。Flask则相对“轻量”WSGI的标准流程更直接。为了验证这一点我们设计了一个“纯响应”测试即接口直接返回一个固定的JSON字符串不涉及任何I/O操作。端点GET /ping-return {message: pong}结果Flask: ~ 8500 RPSFastAPI: ~ 7200 RPS在这个场景下FastAPI仍然慢一些但差距约15%远小于涉及数据库I/O时的差距超过50%。这说明在纯CPU逻辑的极简场景下框架开销的差异存在但不是主导因素。一旦混入I/O并且I/O模式与异步框架的调度开销不匹配时问题就会被急剧放大。4. 场景还原何时FastAPI能真正发挥威力那么FastAPI就一无是处了吗绝对不是。我们的测试揭示了一个特定边界条件下的问题但FastAPI的设计优势在另外一些场景下是无可替代的。关键在于理解其适用场景。4.1 I/O等待时间长且离散的场景想象一个需要聚合多个外部API数据的接口。例如查询一个订单详情需要同时调用用户服务获取买家信息商品服务获取商品快照物流服务获取物流状态支付服务获取支付信息在同步的Flask中代码必须顺序执行这四个调用总耗时是四个服务耗时的总和T1T2T3T4。即使使用gevent如果这些外部服务没有使用协程友好的客户端如requests库默认是阻塞的优化效果也有限。而在FastAPI中你可以使用asyncio.gather轻松地将这四个调用并发执行app.get(/order/{order_id}) async def get_order(order_id: str): user_task fetch_user_info(order_id) product_task fetch_product_info(order_id) logistics_task fetch_logistics_info(order_id) payment_task fetch_payment_info(order_id) user, product, logistics, payment await asyncio.gather( user_task, product_task, logistics_task, payment_task ) # 组装数据并返回这样总耗时接近于最慢的那个外部服务的耗时max(T1, T2, T3, T4)。在这种高并发、长延迟、可并行的I/O场景下FastAPI的异步模型能带来数量级的性能提升。我们的测试场景连续两个快速的数据库查询恰恰是异步模型不擅长的“短平快”I/O。4.2 大量空闲连接与Server-Sent Events/WebSockets对于需要维持长连接的场景如实时通知、聊天应用、仪表盘数据流Server-Sent Events或在线游戏WebSocketsFastAPI的异步原生支持具有巨大优势。一个同步的WSGI服务器即使配合gevent为每个空闲的长连接分配一个线程或greenlet在连接数上万时内存和调度开销会变得非常大。而FastAPI基于ASGI单个事件循环就可以轻松管理数万个空闲连接因为连接在没有数据时只是一个注册在事件循环里的文件描述符fd几乎不消耗CPU和内存资源。这对于需要高并发长连接的场景是至关重要的。4.3 正确的FastAPI性能优化姿势如果我们必须在FastAPI中实现我们测试的那个“短平快”数据库查询接口并且要求高性能该怎么办有几种思路使用同步数据库驱动但放在线程池中执行这是最直接的方法。FastAPI允许你在异步函数中通过asyncio.to_thread或run_in_executor将同步的、阻塞的代码如用psycopg2执行SQL丢到一个单独的线程池中运行避免阻塞事件循环。这实际上是把Flaskgevent的模式“搬”到了FastAPI内部。但这样做的代价是失去了纯异步的简洁性并且线程池的切换也有开销。from concurrent.futures import ThreadPoolExecutor import asyncio thread_pool ThreadPoolExecutor(max_workers20) app.get(/user/{user_id}) async def get_user(user_id: int): loop asyncio.get_event_loop() # 将同步的数据库查询函数丢到线程池 user await loop.run_in_executor(thread_pool, sync_fetch_user, user_id) return user审视数据库操作进行批量化或缓存性能问题的根源往往是设计。连续两次查询同一用户是否可以合并为一次更复杂的查询last_login的更新是否可以用更轻量的方式如Redis或定期任务来替代引入缓存如Redis来避免高频的数据库查询往往比选择什么框架带来的提升大得多。使用更高效的异步驱动或连接池配置确保asyncpg的连接池大小配置合理通常建议是max_size略大于你的并发worker数。虽然在我们这个测试中效果不显著但在其他场景下可能是瓶颈。5. 理性看待框架性能对比与选型建议经过这一番折腾最大的收获不是“Flask比FastAPI快”而是**“脱离场景谈性能就是耍流氓”**。框架选型是一个综合性的决策性能只是其中一个维度而且常常不是决定性维度。给开发者的选型建议明确你的应用类型传统的CRUD Web应用逻辑复杂依赖大量同步库Flask/Django可能更合适。生态成熟同步编程模型简单直观遇到问题容易搜索到解决方案。配合Gunicorngevent也能获得不错的并发能力。API网关、微服务、需要聚合大量外部HTTP/GRPC服务FastAPI的优势明显。其异步特性能够轻松处理这类高并发I/O场景并且自动生成API文档的功能对前后端协作非常友好。实时应用、长连接服务优先考虑FastAPI或其他ASGI框架如Django Channels它们在协议支持和资源消耗上有天然优势。性能测试必须模拟真实场景不要用return {Hello: World}来给框架下定论。设计包含你核心业务逻辑数据库查询、外部调用、计算逻辑的测试用例。同时关注延迟P95, P99而不仅仅是吞吐量RPS。一个能提供稳定低延迟的框架比峰值RPS高但延迟抖动大的框架用户体验更好。关注团队技能与开发效率FastAPI的现代特性类型提示、依赖注入、自动文档能极大提升开发体验和代码质量。如果你的团队熟悉Python类型提示和异步编程FastAPI会非常顺手。如果团队更熟悉传统的同步模式强行上马异步可能会引入复杂的调试和维护问题。生态与可维护性Flask发展多年有海量的扩展和社区支持几乎任何需求都能找到现成的轮子。FastAPI生态正在快速成长但相对而言某些小众需求的解决方案可能不如Flask丰富。评估你需要的功能是否有稳定、维护良好的库支持。回到我们最初的测试它之所以有价值是因为它戳破了一个“异步万能”的泡沫。它告诉我们即使是公认的“性能强者”如果使用场景与其优势不匹配也可能表现不佳。技术选型没有银弹最好的框架永远是那个最适合你当前业务场景、团队能力和长期发展目标的框架。下次当你再看到“XX框架性能碾压YY框架”的标题时不妨先问一句“是在什么场景下测试的代码是怎么写的”理解背后的原理和边界比记住一个简单的结论重要得多。