一、问题背景:一次深夜告警引发的性能排查

上周四晚10点,监控系统连续推送告警:生产环境订单查询接口P95延迟突破2秒,错误率上升至3.2%。这个接口承担了全站60%的读流量,日调用量约120万次。第一反应是数据库连接池打满,但查看RDS监控发现CPU使用率仅35%,慢查询日志也只有零星几条。直觉告诉我——问题出在应用层。

二、环境与版本

生产环境配置如下:

  • Python 3.10.12(注意:3.11+对asyncio有额外优化,但为了兼容旧依赖未升级)
  • FastAPI 0.104.1 + Uvicorn 0.24.0(workers=4, loop="uvloop")
  • SQLAlchemy 1.4.50 + asyncpg 0.29.0
  • Redis 7.0(部署在同一VPC,内网延迟0.4ms)
  • 压测工具:wrk 4.2.0 + Apache Bench 2.3

对比组使用Flask 3.0.0 + Gunicorn 21.2.0(worker_class="gevent"),部署在同一规格容器(4C8G)。

三、方案设计:先Profile,别急着加缓存

很多开发者遇到性能问题第一反应就是加Redis缓存,但缓存只能掩盖问题。我的调优路径:

  1. 用cProfile定位CPU热点
  2. 用Py-Spy dump线程栈分析阻塞点
  3. 针对瓶颈做代码级优化
  4. 最后才考虑缓存策略

四、核心实现:定位与优化

4.1 Profiling:找到真凶

先用cProfile跑一轮压测(5000个请求):

import cProfile
import pstats
from app.main import app
from fastapi.testclient import TestClient

client = TestClient(app)

def run_benchmark():
    for _ in range(200):
        client.get("/api/v1/orders?user_id=12345")

profiler = cProfile.Profile()
profiler.enable()
run_benchmark()
profiler.disable()

stats = pstats.Stats(profiler)
stats.sort_stats("cumulative").print_stats(20)

输出关键行(已脱敏):

ncalls  tottime  percall  cumtime  percall  filename:lineno
  200    0.012    0.000    4.382    0.022  api/order.py:45(get_orders)
  800    3.214    0.004    3.214    0.004  sqlalchemy/engine/base.py:1700(_execute_context)
 1200    2.876    0.002    2.876    0.002  asyncpg/protocol/protocol.pyx:1234(bind_execute)
  400    0.987    0.002    0.987    0.002  pydantic/main.py:214(serialize)

问题清晰了:

  • N+1查询:800次数据库查询对应200个请求,平均每个请求执行4次查询(订单表+商品表+用户表+地址表)
  • 序列化开销:Pydantic v2的序列化占用了约25%的CPU时间

4.2 数据库查询优化:selectinload 解决N+1

原代码是典型的懒加载方式:

# 优化前:每次访问 order.items 都会触发一次DB查询
async def get_orders(user_id: int):
    async with async_session() as session:
        result = await session.execute(
            select(Order).where(Order.user_id == user_id)
        )
        orders = result.scalars().all()
        # 这里会触发 N 次查询
        return [order_to_dict(o) for o in orders]

改为显式加载:

from sqlalchemy.orm import selectinload

# 优化后:一条SQL JOIN + IN查询
async def get_orders(user_id: int):
    async with async_session() as session:
        result = await session.execute(
            select(Order)
            .options(
                selectinload(Order.items),
                selectinload(Order.user),
                selectinload(Order.address)
            )
            .where(Order.user_id == user_id)
        )
        orders = result.scalars().all()
        return [order_to_dict(o) for o in orders]

注意:不要用 joinedload,因为joinedload在分页场景下会产生重复行,且对一对多关系会生成巨大笛卡尔积。selectinload会先查主表,再用WHERE id IN (...)查询关联表,对一对多关系更友好。

4.3 序列化优化:Pydantic v2 的 Model 直接输出

另一个优化点是避免手动构建dict,直接使用Pydantic schema:

from pydantic import BaseModel, ConfigDict
from typing import List

class OrderItemOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    sku_id: int
    quantity: int
    price: float

class OrderOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    user_id: int
    items: List[OrderItemOut]
    total_amount: float

# 在路由中直接返回ORM对象
@app.get("/api/v1/orders", response_model=List[OrderOut])
async def get_orders(user_id: int):
    # ...查询逻辑同上
    return orders  # FastAPI自动调用Pydantic序列化

这里有个关键细节:Pydantic v2用Rust重写了序列化核心,比v1快10倍以上。但要注意from_attributes=True必须配置,否则无法直接序列化ORM对象。

五、踩坑与优化:缓存策略的陷阱

5.1 第一个坑:缓存穿透

优化完查询和序列化后,P95降到680ms。但还不够,继续加Redis缓存。第一版缓存代码:

async def get_orders_cached(user_id: int):
    cache_key = f"user_orders:{user_id}"
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    orders = await get_orders_from_db(user_id)
    await redis.setex(cache_key, 300, json.dumps(orders))
    return orders

压测发现:缓存命中率只有45%。原因是用户订单数据是热点分散的,大部分用户只被查询一次。更严重的是,当缓存过期时,大量请求同时打到数据库,导致雪崩。

5.2 解决:本地缓存 + 分布式锁 + 空值缓存

from functools import lru_cache
import asyncio

# 本地二级缓存(进程内),避免频繁访问Redis
@lru_cache(maxsize=1024)
def get_local_cache(key: str) -> str | None:
    return None  # 实际使用ttl_cache库会更好

async def get_orders_optimized(user_id: int):
    cache_key = f"user_orders:{user_id}"

    # 一级缓存:进程内(使用cachetools的TTLCache)
    local_data = local_cache.get(cache_key)
    if local_data:
        return local_data

    # 二级缓存:Redis
    redis_data = await redis.get(cache_key)
    if redis_data:
        local_cache[cache_key] = redis_data
        return json.loads(redis_data)

    # 三级:数据库(加锁防止缓存击穿)
    async with redis.lock(f"lock:{cache_key}", timeout=2):
        # 双重检查:可能其他进程已经写入
        redis_data = await redis.get(cache_key)
        if redis_data:
            return json.loads(redis_data)

        orders = await get_orders_from_db(user_id)
        await redis.setex(cache_key, 300, json.dumps(orders))
        # 缓存空值,防止恶意遍历
        if not orders:
            await redis.setex(cache_key, 60, json.dumps([]))
        return orders

注意:lru_cache不适合做TTL缓存,推荐用cachetools.TTLCache。本地缓存要注意内存上限,我们设置为maxsize=1024, ttl=60秒。

六、效果数据:压测对比

用wrk压测(并发100,持续60秒):

指标 优化前 查询优化后 +缓存优化 +Flask对比组
吞吐量 (req/s) 280 720 960 410
P50延迟 (ms) 220 95 52 180
P95延迟 (ms) 820 340 180 650
P99延迟 (ms) 1500 620 340 1200
数据库QPS 1120 280 45 890
CPU使用率 85% 70% 45% 90%

关键结论:

  • 数据库查询优化将DB QPS从1120降到280,这才是根本解决
  • 缓存优化进一步将DB QPS降到45,CPU使用率减半
  • Flask+gevent + 同样缓存逻辑只有410 req/s,主要是GIL限制和gevent的monkey patch开销

七、总结

这次调优的核心收获:

  1. 性能优化顺序:Profile → 代码优化 → 缓存,不要跳过第二步。很多人直接上缓存,结果数据库压力没降多少,Redis反而成为新瓶颈。
  2. selectinload vs joinedload:在FastAPI+SQLAlchemy异步场景下,selectinload几乎总是优于joinedload,除非是单表一对一关联。
  3. 缓存分层:本地缓存(毫秒级)→ Redis(微秒级)→ 数据库(毫秒级)。但本地缓存要处理多worker进程的数据一致性,我们通过Redis pub/sub广播失效通知。
  4. FastAPI vs Flask:在I/O密集型API场景,FastAPI的原生async + uvloop比Flask+gevent有约2.3倍性能优势。但Flask生态更成熟,如果团队不熟悉async,Flask+greenlet也不差。

最后提醒:生产环境一定要开启Uvicorn的--limit-concurrency防止突发流量打满线程池,我们设置为1024。另外,Redis连接池要设置为max_connections=100,不然高并发下会出现ConnectionError

以上是这次的完整调优记录,希望对你有帮助。有问题欢迎在评论区交流。