一、问题背景:一个"看起来很简单"的接口

事情是这样的。我们有个订单查询接口 /api/v1/orders,逻辑说白了就是:根据用户ID查订单列表,每条订单再带上商品信息和用户昵称。

代码写得很"优雅",用了SQLAlchemy的relationship,读起来很舒服:

@app.get("/api/v1/orders")
async def list_orders(user_id: int, db: Session = Depends(get_db)):
    orders = db.query(Order).filter(Order.user_id == user_id).all()
    return [OrderSchema.from_orm(o) for o in orders]

本地测试数据量小的时候,响应时间20ms都不到,测都没测就上线了。

然后线上就炸了。监控显示P99到了1.2s,QPS刚过50,数据库CPU直接拉满,接口开始大面积超时。用户投诉、老板追问,典型的"周五下午上线,周六凌晨回滚"剧本。

这篇文章就是记录我是怎么把它从1.2s干到85ms的。

二、环境与版本

先把环境交代清楚,不然数据没法复现:

  • Python 3.11.6
  • FastAPI 0.109.0
  • Uvicorn 0.27.0(--workers 4
  • SQLAlchemy 2.0.25(同步ORM)
  • PostgreSQL 15.4
  • Redis 7.2.3
  • py-spy 0.3.14
  • locust 2.20.0(压测)
  • 机器:4C8G,接口和DB分开部署

三、定位瓶颈:py-spy + 慢查询日志

3.1 先上py-spy看火焰图

py-spy的好处是不用改代码、不用重启,直接attach到进程:

pip install py-spy
py-spy record -o profile.svg --pid $(pgrep -f "uvicorn") --duration 30

压测跑起来,采样30秒,打开火焰图一看——80%的时间卡在 psycopg2execute。这说明瓶颈不在Python代码,而在数据库往返。

3.2 打开SQLAlchemy慢查询日志

光知道卡在DB还不够,得看具体是哪条SQL。SQLAlchemy开echo太吵,用事件钩子更精准:

import time
from sqlalchemy import event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    conn.info.setdefault("query_start_time", []).append(time.perf_counter())

@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    total = time.perf_counter() - conn.info["query_start_time"].pop()
    if total > 0.05:  # 超过50ms记录
        logger.warning(f"SLOW SQL ({total*1000:.1f}ms): {statement[:200]}")

跑了一次请求,日志直接刷屏:一个用户有50条订单,接口发了 1(主查询)+ 50(商品)+ 50(用户)= 101条SQL。经典的N+1问题。

而且主查询 SELECT * FROM orders WHERE user_id = ? 也慢,EXPLAIN ANALYZE 一看,orders表的user_id字段压根没索引,全表扫描。

四、方案设计与核心实现

定位清楚后,优化分三步走:

  1. 消N+1:用 selectinload 预加载关联对象
  2. 加索引:给高频查询字段建复合索引
  3. 上缓存:热点用户订单列表进Redis,短TTL

4.1 消除N+1:selectinload替代懒加载

SQLAlchemy 2.0推荐用 selectinload,它会把关联查询合并成 IN (...) 一条SQL,比 joinedload 更适合一对多:

from sqlalchemy.orm import selectinload

@app.get("/api/v1/orders")
async def list_orders(user_id: int, db: Session = Depends(get_db)):
    stmt = (
        db.query(Order)
        .options(
            selectinload(Order.product),
            selectinload(Order.user),
        )
        .filter(Order.user_id == user_id)
        .order_by(Order.created_at.desc())
        .limit(100)
    )
    orders = stmt.all()
    return [OrderSchema.from_orm(o) for o in orders]

SQL从101条降到3条:主查询+商品IN查询+用户IN查询。

4.2 索引优化

看下最频繁的查询模式:WHERE user_id = ? ORDER BY created_at DESC LIMIT 100

单列索引user_id不够,因为还要排序。建复合索引:

CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);

CONCURRENTLY 很重要,线上建索引不锁表。建完 EXPLAIN ANALYZE 从 Seq Scan 变成 Index Scan,主查询从320ms降到4ms。

4.3 Redis缓存热点数据

订单列表这种读多写少的数据,非常适合缓存。策略:

  • Key:orders:user:{user_id}:limit:{limit}
  • Value:JSON序列化后的响应体
  • TTL:60秒(订单可能随时新增)
  • 缓存穿透:空结果也缓存,TTL设10秒
  • 缓存击穿:加互斥锁或随机TTL抖动

核心实现:

import json
import random
import redis.asyncio as aioredis
from fastapi import FastAPI, Depends, Query

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

CACHE_TTL = 60

async def get_orders_cached(user_id: int, limit: int = 100):
    cache_key = f"orders:user:{user_id}:limit:{limit}"
    cached = await redis_client.get(cache_key)
    if cached is not None:
        return json.loads(cached)

    # 回源查询(同步ORM,包在线程池里)
    orders = await fetch_orders_from_db(user_id, limit)
    payload = [OrderSchema.from_orm(o).model_dump(mode="json") for o in orders]

    # TTL加抖动,避免同一时刻大批key同时过期
    ttl = CACHE_TTL + random.randint(-10, 10)
    await redis_client.setex(cache_key, ttl, json.dumps(payload))
    return payload

@app.get("/api/v1/orders")
async def list_orders(user_id: int, limit: int = Query(100, le=200)):
    return await get_orders_cached(user_id, limit)

写操作记得清缓存,不然用户下了单看不到:

async def invalidate_user_orders(user_id: int):
    # 用scan_iter避免keys命令阻塞
    async for key in redis_client.scan_iter(match=f"orders:user:{user_id}:*"):
        await redis_client.delete(key)

五、踩坑与优化

坑1:selectinload 在数据量大时反而慢。 一个用户有5000条订单时,IN (5000个id) 的SQL会超长。解决:业务层限制单次最多返回200条,配合分页。

坑2:Redis连接池没配好,高并发下报 ConnectionError 默认连接池太小。改成:

redis_client = aioredis.from_url(
    "redis://localhost:6379/0",
    decode_responses=True,
    max_connections=200,
    socket_timeout=0.5,
    socket_connect_timeout=0.5,
)

坑3:SQLAlchemy连接池耗尽。 默认 pool_size=5, max_overflow=10,QPS一上来就 TimeoutError。调到:

engine = create_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=30,
    pool_pre_ping=True,
    pool_recycle=1800,
)

pool_pre_ping=True 避免拿到已被DB断开的死连接。

坑4:Uvicorn workers数量。 一开始设了8,反而更慢——CPU 4核,超过核数的worker只会加剧上下文切换。改成 --workers 4,反而稳。

六、效果数据

Locust压测,100并发,持续5分钟,对比优化前后:

指标 优化前 优化后 提升
P50 480ms 32ms 15x
P95 980ms 68ms 14.4x
P99 1200ms 85ms 14.1x
QPS 52 680 13x
DB CPU 98% 22% -
SQL数/请求 101 3(未命中缓存)/ 0(命中) -

缓存命中率稳定在 87% 左右(订单是读多写少场景)。DB负载直接降了一个数量级,现在这套架构撑个1000 QPS问题不大。

七、总结

这次调优其实没什么黑科技,就是老老实实按流程走:

  1. 先测量再优化,py-spy火焰图是神器,别凭感觉猜
  2. N+1是API性能头号杀手selectinload 一定要用
  3. 索引要和查询模式匹配WHERE + ORDER BY 就上复合索引
  4. 缓存不是银弹,TTL抖动、缓存穿透、写后失效这些细节必须处理
  5. 连接池参数不能全用默认值,DB和Redis都要按并发量调

最后一句忠告:上线前一定要压测。本地20ms和线上1.2s之间,差的不是代码,是数据量和并发。别学我周五上线。

代码都在上面了,有需要的同学直接抄。如果有更好的优化思路,欢迎评论区交流。