一、问题背景:上线当天就被运维喊去开会

事情是这样的,我们有个用户画像聚合API,部署在4核8G的容器里,用的是FastAPI + uvicorn + SQLAlchemy 2.0 + PostgreSQL 14。上线第一天,运维截图发过来:QPS 1200的时候,P99延迟已经飙到860ms,数据库连接池40个连接全部打满,CPU使用率倒是只有45%——典型的“活儿全堵在数据库上”。

我第一反应是“加个索引不就完了”,但查了一圈,该有的索引都有了。那问题出在哪?只能上工具。

二、环境与版本:先把底交代清楚

  • Python 3.11.4(GIL问题在3.12有改善,但生产环境还没升)
  • FastAPI 0.104.1 + uvicorn 0.24.0(worker=1,因为后面要用异步)
  • SQLAlchemy 2.0.23(重点:2.0的ORM写法跟1.x差别很大)
  • asyncpg 0.29.0(PostgreSQL异步驱动)
  • Redis 7.2(用redis-py 5.0,连接池max_connections=50)
  • 压测工具:wrk 4.2.0,单机跑,避免网络干扰

三、第一步:用py-spy和cProfile定位瓶颈

先上py-spy dump看看线程在干嘛:

py-spy dump --pid 12345

输出显示:主线程卡在asyncpg_read_message上,等待数据库返回。同时有3个线程在sqlalchemy_execute_context里。这告诉我两件事:一是数据库查询慢,二是ORM把它拆成了多次查询。

接着用cProfile抓CPU热点:

import cProfile
import pstats
from app.main import app

profiler = cProfile.Profile()
profiler.enable()
# 手动调用API入口函数
response = app.router.routes[0].endpoint(sample_request)
profiler.disable()
pstats.Stats(profiler).sort_stats('cumtime').print_stats(20)

结果触目惊心:UserProfileService.get_profile累计耗时2.3秒,其中fetch_user_orders被调用了47次——N+1查询实锤了。每个用户有30-50个订单,ORM默认懒加载,每次循环都发一条SQL。

四、方案设计:三层优化,从数据库到缓存

优化思路分三层:

  1. 数据库层:用selectinload一次性加载关联对象,替代N+1
  2. 应用层:加Redis缓存,热点数据TTL 60秒,防止缓存雪崩加随机抖动
  3. 进程内层:加Caffeine风格的本地缓存(Python用cachetools.TTLCache),命中率约35%,减少Redis往返

架构图大概是这样:

Client → FastAPI → 本地缓存(L1) → Redis(L2) → PostgreSQL
               ↑_____________命中即返回_____________|

L1失效时查L2,L2失效时查DB并回填。L1的TTL设为10秒(短一点,避免数据不一致),L2设为60秒。

五、核心实现:代码说话

5.1 修复N+1:SQLAlchemy 2.0的selectinload

踩坑:网上很多教程还在用lazy='joined',但SQLAlchemy 2.0里这样会导致LEFT JOIN OUTER,数据量一大反而更慢。正确做法是用selectinload——它会先查主表,再发一条WHERE id IN (...)的查询。

# services/profile_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select

async def get_profile_with_orders(db: AsyncSession, user_id: int):
    # 修复前:每取一个order发一条SQL
    # result = await db.execute(select(User).where(User.id == user_id))
    # user = result.scalar_one()
    # for order in user.orders:  # 这里触发N次查询

    # 修复后:两条SQL搞定
    stmt = (
        select(User)
        .where(User.id == user_id)
        .options(
            selectinload(User.orders),
            selectinload(User.addresses),
        )
    )
    result = await db.execute(stmt)
    return result.scalar_one()

5.2 两级缓存:TTLCache + Redis

# core/cache.py
from cachetools import TTLCache
import redis.asyncio as aioredis
import json

# L1: 进程内缓存,最多1024个key,TTL 10秒
local_cache = TTLCache(maxsize=1024, ttl=10)

# L2: Redis连接池
redis_client = aioredis.from_url(
    "redis://localhost:6379/0",
    max_connections=50,
    decode_responses=True,
)

async def get_cached_profile(user_id: int):
    # 查L1
    cached = local_cache.get(user_id)
    if cached:
        return cached

    # 查L2
    redis_key = f"profile:{user_id}"
    raw = await redis_client.get(redis_key)
    if raw:
        data = json.loads(raw)
        local_cache[user_id] = data  # 回填L1
        return data

    # 查DB(调用方负责回填)
    return None

async def set_cached_profile(user_id: int, data: dict):
    # L1回填
    local_cache[user_id] = data
    # L2回填,TTL加随机抖动防雪崩
    import random
    ttl = 60 + random.randint(0, 30)
    await redis_client.setex(
        f"profile:{user_id}", 
        ttl, 
        json.dumps(data)
    )

注意:L1缓存必须用TTLCache而不是dict,否则内存会炸。另外cachetoolsTTLCache不是线程安全的,但在FastAPI的异步单线程事件循环里,用asyncio.Lock包一下写入操作就行。

六、踩坑与优化:你以为完了?还早

坑1:异步上下文管理器泄漏
第一次压测时发现连接池还是被打满,后来发现是async with db_session()用成了db_session()。SQLAlchemy 2.0的AsyncSession必须用async with,否则连接不释放。

坑2:Redis序列化
一开始用pickle,压测时发现CPU占用高。换成json序列化后,CPU降了12%,因为JSON的序列化/反序列化比pickle快30%。

坑3:本地缓存穿透
TTLCache的get方法在key不存在时返回None,但这跟“缓存了空值”冲突。我的解决办法是:如果DB结果为空,也缓存一个空list,TTL缩短到5秒。

坑4:压测工具的选择
wrk是单线程的,压到3000 QPS时自己先成了瓶颈。换wrk2(支持固定QPS模式)后,数据才稳定。另外一定要用--latency参数,不然看不到P99。

七、效果数据:从1200到9800,数据库扛住了

用wrk2压测,固定QPS,持续60秒,结果如下:

指标 优化前 优化后 提升
QPS 1200 9800 8.2倍
P99延迟 860ms 41ms 21倍
数据库CPU 78% 9.5% -87%
连接池占用 40/40 12/40 -70%
Redis QPS 0 5200 新增

关键数据:数据库的pg_stat_statements显示,SELECT * FROM orders WHERE user_id = $1这条语句的调用次数从每分钟4700次降到380次,缓存命中率稳定在91%。

八、总结:调优不是玄学,是科学

这次调优花了两天,但真正改代码只用了半天。核心收获:

  1. 先profiling再动手,别一上来就加索引/缓存,浪费感情
  2. ORM的懒加载是性能杀手,用selectinloadsubqueryload,别用joined(2.0里性能反而差)
  3. 两级缓存是标配,本地缓存扛热点,Redis扛穿透,TTL加随机抖动防雪崩
  4. 压测要用wrk2,控制QPS才能测出真实吞吐,wrk的并发模式会掩盖问题

最后一句话:“如果你的API慢,先查数据库,再查ORM,最后才考虑加缓存”——这次我是反着来的,结果走了弯路。希望这篇对你有用,有问题评论区见。