1. 背景:一个"能用但很慢"的接口

上个月接手了一个用户画像聚合服务,基于FastAPI + SQLAlchemy 2.0 + PostgreSQL 14构建。核心接口/api/v1/user/profile需要聚合用户基础信息、最近30天订单统计、以及动态标签。线上环境(4C8G容器)压测时发现:

  • 平均响应时间:258ms
  • P99:1.2s(严重超标)
  • QPS:620

业务方反馈"能用但很慢",尤其是大促期间经常超时。我先用locust做了一轮基础压测,确认瓶颈不在网关层,于是开始系统排查。

2. 环境与工具链

先交代一下具体版本,方便大家复现:

Python: 3.11.4
FastAPI: 0.104.1
Uvicorn: 0.24.0 (worker=4)
SQLAlchemy: 2.0.23
PostgreSQL: 14.9 (max_connections=200)
Redis: 7.0.12
压测工具: locust 2.18.4 (200并发, 持续5分钟)

调优工具我主要用了两个:

  • py-spy:无侵入式采样,直接对运行中的进程做火焰图分析,不需要改代码
  • cProfile:配合cProfile.Profile()做局部函数的精细分析

3. 第一步:用py-spy定位热点函数

启动服务后,用locust跑到100并发,然后立刻对worker进程采样:

# 找到worker的PID
ps aux | grep uvicorn | grep -v grep

# 采样30秒,生成火焰图
py-spy record -p 12345 -o profile.svg --duration 30

打开火焰图后,问题一目了然:

  1. UserProfileRepository.get_user_profile占用了47%的CPU时间
  2. 其中SQLAlchemy的Session.execute占了30%
  3. 而且有大量lazy_load调用——典型的N+1查询

再配合cProfile做精细统计:

import cProfile
import pstats
from app.api.v1.endpoints import user_profile

profiler = cProfile.Profile()
profiler.enable()
# 手动调用一次接口逻辑
result = user_profile.get_profile_sync(user_id=12345)
profiler.disable()

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

输出显示:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
     1    0.042    0.042    0.187    0.187  user_profile.py:56(get_profile)
    12    0.051    0.004    0.135    0.011  orm.py:1206(load)
     5    0.028    0.006    0.089    0.018  base.py:190(execute)

12次lazy_load,每次平均11ms——这就是罪魁祸首。

4. 方案设计:三管齐下

定位到问题后,我设计了三个优化点:

4.1 干掉N+1:改写为显式JOIN查询

原来的代码用ORM的relationship懒加载,每个用户的订单都要单独查一次。改为一次性JOIN查询:

# 优化前:N+1查询,12次DB往返
async def get_user_profile_old(user_id: int):
    user = await db.get(User, user_id)
    orders = await user.orders  # lazy load
    tags = await user.tags      # lazy load
    return assemble(user, orders, tags)

# 优化后:单次JOIN,3次查询(user/orders/tags各一次)
from sqlalchemy import select, func

async def get_user_profile_new(user_id: int):
    # 用户基础信息
    user_stmt = select(User).where(User.id == user_id)
    user = (await db.execute(user_stmt)).scalar_one()

    # 最近30天订单统计 - 用聚合函数,避免加载全部订单行
    order_stmt = (
        select(
            Order.user_id,
            func.count(Order.id).label('order_count'),
            func.sum(Order.amount).label('total_amount'),
        )
        .where(Order.user_id == user_id, Order.created_at >= now - timedelta(days=30))
        .group_by(Order.user_id)
    )
    order_stats = (await db.execute(order_stmt)).mappings().first()

    # 标签直接查关联表
    tag_stmt = select(Tag.name).join(UserTag).where(UserTag.user_id == user_id)
    tags = list((await db.execute(tag_stmt)).scalars())

    return assemble(user, order_stats, tags)

4.2 引入Redis缓存:缓存订单统计+标签

订单统计和标签是变化频率低的数据,非常适合缓存。我加了30秒的TTL,用user_id做key:

import json
import redis.asyncio as aioredis

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

CACHE_TTL = 30  # 秒

async def get_cached_order_stats(user_id: int):
    cache_key = f"user:order_stats:{user_id}"
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # 缓存未命中,查数据库
    order_stats = await query_order_stats_from_db(user_id)
    # 写入缓存,过期时间30秒
    await redis_client.set(cache_key, json.dumps(order_stats), ex=CACHE_TTL)
    return order_stats

4.3 优化序列化:避免Pydantic的重复校验

发现一个隐藏坑:响应模型里对Decimal字段做了两次Decimal转换,Pydantic的validator也被调用了两次。我直接用了model_validate替代手动构造,并关闭了不需要的字段校验:

from pydantic import BaseModel, ConfigDict

class UserProfileOut(BaseModel):
    model_config = ConfigDict(validate_assignment=False, extra='ignore')

    user_id: int
    username: str
    order_count: int
    total_amount: float
    tags: list[str]

5. 踩坑与优化细节

坑1:selectinload并不总是最优解

一开始我想偷懒,直接用SQLAlchemy的selectinload替代lazy load:

stmt = select(User).options(selectinload(User.orders)).where(User.id == user_id)

结果压测发现,虽然减少了DB往返,但selectinload会生成额外的IN子查询,在orders表数据量大时反而更慢。最终改用显式聚合查询,从12次查询降到3次。

坑2:Redis连接池耗尽

刚开始直接用aioredis创建连接,没有用连接池。压测到500并发时,Redis报Connection pool exhausted。解决方案:

# 用连接池,min_connections=10, max_connections=50
redis_client = aioredis.from_url(
    "redis://localhost:6379/0",
    decode_responses=True,
    max_connections=50,
    socket_connect_timeout=2,
)

坑3:Uvicorn worker数与CPU核数不匹配

默认4个worker在8核机器上反而性能下降,因为上下文切换开销大。调整为--workers 6后,QPS提升了15%左右。

6. 压测数据对比

用locust做了三轮对比测试,每轮5分钟,200并发:

指标 优化前 优化后(JOIN+Redis) 提升
平均响应时间 258ms 54ms 4.78x
P99延迟 1.2s 180ms 6.67x
QPS 620 2934 4.73x
数据库查询次数/请求 12 3 75%减少
数据库CPU使用率 85% 32% 53%下降

Redis命中率在压测期间保持在94%以上(因为同一批测试用户反复请求)。

7. 总结与建议

这次调优的核心经验:

  1. 不要盲目优化,先定位。py-spy的火焰图30秒就能给出答案,别用猜的
  2. ORM的便捷是有代价的。N+1查询是最常见的性能杀手,但selectinload也不是万能药,复杂聚合场景直接写SQL更可控
  3. 缓存要分层。订单统计和标签是读多写少的数据,缓存收益最大;但用户基础信息变化频繁,不适合缓存
  4. 压测要多次。单次压测结果受JIT预热、数据库缓存影响很大,至少跑3次取中位数

最后提醒一下:如果你的接口响应时间超过200ms,先看一眼是不是N+1查询。这个案例中,仅修复N+1就贡献了60%的性能提升,缓存策略贡献了剩下40%。


代码仓库:github.com/yourname/api-perf-tuning-demo(包含完整的优化前后代码和压测脚本)