1. 问题背景:一个“看起来没毛病”的聚合接口

上个月接手一个订单服务,核心接口是 GET /api/v1/orders/summary,前端要展示用户近30天的订单概览:订单列表、商品快照、优惠明细、物流状态。业务逻辑不复杂,就是三张表join。

但压测结果很打脸:50并发下,P99延迟800ms,数据库CPU 90%,应用服务器CPU只有30%。明显是IO等待,不是计算瓶颈。

先看环境:

Python 3.11.4
FastAPI 0.104.1 (uvicorn 0.24.0, workers=4)
SQLAlchemy 2.0.21 + asyncpg 0.28.0
PostgreSQL 14.5 (16核32G, SSD)
Redis 7.0 (单机)

2. 第一步:用Profiling确定瓶颈在哪

很多同学一上来就改代码,我建议先花10分钟做profile。性能调优的第一原则:不要猜,要测。

写了两个脚本,一个是cProfile静态分析,一个是py-spy动态抓取生产栈。

# profiling/cprofile_runner.py
import cProfile
import pstats
import asyncio
from app.main import app
from httpx import ASGITransport, AsyncClient

async def profile_endpoint():
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        for _ in range(200):  # 预热
            await client.get("/api/v1/orders/summary?user_id=1001")
        # 正式采集
        for _ in range(500):
            resp = await client.get("/api/v1/orders/summary?user_id=1001")
            assert resp.status_code == 200

if __name__ == "__main__":
    profiler = cProfile.Profile()
    profiler.enable()
    asyncio.run(profile_endpoint())
    profiler.disable()

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

关键输出摘录:

ncalls  tottime  percall  cumtime  percall  filename:lineno
500     0.012    0.000    0.482    0.001  sqlalchemy/orm/loading.py:80
500     0.008    0.000    0.356    0.001  sqlalchemy/orm/strategies.py:310  # selectinload
1500    0.021    0.000    0.298    0.000  asyncpg/protocol/protocol.pyx:120  # DB roundtrip
500     0.015    0.000    0.187    0.000  app/services/order_service.py:45  # 序列化

结论非常清晰:数据库查询占了65%以上时间,且selectinload触发了额外的SQL(N+1的变种)。JSON序列化只占0.18s,不是主要矛盾。

再用py-spy抓生产环境的线程栈(注意:生产环境千万别用cProfile,开销太大):

# 在容器内执行
py-spy dump --pid $(pgrep -f uvicorn | head -1) --duration 5

看到大量线程阻塞在asyncpgwait_for_message上,进一步确认是IO瓶颈。

3. 第二步:数据库查询优化 —— 从5次SQL降到1次

原始代码用了SQLAlchemy的lazy='select',导致查询订单后,访问每个订单的itemspromotions都会触发一次异步SQL。30个订单 = 1 + 30 + 30 = 61次查询。

方案:改用selectinload一次性批量加载,同时调整索引。

# app/repositories/order_repo.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select, func
from app.models import Order, OrderItem, Promotion, Logistics
from datetime import datetime, timedelta

async def get_order_summary(session: AsyncSession, user_id: int, days: int = 30):
    """核心优化点:一次查询加载所有关联数据"""

    since = datetime.utcnow() - timedelta(days=days)

    # 1. 主查询:只查订单,用selectinload批量加载关联对象
    stmt = (
        select(Order)
        .options(
            selectinload(Order.items),          # 替代 lazy='select'
            selectinload(Order.promotions),
            selectinload(Order.logistics),
        )
        .where(
            Order.user_id == user_id,
            Order.created_at >= since,
            Order.status.in_(['paid', 'shipped', 'completed'])
        )
        .order_by(Order.created_at.desc())
        .limit(50)
    )

    result = await session.execute(stmt)
    return result.scalars().unique().all()

同时给PostgreSQL加了覆盖索引(之前只有单列索引user_id):

-- 数据库迁移脚本 V20231115__add_composite_index.sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS 
idx_orders_user_created_status 
ON orders (user_id, created_at DESC, status) 
INCLUDE (total_amount, currency);

-- 商品表和物流表也加上外键索引
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items (order_id) INCLUDE (sku_id, quantity, price);
CREATE INDEX IF NOT EXISTS idx_logistics_order_id ON logistics (order_id) INCLUDE (tracking_no, status);

效果:SQL查询次数从61次降到1次(主查询)+ 3次(批量加载)。单次请求数据库耗时从380ms降到95ms。

4. 第三步:Redis三级缓存 —— 读多写少的场景就该这么搞

数据库优化后P99降到220ms,但还不够。分析压测数据发现,80%的请求都在读相同用户的数据(用户频繁刷新页面)。

缓存设计
- L1:进程内缓存(functools.lru_cache)—— 100ms内过期,适合突发流量
- L2:Redis缓存 —— 5分钟过期,存JSON序列化后的完整响应
- L3:数据库 —— 兜底

核心实现

# app/services/cache_service.py
import json
import redis.asyncio as redis
from functools import lru_cache
from app.core.config import settings

redis_client = redis.from_url(
    settings.REDIS_URL,
    encoding="utf-8",
    decode_responses=True,
    socket_connect_timeout=0.5,   # 连接超时设短,避免拖垮主流程
    socket_timeout=0.5,
    max_connections=32,
)

# L2: Redis缓存 —— 带版本号和业务前缀,方便主动失效
async def get_cached_order_summary(user_id: int, days: int = 30):
    cache_key = f"order_summary:v2:{user_id}:{days}"
    try:
        cached = await redis_client.get(cache_key)
        if cached:
            return json.loads(cached)
    except (redis.RedisError, json.JSONDecodeError) as e:
        # 缓存故障降级,记录日志但不阻塞主流程
        logger.warning(f"Redis cache read failed: {e}")
    return None

async def set_cached_order_summary(user_id: int, days: int, data: dict, ttl: int = 300):
    cache_key = f"order_summary:v2:{user_id}:{days}"
    try:
        await redis_client.setex(cache_key, ttl, json.dumps(data, default=str))
    except redis.RedisError as e:
        logger.warning(f"Redis cache write failed: {e}")

# L1: 进程内缓存 —— 配合Redis的TTL,设置更短的过期时间
def l1_cache_key(user_id: int, days: int):
    return (user_id, days)

@lru_cache(maxsize=512, typed=True)
def get_l1_cached_data(user_id: int, days: int):
    # 注意:这里只存计算结果,真正的数据从L2或DB获取
    # 实际项目中建议用ttl_cache,此处简化
    return None

def invalidate_order_cache(user_id: int, days: int = 30):
    """订单变更时主动失效缓存"""
    get_l1_cached_data.cache_clear()  # 简化处理,实际应精确删除
    cache_key = f"order_summary:v2:{user_id}:{days}"
    asyncio.create_task(redis_client.delete(cache_key))

然后在FastAPI路由中加上缓存逻辑:

# app/api/routes/orders.py
from fastapi import APIRouter, Depends, HTTPException
from app.services.cache_service import get_cached_order_summary, set_cached_order_summary
from app.services.order_service import get_order_summary_data

router = APIRouter(prefix="/api/v1")

@router.get("/orders/summary")
async def get_orders_summary(
    user_id: int = Query(..., ge=1),
    days: int = Query(30, ge=1, le=90),
    session: AsyncSession = Depends(get_db)
):
    # 1. 先查缓存
    cached = await get_cached_order_summary(user_id, days)
    if cached:
        # 命中缓存,直接返回(连DB都不查)
        return {"data": cached, "source": "redis"}

    # 2. 缓存未命中,查库并构造响应
    db_data = await get_order_summary_data(session, user_id, days)
    if not db_data:
        raise HTTPException(status_code=404, detail="No orders found")

    # 3. 写回Redis,TTL设为5分钟
    await set_cached_order_summary(user_id, days, db_data.dict())
    return {"data": db_data, "source": "database"}

5. 踩坑与优化:缓存穿透、序列化、连接池

坑1:缓存穿透。压测时发现用户ID随机生成,Redis永远不命中,全部打到DB。解决方案是布隆过滤器或者对空结果也缓存(设置短TTL 30秒)。

async def get_cached_or_empty(user_id: int, days: int):
    data = await get_cached_order_summary(user_id, days)
    if data is None:
        # 缓存空结果,避免穿透
        await set_cached_order_summary(user_id, days, {"empty": True}, ttl=30)
    return data

坑2:JSON序列化慢。Pydantic v2的dict()比手动json.dumps慢30%。改成直接操作ORM对象,用json.dumps(..., default=str)手动序列化,省去Pydantic开销。

坑3:连接池耗尽。Redis连接池默认10,高并发下不够用。调大max_connections=32,而且别忘了decode_responses=True,否则每次都要bytes解码。

压测工具用wrklocust双验证:

# wrk 压测命令
wrk -t8 -c200 -d60s --latency http://localhost:8000/api/v1/orders/summary?user_id=1001

6. 效果数据:调优前后对比

指标 调优前 调优后 提升幅度
P99 延迟 820ms 62ms 92.4% ↓
P50 延迟 450ms 38ms 91.5% ↓
QPS (200并发) 220 1280 481% ↑
数据库CPU 90% 18% 80% ↓
应用CPU 30% 55% 合理上升
SQL查询次数/请求 61 4 93.4% ↓
Redis命中率 0% 87.3% -

最终架构:FastAPI (uvicorn 4 workers) + SQLAlchemy 2.0 async + PostgreSQL + Redis (L2缓存) + 进程内缓存 (L1)。

7. 总结与建议

这次调优的核心收获不是某个具体技术,而是调优方法论

  1. 先profile再动手:cProfile/profiling工具帮你定位真实瓶颈,大多数人花80%时间优化了20%无关紧要的代码。
  2. 数据库永远第一优先:N+1查询是Python异步ORM的通病,用selectinloadjoinedload批量加载。
  3. 缓存是最后手段:先优化SQL,再加缓存。否则缓存里存的是垃圾数据,只会让系统更复杂。
  4. 注意缓存失效:订单状态变化时要主动删除Redis key + 清L1缓存,否则用户看到过期数据会投诉。

另外说句大实话:别一上来就上微服务、消息队列。这个接口单机就能扛1200 QPS,根本不需要分布式方案。先把基础优化做好,比啥都强。

如果有同学遇到类似问题,可以按这个思路一步步排查。代码都放在GitHub仓库(链接见评论区),欢迎交流。