一、问题背景:一个“看起来没毛病”的订单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,对itemsselectinload。如果对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。有问题评论区交流,别私信——我经常漏看私信。