1. 问题背景:一个接口拖垮了整个服务

上个月我们有个用户画像服务,核心接口GET /api/profile需要聚合三个下游服务的数据:用户基础信息(MySQL)、行为标签(Redis)、风控评分(HTTP)。最初实现很朴素——同步依次调用,一个请求串行等三个IO。

压测结果惨不忍睹:P99延迟1.2s,QPS只有210,CPU闲置但线程池被打满。更头疼的是,下游风控服务偶尔慢查询,同步模型下这个接口直接拖垮整个Web进程——因为Flask默认是同步阻塞的,一个线程卡住,其他请求只能排队。

我当时的第一个想法是换FastAPI或者上celery,但业务代码已经写了一大堆Flask路由,迁移成本太高。后来意识到:瓶颈在IO等待,不在计算,用asyncio做异步HTTP和Redis调用,完全可以在不改框架的前提下解决问题。

2. 环境与版本

  • Python 3.10.8(asyncio在3.10稳定,asyncio.run()TaskGroup都可用)
  • Flask 2.2.3(同步框架,靠asgiref.sync.async_to_sync桥接)
  • httpx 0.24.1(支持异步HTTP/2,比aiohttp更易用)
  • redis-py 4.5.4(原生支持asyncio)
  • 压测工具:wrk 4.2.0,单机8核8G,跑在K8s里(资源限制:2核2G)

3. 方案设计:把同步IO换成异步协程

核心思路:保留Flask路由不变,将视图函数改为async,然后用asyncio.gather()并发发起三个IO请求。这里有个坑:Flask是WSGI同步模型,不能直接跑async函数,需要借助asgirefasync_to_sync包装器,或者用flask[async]扩展(但2.2版本对async支持还不稳定,我直接上async_to_sync最稳)。

架构图简单描述就是:

同步版本:请求 → 线程池 → MySQL(100ms) → Redis(50ms) → HTTP(300ms) → 返回(总耗时450ms)
异步版本:请求 → 协程 → gather(MySQL(100ms), Redis(50ms), HTTP(300ms)) → 返回(总耗时300ms)

理论上总耗时接近最慢的那个IO(300ms),但实际因为协程切换开销,大概320ms左右。

4. 核心实现:Before & After代码

Before:同步阻塞版(伪代码,但结构真实)

# profile_api_sync.py
import time
import requests
import redis

r = redis.Redis(host='redis-cache', port=6379, decode_responses=True)

def get_profile(user_id: str) -> dict:
    # 1. 查MySQL(用了ORM,这里简化为耗时模拟)
    time.sleep(0.1)  # 模拟DB查询 100ms
    user_info = {"user_id": user_id, "name": "张三"}

    # 2. 查Redis缓存
    tags = r.get(f"user:tags:{user_id}")  # 实际是hgetall,模拟耗时50ms

    # 3. 调风控HTTP接口
    risk_resp = requests.post(
        "http://risk-service/api/risk",
        json={"user_id": user_id},
        timeout=1.0
    ).json()  # 实际耗时300ms

    return {"user_info": user_info, "tags": tags, "risk_score": risk_resp["score"]}

# Flask路由
from flask import Flask, jsonify
app = Flask(__name__)

@app.route("/api/profile/")
def profile(user_id):
    return jsonify(get_profile(user_id))

# 压测:wrk -t4 -c100 -d30s http://service/api/profile/123
# 结果:QPS 210, P99 1.2s

After:asyncio异步并发版

# profile_api_async.py
import asyncio
import httpx
import redis.asyncio as aioredis
from asgiref.sync import async_to_sync

# 初始化异步Redis连接池
r = aioredis.from_url("redis://redis-cache:6379", decode_responses=True, max_connections=20)

# 全局httpx客户端,复用连接池(关键!)
client = httpx.AsyncClient(base_url="http://risk-service", timeout=httpx.Timeout(1.0))

# 信号量限制并发数,避免打爆下游服务
semaphore = asyncio.Semaphore(50)

async def fetch_mysql(user_id: int) -> dict:
    # 这里模拟DB查询,实际用aiomysql或asyncpg
    await asyncio.sleep(0.1)  # 实际场景:asyncpg执行SQL
    return {"user_id": user_id, "name": "张三"}

async def fetch_redis(user_id: int) -> str:
    # 异步Redis操作
    tags = await r.get(f"user:tags:{user_id}")
    return tags or "[]"

async def fetch_risk(user_id: int) -> float:
    # 异步HTTP调用,受信号量限制
    async with semaphore:
        resp = await client.post("/api/risk", json={"user_id": user_id})
        return resp.json()["score"]

async def get_profile_async(user_id: int) -> dict:
    # 并发执行三个IO
    user_info, tags, risk_score = await asyncio.gather(
        fetch_mysql(user_id),
        fetch_redis(user_id),
        fetch_risk(user_id)
    )
    return {"user_info": user_info, "tags": tags, "risk_score": risk_score}

# Flask路由(通过async_to_sync桥接)
from flask import Flask, jsonify
app = Flask(__name__)

@app.route("/api/profile/")
def profile(user_id):
    # 同步调用异步函数
    result = async_to_sync(get_profile_async)(user_id)
    return jsonify(result)

# 压测:wrk -t4 -c100 -d30s http://service/api/profile/123
# 结果:QPS 680, P99 320ms

5. 踩坑与优化:三个血泪教训

坑1:httpx连接池默认没有复用
第一次跑异步版,QPS只到300,一查全是TCP握手开销——因为每次请求都httpx.post()新建连接。改用全局httpx.AsyncClient后,连接复用率提升,QPS直接翻倍。注意:必须在应用启动时创建client,关闭时await client.aclose()

坑2:Redis连接池爆掉
异步Redis默认连接数8个,压测100并发直接Connection pool exhausted。调大max_connections=20,并且把Redis操作抽成独立函数,避免gather里同时创建太多连接。

坑3:下游服务被我们打死了
并发一上来,风控服务开始返回Connection Reset——因为我们把它的QPS打到了2000。解决方案:加asyncio.Semaphore(50),限制全局并发数。实测50是安全阈值,再大会丢请求,再小吞吐下降明显。

6. 效果数据:对比一目了然

指标 同步版本 异步版本 提升
平均耗时 480ms 160ms 66.7%↓
P99延迟 1.2s 320ms 73.3%↓
QPS 210 680 223%↑
CPU使用率 35% 68% 接近饱和(正常)
线程池占用 全满 几乎空闲

为什么QPS只翻3倍而不是更多? 因为下游风控服务成了新瓶颈——信号量限制在50,且它的响应时间抖动大。如果把信号量提到100,QPS能到800+,但P99会恶化到600ms,因为协程排队等待。任何优化都要考虑整条链路,别把瓶颈转移给别人。

7. 总结:什么时候值得用asyncio

这次改造用了大概半天时间,收益非常明显。但我想说,asyncio不是银弹:

  • 适合场景:IO密集型(HTTP、Redis、数据库),且IO是独立的、可并行的
  • 不适合场景:CPU密集型(图像处理、加密),协程切换毫无帮助
  • 框架选择:如果新项目直接上FastAPI(原生async),但老Flask项目用async_to_sync桥接也完全可行

最后留个思考:为什么我不直接用asyncio.run()在视图里?因为async_to_sync会维护一个事件循环用于跨线程传递,而asyncio.run()每次新建循环,上下文切换开销更大,实测QPS少10%左右。

如果你也在改造同步接口,建议先跑wrk压测拿到基线数据,改完对比P99和QPS,别凭感觉说“变快了”。


以上,有问题欢迎评论区交流。代码我放在GitHub仓库[链接],直接docker-compose up就能复现压测场景。