1. 问题背景:一个聚合接口,下游三个服务
我们有个商品详情页接口 /api/v1/product/{pid},逻辑不复杂:先查本地Redis缓存(约10ms),缓存miss后并行调用三个下游服务——库存服务(平均80ms)、价格服务(平均120ms)、评价服务(平均200ms),最后聚合返回。
最初用Flask同步写,每个请求串行等待三个服务,最坏情况耗时 = 10 + 80 + 120 + 200 = 410ms。压测时单机4核8G,QPS稳定在120左右,P99却飙到2.3s。原因很简单:线程切换开销 + GIL限制,Flask的线程池模式在大量I/O等待时就是浪费CPU。
2. 环境与版本
- Python 3.10.12(注意:3.10+的asyncio稳定多了,3.7的坑太多)
- Flask 2.3.2(同步框架,但我们可以用
asgiref的sync_to_async桥接) - aiohttp 3.8.5(代替requests做并发HTTP调用)
- redis-py 4.5.1(自带asyncio支持,不用额外包)
- 压测工具:locust 2.15.1,200并发用户,持续5分钟
- 部署:Gunicorn + gevent worker(同步版本),改造后换uvicorn(ASGI服务器)运行
3. 方案设计:把阻塞I/O全部换成异步
核心思路:
1. 保留Flask路由层,但把视图函数改成async def(需要ASGI容器支持,所以我们最终切到了FastAPI,但原理一样,代码我两种都贴)。
2. Redis读取用redis.asyncio,三个下游HTTP调用统一用asyncio.gather并发。
3. 关键点:绝对不能在协程里用同步requests,否则事件循环被阻塞,整个进程卡死。
架构图(文字版):
客户端 → ASGI Server (uvicorn) → FastAPI路由 → asyncio事件循环
├── redis.asyncio.get()
├── aiohttp.get(库存)
├── aiohttp.get(价格)
└── aiohttp.get(评价)
聚合结果 → 返回JSON
4. 核心实现:Before/After代码
4.1 Before:同步Flask版本(可运行)
# before_sync.py — Flask同步版
import time
import requests
import redis
from flask import Flask, jsonify
app = Flask(__name__)
cache = redis.Redis(host='localhost', port=6379, db=0)
def fetch_inventory(pid):
time.sleep(0.08) # 模拟80ms I/O
return {"pid": pid, "stock": 100}
def fetch_price(pid):
time.sleep(0.12) # 模拟120ms
return {"pid": pid, "price": 199.0}
def fetch_reviews(pid):
time.sleep(0.20) # 模拟200ms
return {"pid": pid, "rating": 4.5}
@app.route('/api/v1/product/')
def get_product(pid):
cache_key = f"product:{pid}"
cached = cache.get(cache_key)
if cached:
return jsonify(eval(cached)) # 简单演示,实际用json
inv = fetch_inventory(pid)
price = fetch_price(pid)
rev = fetch_reviews(pid)
result = {"pid": pid, "inventory": inv, "price": price, "reviews": rev}
cache.set(cache_key, str(result), ex=60)
return jsonify(result)
if __name__ == '__main__':
app.run(threaded=True) # 默认线程池
4.2 After:asyncio重构版(FastAPI + aiohttp)
# after_async.py — FastAPI + asyncio
import asyncio
import aiohttp
from fastapi import FastAPI
from redis.asyncio import Redis
import json
app = FastAPI()
redis_client = Redis(host='localhost', port=6379, db=0, decode_responses=True)
# 注意:aiohttp连接池,默认100,我们改成500,否则高并发会TimeoutError
session = None
@app.on_event("startup")
async def startup():
global session
conn = aiohttp.TCPConnector(limit=500, ttl_dns_cache=300)
session = aiohttp.ClientSession(connector=conn)
@app.on_event("shutdown")
async def shutdown():
await session.close()
await redis_client.close()
async def fetch_inventory(pid):
await asyncio.sleep(0.08) # 模拟I/O
return {"pid": pid, "stock": 100}
async def fetch_price(pid):
await asyncio.sleep(0.12)
return {"pid": pid, "price": 199.0}
async def fetch_reviews(pid):
await asyncio.sleep(0.20)
return {"pid": pid, "rating": 4.5}
@app.get("/api/v1/product/{pid}")
async def get_product(pid: int):
cache_key = f"product:{pid}"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 关键:并发跑三个协程
inv, price, rev = await asyncio.gather(
fetch_inventory(pid),
fetch_price(pid),
fetch_reviews(pid)
)
result = {"pid": pid, "inventory": inv, "price": price, "reviews": rev}
await redis_client.set(cache_key, json.dumps(result), ex=60)
return result
# 启动:uvicorn after_async:app --workers 4 --loop asyncio
注意区别:Before版本串行耗时410ms,After版本并发耗时max(80,120,200)=200ms,理论提升2倍。但实际压测提升远不止2倍,因为同步版本线程切换和GIL损耗在200并发下极大。
5. 踩坑记录:三个大坑
坑1:asyncio里混用requests导致死锁
第一次改造,我图省事在fetch_inventory里直接requests.get(),结果压测时所有请求全部卡死。因为requests阻塞了事件循环,后续协程永远得不到调度。用py-spy dump --pid看到所有线程卡在select上。修复:全部换aiohttp。
坑2:Redis连接池默认太小
redis.asyncio默认连接池10个,200并发直接ConnectionPoolError。解决:初始化时指定max_connections=200(我们实际用了Redis(connection_pool=... ),但最简单的就是redis_client = Redis(..., max_connections=200))。
坑3:uvicorn默认worker数太少
一开始用uvicorn --workers 1,单进程只能吃一个核,QPS上不去。后来改成--workers 4(和CPU核数一致),但要注意每个worker都有自己的事件循环,Redis连接池和aiohttp连接池是各worker独立的,所以压测时总连接数要乘以worker数,别把Redis打爆。
6. 效果数据:压测结果对比
压测环境:同机4核8G,200并发,5分钟,Redis和模拟服务都在本机(排除网络干扰)。
| 指标 | 同步Flask (threaded) | asyncio (uvicorn 4 workers) | 提升倍数 |
|---|---|---|---|
| QPS | 122 | 847 | 6.9x |
| P50 | 310ms | 210ms | 1.5x |
| P99 | 2.3s | 380ms | 6.1x |
| 超时率 | 8% | 0.2% | 40x |
有意思的是P99提升远超理论值。分析下来同步版本在200并发时,线程上下文切换频繁,CPU大部分时间浪费在调度上,导致长尾特别严重。而asyncio单线程事件循环没有切换开销,P99基本等于模拟服务的最大延迟200ms + 一点点调度损耗。
额外发现:aiohttp连接池limit=500时,压测中看到socket.gaierror报错,排查发现是DNS解析并发过高。加了ttl_dns_cache=300(DNS缓存5分钟)后解决,这个参数平时没人注意,但高并发下特别关键。
7. 总结与建议
这次改造把QPS提升了7倍,P99从2.3s降到380ms,代价是代码复杂度上升——协程、连接池、事件循环这些概念需要团队理解。我的建议:
- 如果接口I/O等待占比超30%,果断上asyncio,收益远大于成本。
- 不要用Flask跑协程,切FastAPI或Sanic,ASGI是必须的。
- 所有网络库必须用异步版:
aiohttp替代requests,redis.asyncio替代redis,数据库用asyncpg或aiomysql。 - 连接池是隐形杀手,aiohttp的
limit、Redis的max_connections都要根据压测峰值调整,并预留20%余量。 - 监控用py-spy,它能dump出协程栈,比看日志直观多了。
最后说一句:异步不是银弹,如果你的接口里有CPU密集计算(比如图片处理),该用多进程还是得用多进程。但纯I/O密集型服务,asyncio就是目前Python生态的最优解。
如果你也在做类似改造,欢迎评论区聊聊你踩过的坑——尤其是aiohttp连接池那个,我怀疑不止我一个人被坑过。