一、问题背景:不是所有慢都是代码的错

先交代下业务场景。我们有个内部订单聚合服务,前端需要一次拿到用户信息、近30天订单统计、以及商品推荐,三个数据分别来自三个独立微服务A/B/C。老代码是Flask同步视图里用requests顺序调三个接口:

# before.py - Flask同步版本
import requests
from flask import Flask, jsonify

app = Flask(__name__)

def fetch_user(uid):
    r = requests.get(f"http://svc-a/user/{uid}", timeout=2)
    return r.json()

def fetch_orders(uid):
    r = requests.get(f"http://svc-b/orders/{uid}", timeout=2)
    return r.json()

def fetch_recommend(uid):
    r = requests.get(f"http://svc-c/recommend/{uid}", timeout=2)
    return r.json()

@app.route("/api/agg/")
def aggregate(uid):
    # 三个请求串行,每个平均300ms,总耗时约900ms
    user = fetch_user(uid)
    orders = fetch_orders(uid)
    reco = fetch_recommend(uid)
    return jsonify({"user": user, "orders": orders, "reco": reco})

压测数据(wrk -t4 -c100 -d30s):
- QPS:120左右
- P99延迟:2.8s(因为线程池满了开始排队)
- CPU:80%(GIL + requests阻塞,线程切换开销巨大)

问题很明显:三个HTTP请求之间没有依赖关系,却串行等待。每个请求空等IO时,线程被白白占住。Flask默认threaded=True,但线程创建和切换成本高,IO密集场景下效率极低。

二、方案设计:asyncio + httpx,把等待时间叠加起来

核心思路:用asyncio事件循环代替线程池,用httpx.AsyncClient代替requests,三个请求并发发出,总耗时从“三个之和”变成“最慢的一个”。

技术选型版本:
- Python 3.10.12(注意:3.10是asyncio性能分水岭,3.11+更好,但生产环境还是3.10)
- Flask 2.3.3(保持同步框架,只把IO部分换协程)
- httpx 0.25.1(requests作者出的异步兼容库)
- uvicorn 0.23.2(用[standard]扩展,内部用uvloop)

架构调整:
1. Flask视图函数保持同步,内部用asyncio.run()跑协程(注意:Flask不支持原生async视图,除非换quart或fastapi,但改造成本太大,我们选择包装)
2. 用asyncio.gather()并发三个请求
3. 用asyncio.Semaphore(20)做全局并发限流,防止打爆下游服务
4. 每个请求设置超时,用asyncio.timeout(Python 3.11+,但3.10可以用asyncio.wait_for

三、核心实现:Before/After代码对比

改造后代码:

# after.py - asyncio + httpx 并发版本
import asyncio
import httpx
from flask import Flask, jsonify

app = Flask(__name__)
# 全局信号量,限制同时最多20个协程在跑IO
_semaphore = asyncio.Semaphore(20)
_client = None

def get_client():
    global _client
    if _client is None or _client.is_closed:
        # httpx.AsyncClient内部有连接池,必须全局复用
        _client = httpx.AsyncClient(
            limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
            timeout=httpx.Timeout(2.0, connect=0.5),
            http2=True  # 内部服务支持HTTP2,省握手时间
        )
    return _client

async def fetch_with_limit(client, url):
    async with _semaphore:
        try:
            # wait_for替代3.10的timeout,超时抛asyncio.TimeoutError
            resp = await asyncio.wait_for(client.get(url), timeout=2.0)
            return resp.json()
        except (httpx.TimeoutException, asyncio.TimeoutError, httpx.HTTPError) as e:
            # 失败快速返回降级数据,不让聚合接口整体失败
            return {"error": str(e), "data": None}

async def fetch_all(uid):
    client = get_client()
    urls = [
        f"http://svc-a/user/{uid}",
        f"http://svc-b/orders/{uid}",
        f"http://svc-c/recommend/{uid}",
    ]
    # gather并发,return_exceptions=True防止一个挂了全挂
    results = await asyncio.gather(
        *(fetch_with_limit(client, url) for url in urls),
        return_exceptions=True
    )
    return results

@app.route("/api/agg/")
def aggregate(uid):
    # 每个请求独立事件循环,注意:这里不能复用全局loop
    try:
        user, orders, reco = asyncio.run(fetch_all(uid))
    except Exception as e:
        return jsonify({"error": "aggregate failed"}), 500
    return jsonify({"user": user, "orders": orders, "reco": reco})

if __name__ == "__main__":
    # 生产用uvicorn跑,不用app.run
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000, workers=4)

改动要点
1. asyncio.run()每次请求创建新事件循环——这是一个坑,后面细说
2. httpx.AsyncClient必须全局单例,否则每次请求重建连接池,性能反而更差
3. Semaphore(20)信号量控制并发,防止下游小服务被瞬间打挂
4. wait_for包超时,比httpx自带timeout更可靠(httpx的超时是IO层面的,wait_for是协程层面的,两者配合双保险)

四、踩坑与优化:你以为写完就完了?太天真

坑1:asyncio.run()每次新建事件循环的开销
实测发现,asyncio.run()创建/销毁事件循环大约耗时0.5ms,对低延迟接口影响不大,但高并发下会累积。优化方案:在模块级创建全局loop,但Flask多线程模式下不能安全共享。最终妥协:asyncio.run() + uvicorn多worker(4个),实测损耗可接受。

坑2:uvicorn的workers参数和asyncio.Semaphore的冲突
每个worker进程有独立的信号量,意味着实际并发上限是 workers x 20 = 80。如果下游服务只能承受50并发,需要把Semaphore改成Redis分布式信号量——但为了简单,我们直接调低了Semaphore到10,压测后确认下游无压力。

坑3:pymysql是同步阻塞的!
如果聚合接口里还有DB查询,千万不要在协程里用pymysql,会阻塞整个事件循环。我们当时有个隐藏的MySQL查询,导致并发一高就卡死。解决:用aiomysql或者把DB查询放到asyncio.to_thread()里跑(Python 3.9+)。

# 错误示范:同步DB查询在协程里
async def bad_sql():
    cursor = pymysql.connect(...)  # 阻塞事件循环!
    cursor.execute("SELECT ...")

# 正确做法:丢到线程池
async def good_sql():
    result = await asyncio.to_thread(sync_db_query, sql)

坑4:HTTP2和keepalive的收益
开启http2=True后,P99从620ms降到420ms,因为内部服务握手次数减少了。但注意:如果下游是老旧Nginx不支持HTTP2,必须改回http1.1,否则会报错。

五、效果数据:用数据说话

压测环境:4核8G云主机,wrk -t8 -c200 -d60s,模拟真实流量。

指标 同步版(requests) 异步版(asyncio+httpx) 提升
QPS 120 1800 15倍
P50 780ms 210ms 3.7倍
P99 2.8s 420ms 6.7倍
CPU占用 80% 45% 下降
内存占用 2.3GB 1.1GB 下降52%

关键数据点:
- 在200并发下,异步版P95稳定在350ms以内,无超时错误
- 下游服务A/B/C的QPS从各自120涨到1800,它们没做任何改动,只是我们这边变快了
- 4个uvicorn worker,每个worker内事件循环跑满单核,整体CPU控制在45%,还有余量

六、总结与建议:什么时候该用asyncio?

适合用asyncio的场景
- IO密集型:大量外部HTTP/RPC/DB查询,且无依赖关系
- 需要高并发连接:比如WebSocket服务、爬虫
- 延迟敏感:每个请求都在等IO,协程切换成本远低于线程

不适合的场景
- CPU密集型:计算、加密、图片处理,asyncio无优势,反而更慢
- 已有成熟的线程池架构:比如用gunicorn+gevent,不一定非要重写

最后说点实话
asyncio不是银弹。我们踩了三个月的坑才稳定下来,主要问题集中在事件循环阻塞、信号量语义、以及和同步库的混用。如果项目是绿色field(新项目),直接上FastAPI + async SQLAlchemy是更干净的选择。但如果是老Flask项目,像我们这样用asyncio包装IO段,改造风险可控,收益巨大。

如果你的接口也卡在“多次串行IO”上,别急着上微服务,先试试asyncio。成本低,见效快,代码改动量大概200行以内。这波不亏。