1. 问题背景:一个3.2秒的接口,用户真的等不了
上个月接手一个后台管理系统,有个“订单概览”接口,前端每次加载都要转圈3秒多。用户投诉说“点一下按钮,够我泡杯咖啡了”。
接口逻辑不复杂:前端请求过来,后端需要调用三个外部服务——用户服务(查用户信息)、库存服务(查商品库存)、优惠券服务(查可用优惠券),然后把结果组装返回。
原代码长这样:
# 同步版本:串行调用三个外部服务
import requests
def get_order_overview(order_id: str):
# 1. 调用户服务
user_resp = requests.get(f"http://user-service/users/{order_id}", timeout=2)
user_data = user_resp.json()
# 2. 调库存服务
stock_resp = requests.get(f"http://stock-service/stocks/{order_id}", timeout=2)
stock_data = stock_resp.json()
# 3. 调优惠券服务
coupon_resp = requests.get(f"http://coupon-service/coupons/{order_id}", timeout=2)
coupon_data = coupon_resp.json()
return {
"user": user_data,
"stock": stock_data,
"coupon": coupon_data
}
测试环境压测结果:平均响应时间3.2秒,P95 4.1秒,QPS只有50。原因一目了然——三个外部服务调用是串行的,每个服务平均响应1秒,加起来就是3秒多。
当时线上流量稍微大一点,接口就超时,监控面板一片红。
2. 环境与版本:别用旧版本踩坑
动手改造前,先交代一下环境:
Python: 3.10.9
FastAPI: 0.95.2
uvicorn: 0.21.1
httpx: 0.24.1
aiohttp: 3.8.4
强烈建议用Python 3.10+,因为3.10的asyncio对任务取消和超时处理更稳定,而且asyncio.timeout(3.11+才有)虽然好用,但3.10用asyncio.wait_for也够。别用Python 3.8以下,asyncio的bug会折磨死你。
HTTP客户端选httpx而不是requests,因为httpx原生支持async/await,而且连接池复用做得好。aiohttp也行,但我个人觉得httpx的API更接近requests,迁移成本低。
3. 方案设计:三个核心点
改造思路不是简单把requests换成async httpx,而是要考虑三个层面的问题:
3.1 并发执行
三个外部服务之间没有依赖关系,完全可以并发执行。用asyncio.gather把三个协程丢出去,总耗时从“三个之和”变成“三个之最大”。
3.2 连接池复用
每次请求都新建HTTP连接是非常浪费的。httpx的AsyncClient内部实现了连接池,默认连接数上限100,空闲超时5秒。但要注意:AsyncClient要复用,不能每次请求都new一个。
3.3 限流与超时
并发上去之后,如果下游服务扛不住,会把你这边拖死。所以必须加信号量限制并发数,同时每个子请求必须设置超时。
4. 核心实现:改造后的代码
# 异步版本:asyncio + httpx 并发调用
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
# 复用AsyncClient,全局单例
client = httpx.AsyncClient(
timeout=httpx.Timeout(2.0, connect=1.0), # 总超时2秒,连接超时1秒
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20)
)
# 信号量:限制同时最多10个并发外部请求
semaphore = asyncio.Semaphore(10)
async def fetch_with_semaphore(url: str):
async with semaphore:
try:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
except httpx.TimeoutException:
# 超时降级:返回空dict,不让主接口挂掉
return {}
except httpx.HTTPStatusError:
return {}
@app.get("/api/orders/{order_id}/overview")
async def get_order_overview(order_id: str):
# 并发发起三个请求
user_task = fetch_with_semaphore(f"http://user-service/users/{order_id}")
stock_task = fetch_with_semaphore(f"http://stock-service/stocks/{order_id}")
coupon_task = fetch_with_semaphore(f"http://coupon-service/coupons/{order_id}")
# gather等待所有完成,return_exceptions=True防止一个失败拖垮全部
user_data, stock_data, coupon_data = await asyncio.gather(
user_task, stock_task, coupon_task,
return_exceptions=False # 因为fetch内部已经捕获异常,这里不用再兜底
)
return {
"user": user_data,
"stock": stock_data,
"coupon": coupon_data
}
改动就这么点:def变async def,requests.get变client.get,加上asyncio.gather。但性能提升是巨大的。
5. 踩坑与优化:这几个坑我必须说出来
5.1 坑一:事件循环阻塞
第一次改造完,发现响应时间没降多少。查了半天,发现有人在视图函数里用了time.sleep(1)模拟业务逻辑——同步sleep会阻塞事件循环!必须用await asyncio.sleep(1)。记住:异步代码里绝对不能出现同步阻塞调用(time.sleep、requests.get、redis.get如果不同步版本都不能用)。
5.2 坑二:超时设置不合理
一开始我把超时设成timeout=5,结果下游服务2秒没响应,这边就干等2秒。后来改成timeout=httpx.Timeout(2.0, connect=1.0),总超时2秒,连接超时1秒。这样即使下游挂了,最多拖累2秒,而且因为是并发,总耗时还是2秒。
5.3 坑三:信号量用错了位置
一开始我把信号量放在gather外面,导致三个请求虽然并发,但信号量只能放行一个,又变回串行。信号量要放在每个请求的内部,即fetch_with_semaphore里面。
5.4 坑四:连接池耗尽
压测的时候发现,一旦并发超过100,httpx会报PoolTimeout。后来加了limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),并把信号量设为10,问题解决。信号量值不要超过连接池上限。
5.5 优化:缓存热点数据
最后发现,用户信息和库存信息一天内变化不大。加了@lru_cache装饰器,对相同order_id的请求做缓存,TTL设60秒。这一下又省了30%的响应时间。
from functools import lru_cache
@lru_cache(maxsize=1024, ttl=60) # Python 3.9+ 支持ttl参数
async def get_user_cached(order_id: str):
return await fetch_with_semaphore(f"http://user-service/users/{order_id}")
6. 效果数据:别跟我谈理论,看数字
改造完成后,用locust压测,相同配置,10个并发用户,持续5分钟:
| 指标 | 同步版本 | 异步版本(无缓存) | 异步版本(有缓存) |
|---|---|---|---|
| 平均响应时间 | 3.2秒 | 1.2秒 | 0.4秒 |
| P95响应时间 | 4.1秒 | 1.8秒 | 0.6秒 |
| QPS | 50 | 180 | 400 |
| 超时率(>3s) | 8.5% | 0.2% | 0% |
为什么异步版本还是1.2秒而不是1秒?因为三个服务中最慢的那个是1.1秒,加上网络开销和调度,1.2秒符合预期。加了缓存后,热点订单直接走内存,0.4秒是纯API组装耗时。
另外注意:异步不是银弹。如果你的接口是CPU密集型(比如图像处理、加密解密),asyncio帮不了你,得用multiprocessing或ThreadPoolExecutor。asyncio只适合IO密集型。
7. 总结:一套可复用的异步改造方法论
这次改造总结下来就四步:
- 找瓶颈:先确认是IO密集还是CPU密集。
cProfile看一下,如果耗时都在read、recv、select上,就是IO密集。 - 换客户端:把
requests换成httpx.AsyncClient,注意复用连接池。 - 并发调度:用
asyncio.gather并发执行无依赖的IO调用。 - 加防护:信号量限流、超时控制、异常捕获、缓存热点。
这套组合拳下来,接口性能提升8倍,QPS提升8倍,线上监控从一片红变成一片绿。
最后说一句:别为了异步而异步。如果你的接口只有一次外部调用,异步毫无意义。异步的价值在于“等待多个IO的同时做其他事”。用好asyncio,关键是识别出“可并发的IO等待”。
代码已经推到公司仓库,需要的自取。有问题评论区聊。