一、问题背景:一个“还能用”的接口背后的隐忧

事情始于一次线上告警:订单查询接口在促销活动期间平均响应时间飙升至2.3秒,数据库CPU打满。这个接口是基于Flask 2.2 + SQLAlchemy 1.4 + PostgreSQL 13构建的,逻辑很简单——根据用户ID查订单列表,关联商品和店铺信息。

最初代码写得很“直观”:

# app.py (Flask版本)
@app.route('/api/orders/')
def get_orders(user_id):
    orders = db.session.query(Order).filter(Order.user_id == user_id).all()
    result = []
    for order in orders:
        # 每个订单都触发一次商品查询
        product = db.session.query(Product).get(order.product_id)
        # 再触发一次店铺查询
        shop = db.session.query(Shop).get(order.shop_id)
        result.append({
            'order_id': order.id,
            'product_name': product.name,
            'shop_name': shop.name,
            'amount': order.amount
        })
    return jsonify(result)

这段代码有个经典问题:N+1查询。每个订单额外执行2条SQL,如果用户有50个订单,就要执行101条查询。在200并发下,数据库连接池直接被打爆。

二、环境与版本:调优前的基线

  • 应用框架:Flask 2.2.5 / FastAPI 0.104.1
  • ORM:SQLAlchemy 1.4.50 → 2.0.23(FastAPI版本)
  • 数据库:PostgreSQL 13.4,配置shared_buffers=1GB,work_mem=64MB
  • 缓存:Redis 6.2,maxmemory 512MB,allkeys-lru策略
  • 压测工具:wrk 4.2.0,单机8核8G
  • 部署:Gunicorn + gevent(Flask),Uvicorn(FastAPI)

基线压测命令:wrk -t8 -c200 -d60s http://localhost:8000/api/orders/12345

基线结果:P95=1843ms,P99=2.2s,吞吐率=318 req/s

三、方案设计:三层递进优化

不搞花活,直接按收益排序来:

  1. 第一层:消除N+1 —— 用SQLAlchemy的selectinload一次性加载关联对象,减少SQL执行次数。
  2. 第二层:序列化优化 —— 放弃Flask的jsonify,改用FastAPI + Pydantic v2的序列化,减少JSON处理时间。
  3. 第三层:Redis缓存 —— 对热点用户订单缓存5分钟,减少数据库压力。

四、核心实现:FastAPI重写 + SQLAlchemy 2.0优化

4.1 消除N+1查询

# main.py (FastAPI版本)
from fastapi import FastAPI, Depends
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import List

app = FastAPI()

class OrderOut(BaseModel):
    order_id: int
    product_name: str
    shop_name: str
    amount: float

    class Config:
        from_attributes = True

@app.get('/api/orders/{user_id}', response_model=List[OrderOut])
async def get_orders(user_id: int, session: AsyncSession = Depends(get_db)):
    # 关键:selectinload一次性加载关联对象
    result = await session.execute(
        select(Order)
        .options(
            selectinload(Order.product),
            selectinload(Order.shop)
        )
        .where(Order.user_id == user_id)
    )
    orders = result.scalars().all()
    # 直接返回ORM对象,Pydantic负责序列化
    return orders

这里有两个关键点:
- selectinload会生成WHERE id IN (...)的批量查询,将101条SQL降为3条。
- FastAPI的response_model使用Pydantic v2(基于Rust的pydantic-core)序列化,比Flask的jsonify快约5倍。

4.2 Redis缓存策略

# cache.py
import json
import redis
from fastapi import HTTPException

redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

CACHE_TTL = 300  # 5分钟

@app.get('/api/orders/{user_id}', response_model=List[OrderOut])
async def get_orders_cached(user_id: int, session: AsyncSession = Depends(get_db)):
    # 先查缓存
    cache_key = f"user_orders:{user_id}"
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # 缓存未命中,查数据库
    result = await session.execute(
        select(Order)
        .options(selectinload(Order.product), selectinload(Order.shop))
        .where(Order.user_id == user_id)
    )
    orders = result.scalars().all()

    # 序列化并写入缓存
    response_data = [OrderOut.model_validate(o).model_dump() for o in orders]
    redis_client.setex(cache_key, CACHE_TTL, json.dumps(response_data))
    return response_data

缓存策略选择:只缓存订单列表,不缓存单个商品(因为商品信息可能频繁变更)。TTL设300秒,配合Redis的allkeys-lru策略,避免内存溢出。

五、踩坑与优化:四个真实遇到的问题

踩坑1:异步SQLAlchemy的坑

FastAPI用async,但SQLAlchemy 1.4的异步支持不完整。必须升级到2.0+,并且注意session.execute()返回的是Result对象,要用.scalars().all()而不是.all()

踩坑2:selectinload的陷阱

selectinload会产生额外的IN查询,如果关联表数据量巨大(比如商品表100万行),首次加载会慢。解决方案:对商品表加(user_id, status)复合索引,实测索引后查询时间从120ms降到8ms。

踩坑3:Pydantic v2的field别名问题

from_attributes = True必须显式声明,否则ORM对象无法直接传给Pydantic。另外,model_validate()代替了v1的parse_obj()

踩坑4:Gunicorn worker类型

FastAPI必须用uvicorn worker,不能用gevent。部署命令改为:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

六、效果数据:优化前后对比

使用wrk压测,参数与基线完全一致,结果如下:

指标 Flask基线 FastAPI+SQL优化 +Redis缓存
吞吐率 318 req/s 1,450 req/s 2,100 req/s
P50 620ms 38ms 12ms
P95 1,843ms 89ms 42ms
P99 2,200ms 210ms 95ms
数据库QPS 32,000 4,500 1,200

数据库QPS从32000降到1200,意味着Redis缓存了约96%的请求。这彻底缓解了数据库压力,促销活动期间不再告警。

七、总结与方法论

这次调优的核心收获:

  1. 先profile再优化:用py-spy dump --pid看线程栈,用cProfile统计函数耗时,而不是靠猜。
  2. N+1是原罪:ORM框架都提供预加载机制(selectinload/joinedload),必须用。
  3. 序列化常被忽视:Python的json.dumps在大量数据下非常慢,Pydantic v2的Rust核心是重大改进。
  4. 缓存要分层:Redis缓存适合读多写少的热点数据,但注意缓存穿透和雪崩,后续可以考虑加布隆过滤器。

如果你也遇到类似性能问题,建议按此顺序排查:SQL查询数 → 序列化耗时 → 缓存命中率。不要一开始就上消息队列或分库分表,先把这三板斧用到位。

最后留个思考题:如果订单量过亿,这个方案还能撑住吗?欢迎评论区讨论。