一、问题背景:一个“看起来没问题”的接口
上个月接手了一个订单查询服务,技术栈是Flask 2.3 + SQLAlchemy 1.4 + PostgreSQL 14。接口逻辑很简单:根据用户ID查最近的20条订单,每条订单关联商品和店铺信息。压测时发现QPS到300后,P99延迟从180ms直接飙到2.1s,但CPU使用率只有40%——典型的I/O瓶颈。
一开始怀疑是数据库慢查询,但看pg_stat_statements发现单条查询都在50ms以内。用cProfile跑了一下本地压测,发现sqlalchemy的__init__占了65%的耗时——问题出在ORM的懒加载上,20条订单触发了20次商品查询+20次店铺查询,加上连接池默认大小只有5,直接被打满。
二、环境与版本:先摆出可复现的底牌
Python 3.11.4
Flask 2.3.2 / FastAPI 0.100.0
SQLAlchemy 1.4.48 (asyncpg 0.27.0)
PostgreSQL 14.5 (shared_buffers=4GB, work_mem=64MB)
Redis 7.0 (maxmemory-policy=allkeys-lru)
gunicorn 21.2.0 (worker_class=gevent / uvicorn)
wrk 4.2.0 (压测工具)
测试机:8核16G的云主机,PostgreSQL和Redis都在同机部署,网络延迟`,看实际运行中的调用栈。
压测发现cProfile显示sqlalchemy.orm.loading._emit_load占了62%时间,但py-spy显示大量线程卡在psycopg2.connect上——连接池等待。这说明有两个独立问题:ORM懒加载和连接池不足。
方案设计如下:
- 数据库层:把N+1查询改为
selectinload或joinedload,一次JOIN拿全数据。 - 缓存层:热点用户(近1小时有访问)的订单列表缓存到Redis,TTL 60秒,key为
order:list:{user_id}。 - 框架层:对比Flask+gevent和FastAPI+asyncpg,看异步是否真的能压榨出更高吞吐。
- 连接池:SQLAlchemy连接池
pool_size从5调到20,max_overflow设为10。
四、核心实现:Flask版优化(同步+连接池+缓存)
先写Flask版优化后的代码,改动点全在查询和缓存上。
# app_flask_optimized.py
from flask import Flask, jsonify, request
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, selectinload
import redis, json, time
app = Flask(__name__)
engine = create_engine(
'postgresql+psycopg2://user:pass@localhost/orders',
pool_size=20, # 默认5,改到20
max_overflow=10, # 最多额外10个连接
pool_pre_ping=True, # 防止连接失效
pool_recycle=300 # 5分钟回收
)
Session = sessionmaker(bind=engine)
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
@app.route('/orders')
def get_orders():
user_id = request.args.get('user_id', type=int)
if not user_id:
return jsonify({'error': 'missing user_id'}), 400
# 缓存策略:先查Redis,命中直接返回
cache_key = f'order:list:{user_id}'
cached = r.get(cache_key)
if cached:
return jsonify(json.loads(cached))
# 数据库查询:一次性JOIN,避免N+1
with Session() as s:
stmt = text("""
SELECT o.id, o.amount, p.name AS product_name, s.name AS shop_name
FROM orders o
JOIN products p ON o.product_id = p.id
JOIN shops s ON o.shop_id = s.id
WHERE o.user_id = :uid
ORDER BY o.created_at DESC
LIMIT 20
""")
result = s.execute(stmt, {'uid': user_id}).mappings().all()
data = [dict(row) for row in result]
# 回填缓存,60秒过期
r.setex(cache_key, 60, json.dumps(data))
return jsonify(data)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
这里有几个坑要提:
pool_pre_ping=True:不加的话,PostgreSQL重启后连接池里的旧连接直接报错,压测时会出现偶发500。selectinload试过但没用:ORM的JOIN在复杂查询下生成的SQL不如手写JOIN可控。这个场景手写SQL性能提升约15%,因为能控制索引选择。- Redis序列化用
json而不是pickle:pickle有安全风险且体积大3倍,网络开销在压测中会放大。
五、核心实现:FastAPI版(异步+asyncpg)
FastAPI版用纯异步,数据库驱动换asyncpg。注意SQLAlchemy在异步下不能用Session的同步方式,得用AsyncSession。
# app_fastapi.py
from fastapi import FastAPI, Query
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import redis.asyncio as aioredis
import json
app = FastAPI()
# asyncpg驱动,注意URL前缀是postgresql+asyncpg
engine = create_async_engine(
'postgresql+asyncpg://user:pass@localhost/orders',
pool_size=20,
max_overflow=10,
pool_pre_ping=True
)
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
r = aioredis.from_url('redis://localhost:6379/0', decode_responses=True)
@app.get('/orders')
async def get_orders(user_id: int = Query(...)):
cache_key = f'order:list:{user_id}'
cached = await r.get(cache_key)
if cached:
return json.loads(cached)
async with SessionLocal() as s:
result = await s.execute(text("""
SELECT o.id, o.amount, p.name AS product_name, s.name AS shop_name
FROM orders o
JOIN products p ON o.product_id = p.id
JOIN shops s ON o.shop_id = s.id
WHERE o.user_id = :uid
ORDER BY o.created_at DESC
LIMIT 20
"""), {'uid': user_id})
rows = result.mappings().all()
data = [dict(row) for row in rows]
await r.setex(cache_key, 60, json.dumps(data))
return data
启动命令差异:Flask用gunicorn -k gevent -w 8 app_flask_optimized:app,FastAPI用uvicorn app_fastapi:app --workers 8。注意FastAPI如果跑在gunicorn下,worker_class得是uvicorn.workers.UvicornWorker,否则异步事件循环跑不起来。
六、压测数据:从2s到120ms的完整对比
用wrk跑60秒,每个配置跑3次取中位数,结果如下:
| 配置 | QPS | P50 (ms) | P99 (ms) | 错误率 |
|---|---|---|---|---|
| Flask原始 (懒加载+pool=5) | 305 | 180 | 2100 | 2.3% |
| Flask+手写JOIN+pool=20 | 820 | 88 | 420 | 0% |
| Flask+JOIN+Redis缓存 | 1450 | 32 | 210 | 0% |
| FastAPI+asyncpg+JOIN | 1100 | 65 | 350 | 0% |
| FastAPI+asyncpg+Redis | 2600 | 18 | 120 | 0% |
关键结论:
- 连接池从5调到20的收益最大——QPS从305提到820,接近3倍。懒加载带来的N+1查询在连接池不足时会被放大成雪崩。
- 异步在无缓存场景下不如预期:FastAPI+asyncpg的QPS 1100只比Flask优化后的820高34%。因为瓶颈在数据库I/O,异步只能减少线程切换开销,不能减少查询次数。
- Redis缓存才是杀手锏:一旦命中缓存,FastAPI能跑到2600 QPS,P99只有120ms。原因是所有数据库查询都被省掉了,纯粹是Redis的GET和JSON反序列化。
- CPU使用率:优化后FastAPI版CPU从40%升到75%,说明瓶颈从I/O转移到了CPU(JSON序列化+Redis协议),这是健康的信号。
七、踩坑与优化:那些文档里没写的细节
坑1:asyncpg连接池默认不校验连接
如果PostgreSQL重启,asyncpg连接池里的旧连接不会自动断开。必须在create_async_engine里加pool_pre_ping=True,否则会报ConnectionResetError,而且错误是随机出现的,压测时特别难排查。
坑2:Redis连接数
FastAPI版如果每个请求都新建Redis连接,压测时会出现ConnectionRefusedError。用aioredis.from_url创建的连接池,默认max_connections=50,在256并发下不够用,需要显式设置max_connections=100。
坑3:gunicorn+gevent的猴子补丁
Flask版必须要在import gevent.monkey; gevent.monkey.patch_all()之后才能用-k gevent,而且必须在导入psycopg2之前。这个顺序问题会导致数据库连接卡死,表现是压测时QPS到500就上不去了。
优化:缓存穿透
压测时发现热点用户(比如测试账号)的缓存TTL到期后,瞬间有大量请求打到数据库。解决方式是加mutex锁:缓存未命中时,先尝试SETNX设置一个锁key,获取到锁的请求才查数据库,其他请求等待100ms后重试。但简单场景下用空值缓存(查不到数据也缓存[])就够了,TTL设30秒。
优化:SQL语句级优化
原始查询里LIMIT 20配合ORDER BY created_at DESC是没问题的,但orders表有50万行后,没有索引会导致排序全表扫描。加了个(user_id, created_at DESC)复合索引,查询时间从50ms降到8ms。这在压测数据里没体现,因为本地数据量小,但生产环境必须加。
八、总结:调优的本质是消除浪费
这次调优让我深刻体会到:
- 先profile再改代码。一开始以为是SQL问题,结果cProfile显示是懒加载和连接池。没有profile,我会在SQL上浪费时间。
- 同步+连接池优化已经能解决90%的问题。异步的收益在低并发下可以忽略,只有在连接数超过1万时才明显。FastAPI的异步优势体现在IO密集型场景,但前提是你已经把查询次数减到最少。
- 缓存是终极手段,但不是银弹。Redis缓存把P99从420ms降到120ms,但缓存穿透、雪崩、一致性都是新问题。对于订单这类最终一致性的数据,60秒TTL完全可以接受。
- 运维侧优化同样重要:gunicorn worker数量从4调到8,QPS提升35%;但调到16反而下降,因为上下文切换开销超过了并行收益。8核机器用8个worker是经验法则。
最终线上版本用的是FastAPI+asyncpg+Redis,配置为uvicorn --workers 8 --limit-max-requests 10000。如果你正在用Flask,也别急着迁移——先检查自己的连接池配置和ORM查询,那两处优化到位的收益比换框架大得多。