一、问题背景:一个慢得离谱的聚合接口
接手的一个Flask项目里有个/api/order/summary接口,逻辑很简单:根据订单ID,依次调用用户服务、商品服务、优惠券服务获取数据,然后聚合返回。三个服务都是内网HTTP接口,平均响应时间分别是:用户服务800ms、商品服务1200ms、优惠券服务600ms。串行调用,总耗时稳定在2.6-2.9秒。
线上用Gunicorn部署,4个worker进程,每个worker用默认的sync worker。压测结果惨不忍睹:
- 并发50,QPS 320,P99延迟 4.2s
- 并发100,QPS 350,P99延迟 7.8s,大量timeout
这接口被业务方天天催。最气人的是,三个服务之间完全无依赖,串行调用纯粹是代码偷懒。当时第一反应是用concurrent.futures.ThreadPoolExecutor改并行,但后来测试发现线程切换开销大,而且Gunicorn sync worker下线程池容易把CPU打满。最终选择了asyncio方案——毕竟IO密集型的活儿,协程才是正统解法。
二、环境与版本:Python 3.10 + Flask 2.2 + httpx 0.24
先交代一下改造环境:
- Python 3.10.9(重点:3.10才有的
asyncio.TaskGroup,3.9及以下得用asyncio.gather) - Flask 2.2.3(保持原框架不动,只改视图函数内部实现)
- httpx 0.24.1(比requests更适合asyncio,原生支持async/await)
- Gunicorn 20.1.0 + gevent worker(注意:这里有个坑,后面细说)
- 压测工具:wrk 4.2.0,单机跑
原本的串行代码长这样,简单粗暴:
# before.py - 串行调用三个服务
import requests
from flask import Flask, jsonify
app = Flask(__name__)
USER_SERVICE_URL = "http://user-service.internal/api/user"
PRODUCT_SERVICE_URL = "http://product-service.internal/api/product"
COUPON_SERVICE_URL = "http://coupon-service.internal/api/coupon"
@app.route("/api/order/summary", methods=["GET"])
def order_summary():
order_id = request.args.get("order_id")
# 串行调用,总耗时 ≈ 800ms + 1200ms + 600ms = 2.6s
user_resp = requests.get(f"{USER_SERVICE_URL}?order_id={order_id}", timeout=3)
user_data = user_resp.json()
product_resp = requests.get(f"{PRODUCT_SERVICE_URL}?order_id={order_id}", timeout=3)
product_data = product_resp.json()
coupon_resp = requests.get(f"{COUPON_SERVICE_URL}?order_id={order_id}", timeout=3)
coupon_data = coupon_resp.json()
return jsonify({
"user": user_data,
"product": product_data,
"coupon": coupon_data
})
三、方案设计:asyncio + httpx 并发请求
核心思路很简单:把三个requests.get换成httpx.AsyncClient.get,用asyncio.gather(或3.10的TaskGroup)并发执行。
但有几个设计决策要提前定:
-
连接池复用:
httpx.AsyncClient必须复用,不能每次请求都新建。因为每次创建client会重新建立TCP连接,握手开销在HTTP/1.1下约50-100ms,在HTTP/2下更高。我们设计为模块级单例,配合async with语法。 -
信号量限流:虽然这里是3个并发,但如果接口被频繁调用,每个请求都开3个协程,瞬间并发量会翻3倍。用
asyncio.Semaphore(10)限制单个请求内最大并发数,防止把下游服务打挂。 -
超时控制:
httpx的timeout参数要设置为httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),不能简单设一个float。之前踩过坑,设置timeout=3只代表总体超时,如果某个服务卡住,其他两个协程会跟着等。 -
错误隔离:三个服务任何一个挂了,不能拖垮整个接口。用
return_exceptions=True或TaskGroup的异常捕获,单独处理每个协程的错误。
改造后的核心代码:
# after.py - asyncio + httpx 并发调用
import asyncio
import httpx
from flask import Flask, jsonify, request
app = Flask(__name__)
# 模块级单例,复用连接池
_http_client = None
def get_client():
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
http2=True # 如果下游支持HTTP/2,可以开启
)
return _http_client
async def fetch_service(client, url, order_id):
"""获取单个服务数据,带错误兜底"""
try:
resp = await client.get(f"{url}?order_id={order_id}")
resp.raise_for_status()
return resp.json()
except Exception as e:
# 记录日志,返回空字典,不让单点故障拖垮整个接口
app.logger.error(f"Service {url} failed: {e}")
return {"error": str(e), "service": url}
@app.route("/api/order/summary", methods=["GET"])
async def order_summary():
order_id = request.args.get("order_id")
client = get_client()
# 信号量限制最大并发为10
semaphore = asyncio.Semaphore(10)
async def bounded_fetch(url):
async with semaphore:
return await fetch_service(client, url, order_id)
# Python 3.10+ 推荐 TaskGroup,自动管理协程生命周期
async with asyncio.TaskGroup() as tg:
task_user = tg.create_task(bounded_fetch(USER_SERVICE_URL))
task_product = tg.create_task(bounded_fetch(PRODUCT_SERVICE_URL))
task_coupon = tg.create_task(bounded_fetch(COUPON_SERVICE_URL))
return jsonify({
"user": task_user.result(),
"product": task_product.result(),
"coupon": task_coupon.result()
})
注意:Flask 2.2默认不支持async视图函数,需要pip install flask[async],或者用asgiref的sync_to_async包装。如果不想改Flask版本,可以用asyncio.run()包裹——但那样每次请求都会创建新事件循环,性能会打折扣。这里我直接用了Flask 2.2的async支持,配合ASGI服务器(Uvicorn)运行。
四、Gunicorn部署坑:sync worker与asyncio不兼容
这个坑差点让我放弃asyncio方案。原部署是gunicorn -w 4 app:app,sync worker。改造后接口变成async,但Gunicorn sync worker本身是同步的,它会把async视图函数当成普通函数调用,导致RuntimeError: no running event loop。
解决方案有两种:
-
换用Uvicorn worker:
gunicorn -w 4 -k uvicorn.workers.UvicornWorker app:app。这是最直接的方案,Uvicorn worker专门处理asyncio应用。 -
保留Gunicorn但用gevent worker:
gunicorn -w 4 -k gevent app:app,然后视图函数内部用asyncio.run()。但这样每次请求都创建新事件循环,连接池复用失效,性能打折。实测QPS只有800多,不推荐。
最终我选了Uvicorn worker,配置如下:
gunicorn app:app -w 4 -k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 30 \
--worker-connections 1000 \
--max-requests 2000 \
--max-requests-jitter 100
max-requests和max-requests-jitter是为了防止worker内存泄漏,Uvicorn worker跑久了会有小概率内存增长。
五、踩坑与优化:从600ms到0.6s的三个关键点
1. Event Loop关闭报错
第一次压测时,大量请求报RuntimeError: Event loop is closed。排查发现是httpx.AsyncClient初始化在模块级别,但Gunicorn worker fork进程时,事件循环是父进程创建的。子进程复用了一个已经关闭的loop。
解决:把client初始化延迟到第一次请求时(lazy init),并放在worker进程内。上面代码里的get_client()就是干这个的。另外,Uvicorn worker会在worker退出时自动关闭loop,所以不需要手动关闭client。
2. 超时参数精细化
最初设timeout=3.0,结果发现如果用户服务耗时1.5秒、商品服务耗时1.5秒、优惠券服务耗时0.5秒,总耗时不是2秒而是2.0秒——因为httpx的timeout是总体超时,不是每次请求的超时。三个协程共享同一个超时预算。
解决:改用httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),read超时2秒是单个请求的读取超时,不会影响其他协程。这样即使优惠券服务卡住,用户和商品的请求也能正常返回。
3. 连接池限制
默认httpx.Limits(max_connections=100, max_keepalive_connections=20)。压测QPS高时,并发请求数可能超过100,导致连接池排队。调大连接池后,内存占用上升约30MB,但QPS提升明显。最终定为max_connections=50,因为下游服务有单机连接数限制,超过50会被拒绝。
六、效果数据:QPS提升5.5倍,P99下降85%
用wrk压测,命令:
wrk -t8 -c100 -d30s http://localhost:8000/api/order/summary?order_id=12345
| 指标 | 改造前(串行+sync worker) | 改造后(asyncio+Uvicorn worker) | 提升幅度 |
|---|---|---|---|
| QPS | 320 | 1750 | 5.5倍 |
| 平均延迟 | 2.8s | 0.58s | 79%下降 |
| P99延迟 | 4.2s | 0.9s | 78.6%下降 |
| 错误率 | 2.3% | 0.1% | 96%下降 |
| CPU使用率 | 85% | 65% | 23%下降 |
内存方面,改造前每个worker约120MB(因为三个requests连接各自独立),改造后约150MB(httpx连接池复用),但4个worker总共才多出120MB,可接受。
七、总结:asyncio不是银弹,但IO密集型是真香
这次改造的核心收益不在于“用asyncio”这个动作,而在于将串行IO改为并发IO。如果你还在用concurrent.futures.ThreadPoolExecutor,其实也能达到类似效果,但线程切换和GIL会让CPU更忙。asyncio的优势在于:
- 单线程内并发,无锁竞争,无线程切换开销
- 连接池复用,TCP握手次数从N次降到1次
- 超时控制更精细,
httpx.Timeout可以拆分connect/read/write
但asyncio也有代价:调试更复杂(协程堆栈不直观),需要理解事件循环模型。如果你的项目是纯CPU密集型,asyncio没意义。如果是IO密集且下游服务可并发,asyncio是当前Python生态里性价比最高的方案。
最后给个建议:如果你的Flask应用要上asyncio,别用Gunicorn sync worker,直接上Uvicorn或者Hypercorn。别在兼容性上浪费人生。