1. 问题背景:一个“还行”的接口,压测就原形毕露

事情是这样的,上个月我们有个订单服务要上线,基于FastAPI 0.104 + SQLAlchemy 2.0 + PostgreSQL 15。功能都写完了,自测接口响应300ms左右,感觉“还行”。结果用Locust压测,200并发一上来,P95直接飙到2.1秒,数据库连接池被打满,CPU飙到85%,一堆超时告警。

我第一时间怀疑是数据库问题,但看慢查询日志,单条SQL都在50ms以内,不像是慢SQL。后来用cProfile一测,发现时间根本不在SQL上——90%的时间花在了对象序列化和懒加载触发上。这就是典型的“代码看着没问题,实际跑起来全是坑”。

2. 环境与版本:别问,问就是这套组合

先说环境,方便你复现:
- Python 3.11.6(注意,3.12的GIL改动对性能有影响,但生产环境我们保守用3.11)
- FastAPI 0.104.1(0.100之后性能提升明显,老版本直接升级)
- SQLAlchemy 2.0.23(2.0的ORM查询和1.x差别巨大,建议直接上2.0)
- uvicorn 0.24.0(worker用--workers 4,注意和--reload互斥)
- PostgreSQL 15.3(pg_stat_statements插件必开)
- Redis 7.0.13(缓存用,后面细说)
- 压测工具:Locust 2.19.1(比wrk好用,能看P95分布)

压测机8核16G,应用服务器4核8G,数据库独立4核8G。网络走内网,延迟 CPU计算 > 内存分配 > 锁竞争`。我们这次主要处理I/O(N+1查询)和CPU(序列化开销)。

4. 核心实现:三步走,每一步都有数据支撑

4.1 第一步:用cProfile和py-spy定位真正的瓶颈

先写个脚本压一下单次请求,用cProfile抓CPU时间:

# profile_api.py
import cProfile
import pstats
import asyncio
from httpx import AsyncClient

async def hit_api():
    async with AsyncClient(base_url="http://127.0.0.1:8000") as client:
        # 模拟真实请求:订单详情 + 用户信息 + 商品列表
        for _ in range(200):  # 跑200次求平均
            r = await client.get("/api/v1/orders/12345?include_items=true")
            assert r.status_code == 200

if __name__ == "__main__":
    profiler = cProfile.Profile()
    profiler.enable()
    asyncio.run(hit_api())
    profiler.disable()
    stats = pstats.Stats(profiler).sort_stats("cumulative")
    stats.print_stats(20)  # 只看前20行

输出结果(截取关键部分):

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
   200    0.002    0.000    4.213    0.021   serializers.py:38 (OrderOutSchema.dump)
   200    0.001    0.000    3.897    0.019   sqlalchemy/orm/loading.py:112 (instance_processor)
  1200    0.045    0.000    2.456    0.002   sqlalchemy/orm/relationships.py:210 (_emit_lazyload)

看到了吗?_emit_lazyload被调了1200次,每次大概2ms,累计2.456秒。这就是经典的N+1问题——查询订单后,访问order.userorder.items,每一条都触发一次新的SQL查询。200次请求,1200次懒加载,平均每次请求额外打6次数据库。

另外OrderOutSchema.dump耗时4.2秒,说明Pydantic v2的序列化也有开销,但主要原因是懒加载的数据在序列化时才去查库,等于把I/O延迟放到了序列化阶段。

结论:先解决N+1,再考虑序列化优化。

4.2 第二步:用selectinload重写ORM查询,消灭N+1

SQLAlchemy 2.0推荐用selectinload(不是老的joinedload——join会把结果集膨胀,selectin是分成两条SQL,一条查主表,一条用IN查关联表,每条都走索引,大数据量下更稳)。

# repositories/order_repository.py
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models import Order, OrderItem, User

async def get_order_with_details(session, order_id: int) -> Order:
    stmt = (
        select(Order)
        .options(
            selectinload(Order.user),          # 原来是懒加载,现在强制预加载
            selectinload(Order.items),         # 原来是懒加载,现在强制预加载
        )
        .where(Order.id == order_id)
    )
    result = await session.execute(stmt)
    return result.scalar_one_or_none()

改动就三行,但效果立竿见影。原来200次请求打了200 * (1 + 6) = 1400条SQL,现在变成200 * 2 = 400条,且每条都是走主键索引的查询。

顺便优化了Pydantic序列化——把response_model的字段明确写出来,不要用model_config = ConfigDict(from_attributes=True)让Pydantic自动遍历所有属性,因为遍历过程也会触发懒加载。

# schemas/order_schema.py
from pydantic import BaseModel

class OrderItemOut(BaseModel):
    sku: str
    price: float
    quantity: int

class OrderOut(BaseModel):
    id: int
    order_no: str
    total_amount: float
    items: list[OrderItemOut]

    # 关键:手动指定字段来源,避免Pydantic遍历ORM对象的所有属性
    @classmethod
    def from_orm_with_items(cls, order: Order) -> "OrderOut":
        return cls(
            id=order.id,
            order_no=order.order_no,
            total_amount=order.total_amount,
            items=[
                OrderItemOut(
                    sku=item.sku,
                    price=item.price,
                    quantity=item.quantity
                )
                for item in order.items
            ]
        )

4.3 第三步:加Redis缓存,把热点查询挡住

N+1解决后,接口P95降到600ms左右。但QPS还是上不去,因为数据库单条查询虽然快,但并发一高,连接池就饱和。这时候上Redis,只缓存两类数据:

  1. 商品信息(SKU、价格)——变化频率极低,缓存5分钟。
  2. 订单列表的前2页——用户翻页行为集中在前面,缓存1分钟。

用普通装饰器即可,但注意缓存穿透和雪崩。我们加了一个简单的cache_decorator

# utils/cache.py
import json
import hashlib
from redis import asyncio as aioredis

redis_client = aioredis.from_url(
    "redis://127.0.0.1:6379/0",
    decode_responses=True,
    max_connections=50,          # 连接池上限,默认10太小
    socket_connect_timeout=2,    # 连接超时,防止Redis挂了拖垮API
    socket_timeout=2,
)

def cache_response(ttl: int, prefix: str):
    def decorator(func):
        async def wrapper(*args, **kwargs):
            # 生成缓存key:函数名 + 参数hash
            key = f"{prefix}:{hashlib.md5(str(kwargs).encode()).hexdigest()}"
            cached = await redis_client.get(key)
            if cached:
                return json.loads(cached)

            result = await func(*args, **kwargs)
            # 只缓存成功响应,避免缓存空结果
            if result.status_code == 200:
                await redis_client.setex(key, ttl, json.dumps(result.body))
            return result
        return wrapper
    return decorator

注意两个细节:
- decode_responses=True,否则拿到的是bytes,每次要decode,浪费CPU。
- socket_timeout=2,如果Redis挂掉,API不能跟着挂,直接放行查数据库。

5. 踩坑与优化:你以为优化完了?还早

踩坑1:selectinload和分页的冲突。如果你用order_by + limitselectinload会在主查询之后追加一条IN查询,但分页的limit只作用于主表,没问题。但如果你用distinct,就会出问题——SQLAlchemy会先查主表再查关联表,distinct可能导致关联表数据错乱。解决方案:先查主表ID列表,再用where_in查子表,手动组装。

踩坑2:Redis缓存了但没设过期时间。上线第二天,商品价格改了三小时没生效,用户投诉。后来加了个delete_keys_by_pattern的管理接口,改价格时主动清缓存。

踩坑3:uvicorn workers数不是越多越好。我们4核8G的机器,一开始配了--workers 8,结果上下文切换开销大,QPS反而下降。实测4个worker最优(=CPU核心数),每个worker开--limit-concurrency 1000,配合数据库连接池pool_size=10, max_overflow=20,刚好不超。

踩坑4:压测时别用--reload。我们第一次压测忘了关--reload,文件监控线程每秒钟扫一次目录,CPU白白吃掉10%。生产环境一定用--workers 4 --no-access-log,关掉访问日志也能省5%CPU。

6. 效果数据:从2.1s到180ms,全程有记录

同一个压测脚本,200并发跑5分钟,数据如下:

指标 调优前 调优后 提升
P50延迟 890ms 85ms 10.5x
P95延迟 2.1s 180ms 11.7x
P99延迟 3.4s 320ms 10.6x
QPS 230 2140 9.3x
数据库连接池使用率 98%(饱和) 42% -
CPU使用率 85% 53% -

数据库层面,pg_stat_statements显示:
- 总SQL执行次数从1400次/请求降到2次/请求。
- 单条SQL平均耗时从52ms降到8ms(主要是因为没有懒加载的额外往返)。

Redis命中率:压测期间约87%。商品信息缓存命中率98%(5分钟TTL),订单列表命中率65%(1分钟TTL),整体命中率在可接受范围。

7. 总结:调优的顺序比技巧更重要

这次调优最大的感触是:先定位再动手,别用直觉猜瓶颈。我见过太多人上来就加Redis,结果发现瓶颈是序列化,缓存加了个寂寞。

我的调优顺序建议:
1. Profiling(cProfile/py-spy) → 找到时间花在哪。
2. 数据库层(pg_stat_statements/慢查询日志) → 解决N+1和缺索引。
3. 序列化层(Pydantic/手动字段映射) → 减少CPU开销。
4. 缓存层(Redis) → 挡住高频热点查询。
5. 并发层(uvicorn workers/连接池参数) → 最后调,因为要基于前几步的实际负载来定。

另外,压测数据一定要记录,每次改动跑一轮,用数据说话。没有对比数据的调优,都是耍流氓。

最后说一句,FastAPI性能本身不差,差的是开发者怎么用ORM和序列化。SQLAlchemy 2.0的selectinload + Pydantic v2的model_validate是绝配,但前提是你得理解懒加载的触发时机。希望这篇能帮你少踩几个坑。