一、问题背景:一个“看起来没毛病”的订单API
业务方反馈:订单详情页每次刷新要等2-3秒,用户流失率明显上升。查看监控,该接口平均响应时间1.7s,P95达到2.8s,但CPU和内存占用率并不高(CPU 45%,内存 2.1GB/4GB)。典型的IO密集型瓶颈——数据库查询和JSON序列化拖了后腿。
环境如下:
- Python 3.11.4
- Flask 2.3.3 + Gunicorn 20.1.0 (2 workers, sync worker)
- SQLAlchemy 2.0.19 + psycopg2 2.9.7
- PostgreSQL 15.2 (单机,shared_buffers=1GB)
- Redis 7.0.11 (单机,maxmemory 512MB, allkeys-lru)
- 压测工具:wrk 4.2.0 (单线程,长连接)
二、先定位再说:PySpy火焰图与慢查询日志
别急着加缓存。用py-spy直接挂在生产进程上抓现场:
# 安装
pip install py-spy
# 抓取10秒火焰图,导出为svg
py-spy record --pid 12345 --duration 10 --format flamegraph -o /tmp/order_api.svg
# 同时开启PostgreSQL慢查询日志
vim /etc/postgresql/15/main/postgresql.conf
log_min_duration_statement = 200ms
log_line_prefix = '%t [%p]: '
火焰图显示热区集中在两个函数:sqlalchemy.orm.query.Query.__iter__(占62%)和flask.json.provider.DefaultJSONProvider.dumps(占23%)。慢查询日志里全是同一类语句,例如:
[2023-11-02 14:31:22] [12345]: duration: 850ms execute SELECT * FROM orders WHERE id = $1
[2023-11-02 14:31:22] [12346]: duration: 830ms execute SELECT * FROM order_items WHERE order_id = $1
[2023-11-02 14:31:22] [12347]: duration: 780ms execute SELECT * FROM users WHERE id = $1
每个订单详情页要查5张表:orders、order_items、users、products、shipping_addresses,且orders表本身还有3个子查询。典型的N+1问题——一个订单详情请求,实际执行了20+条SQL。更别提ORM默认的懒加载(lazy='select')在循环里逐条查询。
三、第一刀:SQLAlchemy预加载与查询瘦身
先看原始代码(简化版):
# Flask + SQLAlchemy 懒加载写法(问题代码)
@app.route('/api/orders/')
def get_order(order_id):
order = db.session.query(Order).filter(Order.id == order_id).one()
# 每次访问 order.items 都会触发一条新SQL
items = [{
'id': item.id,
'product_name': item.product.name, # 懒加载 product
'price': item.price
} for item in order.items]
# user 也是懒加载,而且用了子查询计算总额
user = order.user
total = db.session.query(func.sum(OrderItem.price)).filter(OrderItem.order_id == order_id).scalar()
return jsonify({'order': order.to_dict(), 'items': items, 'user': user.name, 'total': total})
改用selectinload预加载,一次性把关联对象取回来,并将子查询改为窗口函数减少一次往返:
# 优化后:SQLAlchemy 2.0 selectinload
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import func, select
@app.route('/api/orders/')
def get_order(order_id):
stmt = (
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
joinedload(Order.user),
selectinload(Order.shipping_address)
)
.where(Order.id == order_id)
)
order = db.session.execute(stmt).scalar_one()
# 总额用Python累加,不再单独查库
total = sum(item.price for item in order.items)
return jsonify({
'order_id': order.id,
'user': order.user.name,
'items': [{'id': i.id, 'product': i.product.name, 'price': i.price} for i in order.items],
'total': round(total, 2)
})
注意selectinload在SQLAlchemy 2.0中替代了旧的subqueryload,它先在内存中收集主键,再一次性IN查询,比subqueryload少了临时表开销。同时把原本的func.sum子查询改成Python层累加,因为items已经全量加载,再查一次纯属浪费。
踩坑提醒:joinedload对collection(一对多)会把结果集笛卡尔积膨胀,所以这里只对user(多对一)用joinedload,对items用selectinload。如果对collection用joinedload,你会在火焰图里看到一个“膨胀10倍”的循环。
四、第二刀:Redis缓存策略——热点数据直连
数据库查询优化后,单次响应从1.7s降到410ms。但QPS一高,数据库连接池还是扛不住。订单数据有明确的热点特征(近期订单高频访问),用Redis做两级缓存:
# 缓存层代码(Redis + 手动序列化)
import redis
import pickle
redis_client = redis.Redis(host='127.0.0.1', port=6379, db=0, decode_responses=False)
CACHE_TTL = 300 # 5分钟
@app.route('/api/orders/')
def get_order_cached(order_id):
cache_key = f'order:detail:{order_id}'
# 第一级:Redis 读
cached = redis_client.get(cache_key)
if cached:
return jsonify(pickle.loads(cached))
# 第二级:查库(经过预加载优化后的查询)
stmt = (
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
joinedload(Order.user),
selectinload(Order.shipping_address)
)
.where(Order.id == order_id)
)
order = db.session.execute(stmt).scalar_one()
data = {
'order_id': order.id,
'user': order.user.name,
'items': [{'id': i.id, 'product': i.product.name, 'price': i.price} for i in order.items],
'total': round(sum(i.price for i in order.items), 2)
}
# 写入缓存,用pickle保留float精度,避免json.dumps丢失
redis_client.setex(cache_key, CACHE_TTL, pickle.dumps(data))
return jsonify(data)
缓存策略细节:
- TTL 300秒:订单状态在5分钟内变化可接受,超过5分钟强制回源,避免长时间脏数据。
- pickle vs JSON:pickle序列化比json快2-3倍(实测:pickle 0.8ms vs json 2.1ms),且不需要手动处理Decimal类型。缺点是跨语言不可读,但这里只有Python服务消费,没问题。
- 缓存穿透防护:如果订单不存在,也缓存一个空对象(TTL 60秒),防止恶意请求穿透到数据库。
踩坑提醒:不要用json.dumps直接缓存order.to_dict(),因为to_dict()里可能有datetime对象,json.dumps会抛异常或转成字符串,反序列化后还得再解析。用pickle一步到位。
五、第三刀:FastAPI异步路线与Gunicorn混合部署
Flask是同步WSGI,每个请求占一个worker线程,2个worker最多并发2个请求(sync worker模型)。换FastAPI + Uvicorn(异步ASGI),配合async def路由和await数据库操作,单进程就能扛几百并发。
对比测试代码:
# FastAPI 异步路由
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
import asyncpg
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/db"
engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=20)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int):
async with AsyncSessionLocal() as session:
stmt = (
select(Order)
.options(selectinload(Order.items).selectinload(OrderItem.product))
.where(Order.id == order_id)
)
result = await session.execute(stmt)
order = result.scalar_one()
return {'order_id': order.id, 'items': [{'id': i.id} for i in order.items]}
部署配置——Gunicorn作为进程管理器,Uvicorn worker处理请求:
# gunicorn.conf.py
workers = 4 # 4个进程
worker_class = "uvicorn.workers.UvicornWorker"
threads = 1 # 异步worker不需要线程
timeout = 30
注意:UvicornWorker必须配uvicorn.workers,不能用sync worker。否则异步代码会跑在同步线程里,性能反而更差。
六、压测对比:从2.8s到310ms的完整数据
用wrk压测,参数:wrk -t4 -c200 -d30s http://127.0.0.1:8000/api/orders/123 --latency
| 阶段 | 平均延迟 | P95延迟 | QPS | 错误率 |
|---|---|---|---|---|
| 原始Flask + 懒加载 | 1.7s | 2.8s | 210 | 0.2% (超时) |
| Flask + selectinload | 410ms | 620ms | 480 | 0% |
| Flask + selectinload + Redis | 180ms | 310ms | 980 | 0% |
| FastAPI + asyncpg + Redis | 145ms | 260ms | 1250 | 0% |
| FastAPI + asyncpg + Redis (4 workers) | 150ms | 270ms | 1120 | 0% |
关键结论:
1. selectinload单独就减少了75%的延迟(1.7s→410ms),因为SQL从20+条降到3条。
2. Redis缓存让P95进一步降到310ms,且QPS提升2倍(480→980),因为大部分请求不再访问PostgreSQL。
3. FastAPI异步模型在单进程下QPS比Flask高28%(1250 vs 980),但4 workers时反而略降——因为PostgreSQL连接池和Redis连接数成为瓶颈。最终生产环境用了FastAPI+2 workers,QPS稳定在1100左右。
压测时的坑:wrk默认使用HTTP/1.1,但FastAPI的keep-alive默认关闭。需要显式配置--http 1.1并设置--header "Connection: keep-alive",否则每个请求都要重新建TCP连接,QPS会掉一半。另外,wrk的线程数要等于CPU核数,-t4在8核机器上会不均。
七、总结与后续优化方向
性能调优的顺序很重要:先定位(profile)→ 再优化IO(SQL/缓存)→ 最后考虑并发模型。我们最开始加缓存和换框架,效果都不明显,直到用py-spy抓到N+1查询,才算真正解决。
留几个后续可以做的点:
1. Redis集群:目前单节点,内存不够时LRU会淘汰热点,建议分片。
2. P99优化:目前P99还有1.1s,是冷缓存回源查询PostgreSQL导致的。可以加一层本地内存缓存(如caffeine等价物cachetools),TTL短一点(30秒)。
3. 异步ORM:FastAPI+asyncpg已经上了,但SQLAlchemy的异步session开销比同步大,如果QPS再高,考虑纯SQL或databases库。
最后附上所有代码的GitHub链接(虚构):github.com/yourname/order-api-tuning。有问题评论区交流,别私信——我经常漏看私信。