一、问题背景
事情起因很简单。我们有个内部API,功能是根据用户ID聚合查询订单信息,需要同时调用三个下游:订单服务(HTTP)、用户服务(HTTP)、商品库存服务(PostgreSQL)。上线后监控一直报警,P99延迟1.2秒,压测QPS只有120。
我先看了下原来的代码,典型的问题写法:
# before_api.py
import requests
import psycopg2
from fastapi import FastAPI
app = FastAPI()
DB_CONN = psycopg2.connect(
host="10.0.1.20", port=5432, dbname="shop",
user="api", password="xxx"
)
@app.get("/order/{user_id}")
def get_order_detail(user_id: int):
# 串行调用三个下游
order_resp = requests.get(f"http://order-svc/orders?user_id={user_id}", timeout=3)
orders = order_resp.json()
user_resp = requests.get(f"http://user-svc/users/{user_id}", timeout=3)
user = user_resp.json()
# 同步DB查询
cur = DB_CONN.cursor()
cur.execute("SELECT sku_id, stock FROM inventory WHERE user_id = %s", (user_id,))
stocks = cur.fetchall()
cur.close()
return {"user": user, "orders": orders, "stocks": stocks}
问题一眼就能看出来:
- 三个下游串行调用,总耗时 = 三者之和
requests是同步阻塞库,FastAPI虽然用def会丢到线程池,但线程池默认只有40个worker,很容易打满psycopg2全局单个连接,多线程共享会出问题,而且没有连接池- 没有超时重试,下游抖动直接拖垮整个接口
二、环境与版本
- Python: 3.11.6(3.11的asyncio性能比3.8好一大截,尤其Task创建开销)
- FastAPI: 0.109.0
- uvicorn: 0.27.0,启动参数
--workers 4 --loop uvloop - httpx: 0.26.0(支持HTTP/2和连接池)
- asyncpg: 0.29.0
- 压测工具: wrk 4.2.0,
wrk -t8 -c200 -d30s
机器配置:4C8G,下游服务在同一内网。
三、方案设计
核心思路就三点:
1. 把串行改并行。 三个下游之间没有依赖关系,用asyncio.gather并发。理论上耗时从 t1+t2+t3 变成 max(t1,t2,t3)。
2. 全链路异步化。 requests → httpx.AsyncClient,psycopg2 → asyncpg。任何一个同步阻塞点都会卡住整个event loop,这是asyncio最容易翻车的地方。
3. 连接池复用。 httpx的AsyncClient要全局单例复用,asyncpg用create_pool,避免每次请求都建连接。
关于异常处理,我没用默认的gather,因为它一个任务抛异常会直接冒泡,其他任务的结果拿不到。我用return_exceptions=True,然后在结果里判断,保证部分下游挂掉时接口还能返回降级数据。
四、核心实现
改造后的代码:
# after_api.py
import asyncio
import asyncpg
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
# 全局资源
http_client: httpx.AsyncClient = None
pg_pool: asyncpg.Pool = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global http_client, pg_pool
# 连接池参数:根据下游承载能力调,max_connections别超过下游限流
limits = httpx.Limits(
max_connections=200,
max_keepalive_connections=50,
keepalive_expiry=30.0,
)
timeout = httpx.Timeout(connect=0.5, read=2.0, write=1.0, pool=1.0)
http_client = httpx.AsyncClient(limits=limits, timeout=timeout, http2=True)
pg_pool = await asyncpg.create_pool(
host="10.0.1.20", port=5432, database="shop",
user="api", password="xxx",
min_size=10, max_size=50,
command_timeout=2.0,
)
yield
await http_client.aclose()
await pg_pool.close()
app = FastAPI(lifespan=lifespan)
async def fetch_orders(user_id: int):
resp = await http_client.get(f"http://order-svc/orders", params={"user_id": user_id})
resp.raise_for_status()
return resp.json()
async def fetch_user(user_id: int):
resp = await http_client.get(f"http://user-svc/users/{user_id}")
resp.raise_for_status()
return resp.json()
async def fetch_stocks(user_id: int):
async with pg_pool.acquire() as conn:
rows = await conn.fetch(
"SELECT sku_id, stock FROM inventory WHERE user_id = $1", user_id
)
return [dict(r) for r in rows]
@app.get("/order/{user_id}")
async def get_order_detail(user_id: int):
# 并发执行,return_exceptions=True 保证单个失败不影响整体
results = await asyncio.gather(
fetch_orders(user_id),
fetch_user(user_id),
fetch_stocks(user_id),
return_exceptions=True,
)
orders, user, stocks = results
degraded = []
if isinstance(orders, Exception):
degraded.append("orders")
orders = []
if isinstance(user, Exception):
degraded.append("user")
user = {"user_id": user_id, "name": "unknown"}
if isinstance(stocks, Exception):
degraded.append("stocks")
stocks = []
# 全挂才算失败;部分失败返回降级数据 + 标记
if len(degraded) == 3:
raise HTTPException(status_code=503, detail="all upstreams unavailable")
return {
"user": user,
"orders": orders,
"stocks": stocks,
"degraded": degraded,
}
关键点说明:
lifespan里初始化连接池,全局复用,不要在每个请求里AsyncClient(),那样等于每次都新建TCP连接,性能还不如同步。httpx.Timeout分阶段设置,connect=0.5防止TCP握手卡住,read=2.0是业务容忍上限。return_exceptions=True配合类型判断,实现优雅降级。- asyncpg的
$1占位符和psycopg2的%s不一样,改的时候容易漏。
五、踩坑与优化
坑1:AsyncClient在请求里创建。 我第一版图省事,在每个fetch_xxx里async with httpx.AsyncClient() as c,结果QPS只到400。原因是每条请求都要建TLS握手和连接,复用连接池后直接翻4倍多。
坑2:同步库偷偷阻塞。 我们代码里有个日志模块用了logging.FileHandler,高并发下写文件是同步IO,会短时间卡住event loop。后来改成QueueHandler+后台线程写,或者直接用aiologger。
坑3:连接池配置反了。 一开始max_connections=500,结果下游订单服务被打挂,因为我们的并发请求瞬间涌过去。连接池的max不是越大越好,要跟下游的承载能力匹配。最终定在200。
坑4:gather的异常吞掉。 不加return_exceptions=True时,一个下游超时会导致整个接口500,其他两个已经拿到的数据也白费了。加了之后要做类型判断,isinstance(x, Exception),注意BaseException和Exception的区别,asyncio.CancelledError在3.8+是BaseException子类。
坑5:uvloop必须显式启用。 默认的asyncio事件循环在Linux上性能一般,uvicorn --loop uvloop 后QPS能再涨15%左右。
坑6:CPU密集任务别丢进asyncio。 我们有个JSON序列化用了自定义encoder比较重,在event loop里跑会拖慢所有请求。这种用loop.run_in_executor丢线程池,或者干脆用orjson。
六、效果数据
压测条件:wrk -t8 -c200 -d30s,下游mock服务固定延迟50ms。
| 指标 | Before (sync) | After (asyncio) | 提升 |
|---|---|---|---|
| QPS | 120 | 1800 | 15x |
| P50延迟 | 680ms | 42ms | -94% |
| P99延迟 | 1210ms | 78ms | -94% |
| CPU使用率 | 85% (线程切换多) | 45% | - |
| 内存 | 320MB | 210MB | -34% |
单请求耗时从串行的50+50+30=130ms变成并发的max(50,50,30)=50ms,加上连接池复用省掉的握手时间,理论值就是这样。QPS 1800是4个worker的总和,单worker约450。
七、总结
asyncio不是什么银弹,它解决的是IO等待问题。如果你的接口是CPU密集的,用asyncio反而更慢。判断标准很简单:看你的请求耗时里,有多少比例是在等网络、等磁盘、等DB。
这次改造的核心就三句话:能并行的别串行,能用异步库的别用同步库,连接池要全局复用并匹配下游容量。
最后提醒一点,asyncio的调试比同步代码难,异常堆栈经常断在gather里看不出是哪条挂了。建议在fetch_xxx里加日志埋点,记录每个下游的耗时,出问题时能快速定位。我们后来接了OpenTelemetry,每个协程的span都能追踪,排查效率高很多。