1. 问题背景:一个查询接口为何要等3秒
上周四,运维同事发来告警:订单详情接口在双11预热流量下P99延迟飙升到3.2秒,数据库CPU没满,但应用线程池全部阻塞。查了链路追踪,发现问题不在SQL,而在接口内部串行调用了5个下游服务——用户服务、商品服务、优惠券服务、物流服务、风控服务,每个平均耗时400-600ms。
当时的代码用requests库同步调用,逻辑简单但致命:
# 旧代码:串行HTTP调用,总耗时 = 5次请求耗时之和
def get_order_detail(order_id):
user = requests.get(f"http://user-service/{order_id}/user").json() # 450ms
product = requests.get(f"http://product-service/{order_id}/product").json() # 520ms
coupon = requests.get(f"http://coupon-service/{order_id}/coupon").json() # 380ms
logistics = requests.get(f"http://logistics-service/{order_id}/logistics").json() # 610ms
risk = requests.get(f"http://risk-service/{order_id}/risk").json() # 430ms
return assemble(user, product, coupon, logistics, risk)
这5个服务之间没有任何依赖关系,完全可以并行。但requests库是同步阻塞的,线程池被占满后,后续请求只能排队。Flask默认线程池只有40个线程,200并发直接雪崩。
2. 环境与版本:Python 3.10 + FastAPI替代Flask
既然要做异步化,首先得换框架。Flask不支持原生异步视图,虽然可以用flask[async]补丁,但生产环境不推荐。我选了FastAPI(0.104.1)+ uvicorn(0.24.0),Python版本3.10.12,因为asyncio在3.10后API稳定,且支持asyncio.timeout(3.11+)但3.10需要asyncio.wait_for。
关键依赖版本:
- httpx 0.25.1(支持HTTP/2和连接池)
- uvloop 0.19.0(事件循环替换,性能提升约15%)
- gunicorn 21.2.0(配合uvicorn worker)
3. 方案设计:asyncio.gather并发调用+信号量限流
核心思路:将所有HTTP调用改为httpx.AsyncClient,用asyncio.gather并发执行5个协程。但直接并发有风险——下游服务可能扛不住突发流量,所以需要asyncio.Semaphore限制最大并发数(这里设置20)。
另外必须处理超时:原代码没有超时,导致下游慢请求拖死线程。现在统一用httpx.Timeout(5.0),并在每个子任务添加独立超时(3秒),避免一个服务卡死影响整体。
# 新代码:异步并发+信号量限流+超时控制
import asyncio
import httpx
async def fetch_with_semaphore(sem, client, url):
async with sem:
resp = await client.get(url)
return resp.json()
async def get_order_detail_async(order_id):
urls = [
f"http://user-service/{order_id}/user",
f"http://product-service/{order_id}/product",
f"http://coupon-service/{order_id}/coupon",
f"http://logistics-service/{order_id}/logistics",
f"http://risk-service/{order_id}/risk",
]
sem = asyncio.Semaphore(20)
timeout = httpx.Timeout(5.0, connect=2.0)
async with httpx.AsyncClient(timeout=timeout, limits=httpx.Limits(max_connections=50)) as client:
tasks = [fetch_with_semaphore(sem, client, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return assemble(*results)
4. 核心实现:FastAPI异步接口+uvicorn性能调优
在FastAPI中,视图函数定义为async def,FastAPI自动放入事件循环。但要注意:如果视图内部有同步阻塞操作(如time.sleep或requests.get),会阻塞整个事件循环。所以必须确保所有IO都是异步的。
# FastAPI异步视图
from fastapi import FastAPI
import uvicorn
app = FastAPI()
@app.get("/api/orders/{order_id}")
async def order_detail(order_id: str):
try:
data = await get_order_detail_async(order_id)
return {"code": 0, "data": data}
except Exception as e:
return {"code": 500, "msg": str(e)}
if __name__ == "__main__":
# uvicorn配置:4个worker进程,每个进程一个事件循环
uvicorn.run(app, host="0.0.0.0", port=8000, workers=4, loop="uvloop")
Gunicorn启动命令(生产推荐):
gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 --threads 1
这里有个关键点:--threads 1,因为uvicorn worker本身是异步的,不需要额外线程。如果开了线程反而会降低性能。
5. 踩坑与优化:连接池复用和uvloop的坑
坑1:每请求创建AsyncClient
最初我在get_order_detail_async里每次创建AsyncClient,导致连接频繁建立/断开,性能反而下降。解决:用FastAPI的lifespan事件全局创建共享client:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
app.state.client = httpx.AsyncClient(timeout=httpx.Timeout(5.0), limits=httpx.Limits(max_connections=100))
yield
await app.state.client.aclose()
坑2:asyncio.Semaphore默认值太激进
第一次压测时把信号量设为50,下游服务直接超时报警。改为20后,P99稳定在500ms内。信号量需要根据下游实际吞吐调整,建议压测时从10开始递增。
坑3:uvloop在gunicorn下失效
uvicorn.run(loop="uvloop")在本地有效,但gunicorn启动时不会自动加载。需要在uvicorn worker配置中指定:--loop uvloop。否则用的是默认的asyncio事件循环,性能差约12%。
坑4:return_exceptions=True丢失异常细节
我用了asyncio.gather(... return_exceptions=True),但这样如果某个子任务失败,返回的是exception对象,而不是数据。我在assemble函数里加了类型判断,遇到exception返回降级数据(如空对象),不阻断整体接口。
6. 效果数据:全面对比
压测工具:wrk 4.2.0,200并发,持续60秒,场景是模拟真实流量(5次下游调用)。
| 指标 | 旧代码(Flask+requests) | 新代码(FastAPI+asyncio) | 提升 |
|---|---|---|---|
| QPS | 128 | 847 | 6.6倍 |
| 平均延迟 | 1580ms | 236ms | 85%↓ |
| P99延迟 | 3200ms | 480ms | 85%↓ |
| 超时率(>2s) | 23.5% | 0.3% | -98.7% |
| 线程/协程占用 | 40线程全阻塞 | 20协程+4进程 | - |
额外测试:单独对uvloop做对比(同样代码,默认事件循环 vs uvloop),QPS从735提升到847,增幅15.2%。内存占用从920MB降至640MB(因为不需要大量线程)。
7. 总结:异步化不是银弹,但IO密集场景真香
这次改造最大的收益不是QPS数字,而是资源利用率。原来40个线程被IO阻塞白白等待,现在4个进程+事件循环把CPU用满,单机即可支撑原先3台机器的流量。
但要注意:如果代码里有CPU密集型计算(如复杂JSON解析、加密),异步化会适得其反,因为协程切换无法利用多核。这种情况下应该用多进程或asyncio.to_thread把计算丢到线程池。
最后推荐一个排查工具:py-spy dump --pid可以查看协程运行状态,比看线程栈更直观。改造后记得用curl -w "%{time_total}"做冒烟测试,确认延迟真的降下来了。
附录:assemble函数降级逻辑
def assemble(*results):
data = {}
for i, r in enumerate(results):
if isinstance(r, Exception):
data[keys[i]] = None
else:
data[keys[i]] = r
return data