一、问题背景:一个“跑得动”但“扛不住”的API

上周上线了一个订单聚合接口,功能很简单:根据用户ID返回近30天订单统计。上线前手测单次响应120ms,感觉还行。结果压测工具(wrk)一上200并发,P95直接飙到380ms,数据库CPU瞬间打到95%。更诡异的是,数据库连接池疯狂报错,但慢查询日志里却看不到特别慢的SQL。

直觉告诉我,问题不在SQL本身,而在“SQL执行次数”和“数据序列化”上。用两个工具验证了猜想:cProfile(函数级CPU分析)和py-spy(生产环境采样),最终定位到三个瓶颈:N+1查询、重复计算、JSON序列化开销。

二、环境与版本:别用老版本踩坑

  • Python 3.11.9(3.10以下性能差异明显)
  • FastAPI 0.115.2 + Uvicorn 0.30.6(worker=4,loop=uvloop)
  • SQLAlchemy 2.0.35 + asyncmy 0.2.9(异步MySQL驱动)
  • Redis 7.2(单机,用于二级缓存)
  • 压测工具:wrk 4.2.0(线程4,连接200,时长30s)

注意:FastAPI 0.100之前和之后的异步性能差异很大。0.100+对异步路由支持更彻底,建议至少用0.100+。另外,SQLAlchemy 1.4和2.0的查询写法不兼容,2.0的select()语法性能更好。

三、第一步:Profiling——先定位,再动手

3.1 用cProfile抓函数级瓶颈

import cProfile
import pstats
from io import StringIO

# 假设这是你的FastAPI应用
from main import app
from fastapi.testclient import TestClient

client = TestClient(app)

# 模拟一次真实请求,捕获profile数据
profiler = cProfile.Profile()
profiler.enable()
client.get("/api/v1/users/12345/order-stats")
profiler.disable()

# 输出耗时Top 10
s = StringIO()
ps = pstats.Stats(profiler, stream=s).sort_stats("cumulative")
ps.print_stats(10)
print(s.getvalue())

输出关键行摘录:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
1       0.002    0.002    0.381    0.381  fastapi/routing.py:... (get_route_handler)
83      0.008    0.000    0.247    0.003  sqlalchemy/orm/loading.py:... (_load_scalar_from_orm)
83      0.011    0.000    0.225    0.003  sqlalchemy/dialects/mysql/asyncai.py:... (_execute_context)
1       0.067    0.067    0.067    0.067  json/encoder.py:... (default)

结论:83次ORM加载调用,累计占了0.247秒——典型的N+1。另外JSON序列化用了0.067秒,说明数据量不小。

3.2 用py-spy确认生产环境瓶颈

cProfile会显著拖慢速度(30%+),生产环境用py-spy采样更安全:

# 安装并获取进程PID
pip install py-spy
pgrep -f "uvicorn main:app"

# 采样10秒,生成火焰图SVG
py-spy record --pid  --duration 10 -o /tmp/perf.svg
py-spy top --pid  --duration 5

火焰图显示build_order_stats函数占用了58%的CPU,其中大部分时间消耗在OrderItem__repr__和关联的Product查询上。埋点确认:一次请求内部执行了83条SQL,其中80条是查询同一个商品表——典型的懒加载。

四、数据库查询优化:消灭N+1与重复查询

4.1 方案:用selectinload替代懒加载

原代码用的是relationship懒加载,每次访问order.items都会发一条SQL。改为显式预加载:

# 优化前(懒加载,N+1)
async def get_order_stats(user_id: int):
    orders = await session.execute(
        select(Order).where(Order.user_id == user_id)
    )
    orders = orders.scalars().all()

    total_amount = 0.0
    item_count = 0
    for order in orders:
        for item in order.items:  # 这里每访问一次发一条SQL!
            total_amount += item.price
            item_count += 1
    return {"total": total_amount, "count": item_count}

# 优化后(selectinload,2条SQL)
from sqlalchemy.orm import selectinload

async def get_order_stats(user_id: int):
    orders = await session.execute(
        select(Order)
        .options(selectinload(Order.items))  # 一次性预加载所有items
        .where(Order.user_id == user_id)
    )
    orders = orders.scalars().all()

    total_amount = sum(item.price for order in orders for item in order.items)
    item_count = sum(len(order.items) for order in orders)
    return {"total": total_amount, "count": item_count}

效果:SQL执行次数从83次降到2次。这一步直接让响应时间从380ms降到约150ms(压测P95)。

4.2 数据库端聚合:连Python计算都省了

既然只是求和和计数,直接在SQL里完成:

from sqlalchemy import func, select, and_

async def get_order_stats_fast(user_id: int):
    # 单条SQL聚合查询,数据库端完成计算
    result = await session.execute(
        select(
            func.coalesce(func.sum(OrderItem.price), 0.0).label("total"),
            func.count(OrderItem.id).label("count")
        )
        .join(Order, Order.id == OrderItem.order_id)
        .where(and_(Order.user_id == user_id, 
                    Order.created_at >= datetime.now() - timedelta(days=30)))
    )
    row = result.one()
    return {"total": row.total, "count": row.count}

这一版SQL从2条降到1条,而且不再需要Python遍历计算。P95降到约95ms。但压测200并发时,数据库CPU仍然在60%左右——查询本身不慢了,但每次都查同一个用户的数据,太浪费。

五、缓存策略:Redis+本地二级缓存,命中率94%

5.1 为什么需要两级缓存?

  • Redis缓存:跨进程共享,适合多worker部署,但网络I/O有开销(~1ms)
  • 本地缓存(字典+TTL):零网络开销,适合高频重复访问。但多worker下数据可能不一致,需要设置较短TTL(30-60秒)

对于这个接口,用户数据更新不频繁(订单T+1统计),完全可以接受60秒的缓存延迟。

5.2 核心实现代码

import json
import time
from functools import lru_cache
from fastapi import APIRouter, Depends
from redis.asyncio import Redis

router = APIRouter()

# 本地缓存:简单字典 + 过期时间
_local_cache = {}
_LOCAL_TTL = 30  # 本地缓存30秒

# Redis连接(全局复用)
redis_client = Redis(host="localhost", port=6379, decode_responses=True)
_REDIS_TTL = 60  # Redis缓存60秒

async def get_order_stats_cached(user_id: int):
    # 1. 查本地缓存(最快)
    cached = _local_cache.get(user_id)
    if cached and time.time() - cached[1] < _LOCAL_TTL:
        return cached[0]

    # 2. 查Redis(跨进程共享)
    redis_key = f"order_stats:{user_id}"
    redis_val = await redis_client.get(redis_key)
    if redis_val:
        data = json.loads(redis_val)
        _local_cache[user_id] = (data, time.time())
        return data

    # 3. 查数据库(唯一慢路径)
    data = await query_from_database(user_id)  # 即上一节的聚合SQL

    # 4. 回填缓存
    await redis_client.setex(redis_key, _REDIS_TTL, json.dumps(data))
    _local_cache[user_id] = (data, time.time())
    return data

@router.get("/api/v1/users/{user_id}/order-stats")
async def get_stats(user_id: int):
    return await get_order_stats_cached(user_id)

5.3 踩坑:缓存穿透与更新策略

  • 穿透:如果user_id不存在,会每次都查数据库。解决方案是缓存空结果({"total":0,"count":0})并设置较短TTL(如5秒)。
  • 更新:订单状态变更时,手动删除对应Redis key。我写了一个后台任务,在订单写入后await redis_client.delete(f"order_stats:{user_id}")
  • 本地缓存一致性:多worker下,本地缓存可能不一致。我接受30秒内不一致,因为这是报表数据,不是实时库存。

六、最终压测数据与总结

使用wrk压测30秒,200并发,结果对比:

指标 优化前 优化后 提升幅度
P50延迟 120ms 22ms 5.4x
P95延迟 380ms 48ms 7.9x
P99延迟 620ms 95ms 6.5x
吞吐量(QPS) 610 2150 3.5x
数据库CPU 95% 27% -72%
SQL执行次数/请求 83 1 98.8%

关键结论
1. 先Profiling再优化,不要凭感觉。cProfile+py-spy帮我们省了至少2小时瞎猜时间。
2. N+1问题在高并发下是致命的,ORM的懒加载是性能杀手。SQLAlchemy 2.0的selectinload是标准解法。
3. 缓存一定要分级。本地缓存(30秒)承担了90%的请求,Redis只做跨进程兜底。注意设置合理的TTL,避免一致性问题。
4. 数据库聚合优于Python计算。能把计算下推到数据库就下推,减少网络传输和Python开销。

现在这个接口在生产环境稳定运行了两周,P95一直维持在45-55ms之间。如果你也遇到类似的“单次请求快,并发一高就崩”的问题,建议按这个顺序排查:先看ORM查询次数,再看是否过度序列化,最后上缓存。

踩坑补充:不要用asyncio.gather去并发查询N个小SQL,那只是把N+1从串行变并行,数据库压力没减。正确做法永远是减少查询次数,而不是加速查询。