一、问题背景
上个月接手了一个订单详情接口,逻辑不复杂:根据订单ID查数据库拿基础信息,然后并发去三个下游服务补齐数据——用户信息、物流状态、支付记录。听起来就是个典型的聚合接口。
但线上监控显示这个接口的P99延迟稳定在1.2s左右,高峰期QPS只能跑到320就开始大量超时。用py-spy dump了一下火焰图,发现90%的时间都耗在等待下游HTTP响应上,CPU占用率却只有8%。
这就很典型了:同步阻塞IO把worker全占住了,Gunicorn开了8个worker,每个worker同时只能处理一个请求,8个并发在扛QPS 320,平均每个请求耗时25ms?不对,一算就知道,320 QPS对应平均耗时 8/320 = 25ms?那P99怎么会1.2s。原因是下游服务偶尔抖动,一旦某个下游响应慢到500ms,worker就被卡住,请求排队,尾延迟直接爆炸。
结论很明确:必须改成异步并发。
二、环境与版本
先交代下技术栈,避免版本差异导致的坑:
- Python 3.11.6(3.10+ 的 asyncio 性能有明显改善,特别是 TaskGroup)
- 原方案:Flask 2.3.3 + requests 2.31.0 + Gunicorn 21.2.0(sync worker)
- 新方案:FastAPI 0.104.1 + httpx 0.25.1 + uvicorn 0.24.0
- 压测工具:wrk 4.2.0,
wrk -t4 -c200 -d60s - 机器:4C8G 容器,下游服务在同一个内网,RTT 约 5ms
选httpx而不是aiohttp,主要是团队已有代码都用httpx,API更统一,而且0.25版本对连接池的支持已经很成熟了。
三、方案设计
改造核心就三点:
- 换异步框架:Flask → FastAPI,用uvicorn跑ASGI
- 下游调用并发化:三个下游请求用
asyncio.gather并发发出,总耗时从 sum(t1,t2,t3) 变成 max(t1,t2,t3) - 数据库访问异步化:原本用SQLAlchemy同步session,换成
asyncpg直连(这一步可选,但既然都异步了,别留同步阻塞点)
关键设计决策:用asyncio.gather还是TaskGroup?我最终用了gather,因为需要return_exceptions=True来容忍单个下游失败,TaskGroup在3.11虽然更优雅,但异常处理语义对"部分失败可接受"的场景不如gather直观。
四、核心实现
Before:Flask同步版本
# app_sync.py
from flask import Flask, jsonify
import requests
app = Flask(__name__)
DOWNSTREAM = {
"user": "http://user-svc/api/user/{uid}",
"logistics": "http://logi-svc/api/logistics/{oid}",
"payment": "http://pay-svc/api/payment/{oid}",
}
@app.route("/api/order/")
def get_order(order_id):
# 1. 查数据库(同步)
order = db.query_one("SELECT * FROM orders WHERE id=%s", order_id)
if not order:
return jsonify({"error": "not found"}), 404
# 2. 串行调用三个下游 —— 这就是瓶颈
user = requests.get(DOWNSTREAM["user"].format(uid=order["user_id"]), timeout=1).json()
logistics = requests.get(DOWNSTREAM["logistics"].format(oid=order_id), timeout=1).json()
payment = requests.get(DOWNSTREAM["payment"].format(oid=order_id), timeout=1).json()
return jsonify({"order": order, "user": user,
"logistics": logistics, "payment": payment})
启动命令:gunicorn -w 8 -k sync -b 0.0.0.0:8000 app_sync:app
假设三个下游各耗时 50ms,这个接口光下游就要 150ms,加上DB查询,单请求200ms+。8个worker,理论QPS上限 8/0.2 = 40?实测320是因为下游有缓存,实际RTT更短,但量级对得上。
After:FastAPI + asyncio版本
# app_async.py
import asyncio
import httpx
from fastapi import FastAPI, HTTPException
import asyncpg
app = FastAPI()
# 全局共享的httpx客户端,复用连接池
client: httpx.AsyncClient | None = None
pool: asyncpg.Pool | None = None
@app.on_event("startup")
async def startup():
global client, pool
# 关键配置:限制连接数,避免打爆下游
limits = httpx.Limits(
max_connections=200,
max_keepalive_connections=50,
keepalive_expiry=30.0,
)
client = httpx.AsyncClient(
limits=limits,
timeout=httpx.Timeout(1.0, connect=0.3),
)
pool = await asyncpg.create_pool(
dsn="postgresql://user:pass@db:5432/order",
min_size=5, max_size=20,
)
@app.on_event("shutdown")
async def shutdown():
await client.aclose()
await pool.close()
async def fetch_json(url: str) -> dict:
"""单个下游调用,失败返回空dict,不抛异常"""
try:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
except (httpx.HTTPError, asyncio.TimeoutError) as e:
# 记日志,返回降级数据
return {"_error": str(e)}
@app.get("/api/order/{order_id}")
async def get_order(order_id: int):
async with pool.acquire() as conn:
order = await conn.fetchrow(
"SELECT * FROM orders WHERE id=$1", order_id
)
if not order:
raise HTTPException(status_code=404, detail="not found")
# 三个下游并发发出
user_task = fetch_json(f"http://user-svc/api/user/{order['user_id']}")
logi_task = fetch_json(f"http://logi-svc/api/logistics/{order_id}")
pay_task = fetch_json(f"http://pay-svc/api/payment/{order_id}")
user, logistics, payment = await asyncio.gather(
user_task, logi_task, pay_task,
return_exceptions=False, # fetch_json内部已吞异常
)
return {"order": dict(order), "user": user,
"logistics": logistics, "payment": payment}
启动命令:uvicorn app_async:app --host 0.0.0.0 --port 8000 --workers 4
注意这里workers只开了4个,因为异步模型下单worker就能扛住大量并发连接,开太多反而增加内存和上下文切换开销。
五、踩坑与优化
改造过程中踩了几个坑,记录一下:
坑1:event loop被同步代码阻塞。 一开始我DB层没换,还是用的SQLAlchemy同步session。结果压测时QPS上不去,只有600。用asyncio的debug模式(loop.set_debug(True))发现"Executing took 0.15 seconds"的警告——同步DB查询把loop卡住了。换成asyncpg后QPS直接翻倍。
坑2:连接池没配,下游被打爆。 第一版httpx没设Limits,默认max_connections是100,但uvicorn多worker下每个worker一个client,4个worker就是400连接,下游服务直接502。加上max_connections=200和max_keepalive_connections=50后稳定了。keepalive特别重要,没有它每次请求都重新TCP握手,5ms的RTT能变成15ms。
坑3:gather的异常传播。 最初我让fetch_json直接抛异常,用gather(..., return_exceptions=True),结果拿到的是Exception对象,还得逐个判断类型,代码很丑。改成在fetch_json内部catch并返回降级数据,调用方拿到的永远是dict,清爽很多。
坑4:超时设置。 httpx的timeout要分层设:connect 0.3s,总超时1s。如果只设总超时,连接阶段卡住也会占满1s。还有,asyncio.gather本身没有超时,得靠httpx的timeout,或者外层包asyncio.wait_for。
优化点: 加了一层本地缓存(cachetools.TTLCache),用户信息5秒内复用。这一步把QPS又推高了约15%,因为用户信息查询占了总调用量的40%。
六、效果数据
用wrk压测60秒,200并发,结果对比:
| 指标 | Before (Flask+Gunicorn 8w) | After (FastAPI+uvicorn 4w) | 提升 |
|---|---|---|---|
| QPS | 320 | 2100 | 6.5x |
| P50 延迟 | 45ms | 18ms | 2.5x |
| P99 延迟 | 1200ms | 180ms | 6.7x |
| 平均CPU | 8% | 35% | - |
| 内存 | 420MB | 380MB | - |
P99从1.2s降到180ms是最爽的,因为下游抖动时,异步模型下其他请求不会被阻塞,尾延迟不再雪崩。
有一点要说明:QPS提升不是线性的6.5倍,因为下游服务本身有容量上限,2100 QPS时下游CPU已经到70%了。如果下游能扛,理论上还能更高。
七、总结
这次改造的核心经验:
- 异步不是银弹,但IO密集场景收益巨大。 这个接口90%时间在等IO,改异步立竿见影。如果是CPU密集任务,asyncio帮不上忙,得上多进程。
- 异步要彻底。 留一个同步阻塞点(比如同步DB驱动),整个event loop都会被拖累,收益大打折扣。
- 连接池和超时是必配项。 不配连接池会打爆下游,不配超时会拖垮自己。
- 压测数据比感觉靠谱。 改之前我预估QPS能到1500,实际2100,但也踩了同步DB的坑一度只有600。没有压测根本发现不了。
最后提醒一句:如果你的团队对async/await不熟,别为了性能硬上。异步代码的调试成本、异常栈可读性都比同步差。但这个接口的场景,收益确实值得。
代码已经上线两周,线上P99稳定在200ms以内,没有再出现尾延迟雪崩。