1. 问题背景:一个“简单”的列表接口为何扛不住?

上个月负责一个内部数据平台的API重构。业务逻辑很简单:查询某时间范围内的订单列表,每页20条,返回订单号、金额、用户名称。旧系统用Flask 1.1.2 + SQLAlchemy 1.3.23 + MySQL 5.7,接口响应时间在低并发时约500ms,但生产环境一旦超过50并发,平均响应时间飙升到10秒以上,频繁超时。接手后决定用FastAPI 0.78.0 + Uvicorn 0.17.6重写,但第一版压测结果依然惨不忍睹——必须拆解到底层。

2. 环境与版本

  • 压测工具:wrk 4.2.0
  • Python版本:3.9.13
  • Web框架:FastAPI 0.78.0 / Flask 1.1.2
  • ORM:SQLAlchemy 1.4.40(FastAPI版)/ 1.3.23(Flask版)
  • 数据库:MySQL 5.7.38(事务隔离级别 RR,连接池大小10)
  • 缓存:Redis 6.2.6(单机,无集群)
  • 部署:单台4C8G阿里云ECS,Ubuntu 20.04,容器化运行

3. 定位瓶颈:别猜,用数据说话

先写一个简易的profiling中间件,对每个请求进行cProfile采样,生成火焰图。这里给一个可直接运行的profiling装饰器(FastAPI版本):

# profiling_middleware.py
import cProfile
import io
import pstats
from functools import wraps
from fastapi import Request, Response
import time

def profile_endpoint(func):
    @wraps(func)
    async def wrapper(request: Request, *args, **kwargs):
        pr = cProfile.Profile()
        pr.enable()
        start = time.perf_counter()
        response = await func(request, *args, **kwargs)
        elapsed = time.perf_counter() - start
        pr.disable()

        s = io.StringIO()
        ps = pstats.Stats(pr, stream=s).sort_stats('cumtime')
        ps.print_stats(20)  # 只打印前20耗时

        # 写入日志或返回header
        print(f"Endpoint {request.url.path} took {elapsed:.3f}s")
        print(s.getvalue()[:2000])  # 截断输出
        return response
    return wrapper

# 使用方式:在路由上 @router.get("/orders")(profile_endpoint)

第一次压测火焰图显示:__json_serialize 占39%,User.query.get 占32%,Order.query.filter 占18%。问题一目了然:

  • 序列化开销:FastAPI默认用Pydantic的json.dumps,每行数据都重新序列化,20条订单重复生成200次时间戳对象。
  • ORM懒加载User.query.get 在循环中执行,每次查询都新建数据库连接——N+1查询。
  • 无连接池复用:SQLAlchemy默认没有开启连接池回收,高并发下频繁建连。

4. 方案一:ORM查询优化——干掉N+1与连接池配置

第一个优化点:将循环中的单条用户查询改为预加载(eager loading),同时配置连接池。

核心代码改动(基于FastAPI + SQLAlchemy 1.4):

# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.pool import QueuePool

# 关键配置:连接池大小20,溢出10,回收300秒
engine = create_engine(
    "mysql+pymysql://user:pass@host:3306/orders",
    poolclass=QueuePool,
    pool_size=20,
    max_overflow=10,
    pool_recycle=300,
    pool_pre_ping=True
)
SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine))

# order_service.py
from sqlalchemy.orm import joinedload
from models import Order, User

def get_orders_with_user(db, limit=20, offset=0):
    # 之前写法:先查订单,再循环查用户
    # orders = db.query(Order).offset(offset).limit(limit).all()
    # 优化后:一次性join加载user
    orders = db.query(Order).options(
        joinedload(Order.user)  # 假设Order模型有user关系
    ).offset(offset).limit(limit).all()

    # 直接访问Order.user不再触发额外查询
    result = []
    for order in orders:
        result.append({
            "order_id": order.id,
            "amount": order.amount,
            "user_name": order.user.name  # 懒加载已预填充
        })
    return result

踩坑joinedload 会默认内连接,如果订单没有对应用户则结果集减少。改用 contains_eager + 显式join可保留左外连接。另外注意SQLAlchemy 1.4中 joinedload 默认惰性加载策略改为selectinload,需要显式指定。

效果:单次接口响应从500ms降至220ms(数据库查询从35次减少到1次),但序列化仍占大头。

5. 方案二:序列化与响应体优化——使用ujson与StreamingResponse

Pydantic的默认JSON序列化器是标准库json,处理大量datetime对象时性能差。替换为ujson,同时对于大列表返回使用StreamingResponse逐块生成。

# main.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import ujson
import datetime

app = FastAPI()

def custom_serializer(obj):
    if isinstance(obj, datetime.datetime):
        return obj.isoformat()
    raise TypeError(f"Type {type(obj)} not serializable")

def json_streamer(data_generator):
    """逐块生成JSON数组,避免一次性构建大字符串"""
    yield "["
    first = True
    for item in data_generator:
        if not first:
            yield ","
        first = False
        # 使用ujson.dumps,速度比标准json快3-5倍
        yield ujson.dumps(item, default=custom_serializer)
    yield "]"

@app.get("/orders/stream")
def get_orders_stream(page: int = 1, page_size: int = 20):
    db = SessionLocal()
    try:
        orders = get_orders_optimized(db, page, page_size)
        # 生成器逐条返回
        def gen():
            for o in orders:
                yield {
                    "order_id": o.id,
                    "amount": float(o.amount),
                    "user_name": o.user.name,
                    "created_at": o.created_at
                }
        return StreamingResponse(
            json_streamer(gen()),
            media_type="application/json"
        )
    finally:
        db.close()

踩坑StreamingResponse 虽然减少了内存,但压测时发现wrk无法正确处理分块传输编码,需要设置 Transfer-Encoding: chunked,且前端需要支持流式解析。如果客户端不支持,建议用普通Response + ujson一次性序列化。

效果:序列化耗时从120ms降至25ms,整体接口响应降至180ms。

6. 方案三:缓存策略——一级缓存+Redis二级缓存

数据库查询优化后,QPS从150提升到800,但距离目标3000 QPS还有差距。引入两级缓存:
- 一级缓存:SQLAlchemy的cache选项(需要安装sqlalchemy-caching),或简单的lru_cache装饰器对耗时计算函数缓存。
- 二级缓存:Redis缓存序列化后的响应体,TTL设为60秒。

核心实现(FastAPI中间件+Redis):

# cache_middleware.py
import hashlib
import json
import redis.asyncio as aioredis
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

class RedisCacheMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # 只缓存GET请求
        if request.method != "GET":
            return await call_next(request)

        # 生成缓存key(基于路径+查询参数)
        cache_key = f"api_cache:{request.url.path}:{hashlib.md5(json.dumps(dict(request.query_params)).encode()).hexdigest()}"

        # 尝试从Redis获取
        cached = await redis_client.get(cache_key)
        if cached:
            return Response(content=cached, media_type="application/json")

        # 执行真实请求
        response = await call_next(request)

        # 写入Redis,TTL 60秒
        if response.status_code == 200:
            body = b""
            async for chunk in response.body_iterator:
                body += chunk
            await redis_client.setex(cache_key, 60, body.decode())
            # 重新构造Response
            return Response(content=body, media_type=response.media_type, status_code=response.status_code)

        return response

效果数据(wrk压测,30秒,10线程,100连接):

优化阶段 平均延迟 P90延迟 QPS CPU利用率
原始Flask 9.8s 15.2s 52 35%
FastAPI + ORM优化 220ms 380ms 812 55%
+ ujson序列化 180ms 290ms 1050 60%
+ 连接池/预加载 140ms 210ms 1580 72%
+ Redis缓存 18ms 35ms 3210 45%

缓存命中率约85%时,QPS从1580飙升至3210,CPU反而下降,因为大量请求直接返回缓存。

7. 总结与反思

这次优化让我确认了几件事:
1. 永远先profiling:不要凭感觉优化。cProfile火焰图直接告诉我序列化和N+1是元凶,而不是数据库索引。
2. ORM不是恶魔,懒加载才是:正确使用joinedload和连接池,SQLAlchemy 1.4的性能足以媲美原生SQL。
3. 缓存要分层次:一级缓存(进程内)扛住瞬时高并发,二级缓存(Redis)做跨实例共享。但注意缓存失效风暴——我设置了TTL抖动(±10秒)防止雪崩。
4. FastAPI的StreamingResponse是双刃剑:适合大结果集,但兼容性不如标准Response。

最终这个接口从用户反馈“慢到想砸键盘”变成了“秒开”。如果你也遇到类似的性能问题,建议先跑一遍profiling,大概率会发现80%的瓶颈在20%的代码里。