一、问题背景:一个拖垮全站的慢接口

我们的订单详情接口 /api/v1/orders/{id} 需要聚合三个下游服务的数据:订单基础信息(MySQL)、用户信息(Redis)、物流轨迹(第三方HTTP)。上线半年一直用 requests 库同步调用,平时并发不高没暴露问题。

大促压测时发现:500并发下,该接口P99延迟达到2.8秒,QPS只有120,直接拖垮了网关。用 cProfile 一分析,发现 73% 的时间花在 requests.get 的阻塞等待上——每次HTTP调用要等DNS解析、TCP握手、TLS协商、响应体下载,而CPU是空闲的。

二、环境与版本

Python: 3.10.11 (CPython, 启用--with-lto优化)
框架: Flask 2.3.2 (Werkzeug 2.3.6)
异步客户端: httpx 0.24.1
压测工具: wrk 4.2.0 (单线程, 4个连接)
服务器: 4核8G 云服务器 (Intel Xeon 8374C, 共享型)
操作系统: Ubuntu 22.04 LTS

注意:Flask是同步框架,不能直接在view函数里 await。所以方案是:用 asyncio.run() 在同步view中跑异步任务,用 httpx.AsyncClient 替代 requests

三、方案设计:织一张异步的网

核心思路是将三个独立的下游IO从串行改为并发。改造前伪代码:

def get_order_detail(order_id):
    db_data = requests.get(f"http://db-service/orders/{order_id}").json()  # 200ms
    user_data = requests.get(f"http://user-service/users/{db_data['uid']}").json()  # 150ms
    logistics = requests.get(f"http://logistics-service/tracks/{order_id}").json()  # 300ms
    return merge(db_data, user_data, logistics)

总耗时 ≈ 200 + 150 + 300 = 650ms(串行)。

改造后,三个请求并发发出,总耗时 ≈ max(200, 150, 300) = 300ms。但这只是第一步——更重要的是用异步客户端复用TCP连接,减少握手开销。

四、核心实现:asyncio.gather + Semaphore限流

先看关键代码。注意我加了 asyncio.Semaphore(20) 限流,防止瞬间打满下游服务:

import asyncio
import httpx
from functools import lru_cache

# 全局复用AsyncClient,避免每个请求重新建连
@lru_cache(maxsize=1)
def get_async_client() -> httpx.AsyncClient:
    return httpx.AsyncClient(
        timeout=httpx.Timeout(2.0, connect=1.0),  # 总超时2秒,连接超时1秒
        limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
        headers={"X-Client": "order-api-async"},
    )

# 信号量,控制并发数不超过20
_semaphore = asyncio.Semaphore(20)

async def fetch_json(client: httpx.AsyncClient, url: str) -> dict:
    async with _semaphore:
        try:
            resp = await client.get(url)
            resp.raise_for_status()
            return resp.json()
        except httpx.TimeoutException:
            return {"_error": "timeout", "_url": url}
        except httpx.HTTPStatusError as e:
            return {"_error": f"http_{e.response.status_code}", "_url": url}

async def get_order_async(order_id: str) -> dict:
    client = get_async_client()
    # 并发发起三个请求,asyncio.gather保证等最慢的一个
    results = await asyncio.gather(
        fetch_json(client, f"http://db-service/orders/{order_id}"),
        fetch_json(client, f"http://user-service/users/{order_id}"),  # 实际应从db结果取uid,这里简化
        fetch_json(client, f"http://logistics-service/tracks/{order_id}"),
        return_exceptions=False  # 不强抛,由fetch_json内部捕获
    )
    # 合并逻辑(省略字段校验)
    merged = {}
    for r in results:
        if "_error" not in r:
            merged.update(r)
    return merged

# 在Flask同步view中调用
def get_order_detail(order_id: str):
    try:
        return asyncio.run(get_order_async(order_id))
    except RuntimeError as e:
        # 处理event loop冲突(见踩坑部分)
        print(f"[WARN] asyncio.run failed: {e}, fallback to sync")
        return fallback_sync(order_id)

改造后视图函数几乎没变,只是把 requests 换成 asyncio.run 包裹的异步函数。但要注意生产环境不要用 asyncio.run 在同步view里高频调用——它每次创建新EventLoop,开销大。更好的方案是全局维护一个EventLoop(见踩坑部分)。

五、踩坑与优化:EventLoop地狱与超时玄学

这里必须吐槽几个坑,全是血泪教训:

坑1:asyncio.run 在Flask多线程下崩溃
Flask默认threaded=True,每请求一个线程。如果两个线程同时调用 asyncio.run(),会报 RuntimeError: asyncio.run() cannot be called from a running event loop。解决方案:全局只创建一个EventLoop,用 loop.run_until_complete() 提交任务:

# 全局单例loop
_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_loop)

def run_async(coro):
    return _loop.run_until_complete(coro)

坑2:httpx超时参数别乱设
我一开始只设了 timeout=2.0,结果发现连接池满了后,排队等待时间也计入2秒超时,导致大量 TimeoutException。正确做法是分开设置:httpx.Timeout(2.0, connect=1.0, pool=0.5)——pool超时是等待空闲连接的时限,设大点(比如5秒)更合理。

坑3:DNS解析在异步中依然阻塞
httpx默认用 loop.getaddrinfo 执行DNS解析,但它是线程池实现的,高并发下线程池默认32会成瓶颈。解决:用 httpx.AsyncClient(trust_env=False) 并预解析IP,或用 uvloop 替代默认EventLoop。

坑4:下游服务突然变慢,全部超时怎么办?
我在 fetch_json 里捕获了所有异常返回 _error 标记,但合并逻辑里如果三个都失败,返回空dict会导致前端异常。所以加了fallback:如果 results 全是error,就返回一个包装的错误结构。

六、效果数据:不看广告看疗效

压测配置:wrk -t4 -c500 -d30s http://localhost:8080/api/v1/orders/12345

指标 改造前 (requests) 改造后 (asyncio+httpx) 提升
平均延迟 1.2s 180ms 6.7x
P99延迟 2.8s 450ms 6.2x
QPS 120 850 7.1x
错误率 5.2% (超时) 0.3% (仅5xx) 平稳
CPU使用率 35% 60% 略升但合理

为什么延迟降了但CPU升了? 因为原来线程阻塞时CPU空闲,现在线程等待期间EventLoop在轮询socket,CPU利用率上去了。但注意:4核跑到60%说明压力测试机还有余量,真实环境建议加 uvloop 后单worker能扛更多。

连接复用效果:改造前每请求3次TCP握手(总共500并发要1500个连接),改造后连接池复用,实际活跃连接数稳定在20-30之间,DB服务和物流服务的负载也降了。

七、总结:什么时候值得用asyncio?

这次改造让我重新理解了“异步不是银弹”。如果瓶颈在CPU计算,asyncio没用;如果瓶颈在数据库查询,你需要的是连接池+索引优化;但如果你有大量HTTP调用的IO密集体,asyncio+httpx能带来数量级的提升。

关键教训:
- 同步Flask + asyncio是可以的,但别用 asyncio.run,要全局EventLoop。
- 连接池参数必须调优:max_connections 设为下游能承受的极限值,max_keepalive_connections 设为并发数的1/3。
- 超时设置要分层:连接超时、读取超时、池等待超时分开设。
- 压测用wrk,不要用ab——wrk支持并发连接复用,更接近真实场景。

最后,如果你用的是FastAPI(原生异步框架),直接写 async def 视图函数即可,代码更简洁。但Flask存量项目,按我上面的方法改,40行代码就能救活一个服务。有问题欢迎评论区交流。