1. 背景:一个被投诉的报表接口

上周三下午,运营同学在群里@我:“那个销售汇总报表,每次打开要转圈5-6秒,还经常超时,能不能管管?” 我一看监控,/api/v1/sales/summary 这个接口,P95耗时560ms,单机QPS峰值12,数据库连接池经常打满。这个接口是FastAPI写的,部署在K8s里,2个Pod,每个Pod限制1核CPU。

第一反应是“加机器”。但看了下代码,发现这接口从数据库查了4张表,每张表还循环查询关联子表——典型的N+1问题。加机器治标不治本,数据库才是瓶颈。

2. 环境与版本:先把家底盘清楚

调优前,先确认所有依赖版本,避免玄学问题:

组件 版本
Python 3.11.4
FastAPI 0.100.0
Uvicorn 0.23.2
SQLAlchemy 2.0.19
PostgreSQL 14.5
Redis 7.0.12
py-spy 0.3.14
cProfile 内置

服务部署:Docker + K8s,资源限制requests: cpu=500m, memory=512Milimits: cpu=1, memory=1Gi。压测工具用wrk,单机300并发,持续60秒。

3. Profiling第一轮:cProfile找到“谁在慢”

先上cProfile,直接在FastAPI路由函数里加装饰器,只测单个请求:

import cProfile
import pstats
import io
from functools import wraps

def profile_endpoint(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        profiler = cProfile.Profile()
        profiler.enable()
        try:
            result = await func(*args, **kwargs)
        finally:
            profiler.disable()
            s = io.StringIO()
            ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
            ps.print_stats(20)
            print(s.getvalue())
        return result
    return wrapper

# 在路由上使用
@app.get("/api/v1/sales/summary")
@profile_endpoint
async def get_sales_summary(date: str):
    # ... 原有逻辑
    pass

跑一次请求,输出关键部分:

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    1    0.002    0.002    0.512    0.512 routes/sales.py:42(get_sales_summary)
   24    0.001    0.000    0.398    0.017 orm/loading.py:526(_load_collection_from_zipped)
  128    0.015    0.000    0.245    0.002 orm/relationships.py:1554(_emit_load)
  512    0.034    0.000    0.087    0.000 sqlalchemy/dialects/postgresql/psycopg2.py:200(do_execute)

破案了_load_collection_from_zipped 累计耗时398ms,说明SQLAlchemy在加载关系对象时,对每个父对象都执行了一次子查询。这就是N+1:主查询返回24条销售记录,每条记录又查了5-6张关联表,总共执行了128条SQL。

验证一下数据库日志,果然,一个请求产生了129条SELECT语句。

4. 方案设计:N+1消除 + 缓存策略

针对定位到的问题,设计两个方案并行:

方案A:消除N+1。用SQLAlchemy 2.0的selectinload,一次性预加载所有关联表。核心改动是查询语句:

from sqlalchemy.orm import selectinload

# 改造前(产生N+1)
stmt = select(SalesOrder).where(SalesOrder.date == target_date)

# 改造后(一次性JOIN或IN查询)
stmt = select(SalesOrder).options(
    selectinload(SalesOrder.customer),
    selectinload(SalesOrder.items).selectinload(OrderItem.product),
    selectinload(SalesOrder.region),
    selectinload(SalesOrder.salesperson)
).where(SalesOrder.date == target_date)

async with async_session() as session:
    result = await session.execute(stmt)
    orders = result.scalars().unique().all()

selectinload会先查主表,然后对每个关系用WHERE id IN (...)批量加载,把128条SQL降为5条。

方案B:Redis缓存热点数据。这个报表按日期聚合,历史数据不会变(当天数据有延迟统计),所以适合缓存。TTL设为5分钟,key设计为sales:summary:{date}:{version},加个版本号方便强制刷新。

5. 核心实现:缓存层 + 兜底策略

缓存代码直接放在路由层,注意两点:一是序列化用orjson(比json快3-5倍),二是缓存穿透保护(如果数据库查出来是空,也缓存空结果,但TTL缩短到30秒):

import orjson
import redis.asyncio as aioredis

redis_client = aioredis.from_url(
    "redis://redis-service:6379/0",
    decode_responses=False  # 保持bytes,orjson直接处理
)

async def get_sales_summary_cached(date: str) -> dict:
    cache_key = f"sales:summary:{date}:v2"

    # 1. 查缓存
    cached = await redis_client.get(cache_key)
    if cached:
        return orjson.loads(cached)

    # 2. 缓存未命中,查数据库(使用优化后的查询)
    async with async_session() as session:
        result = await session.execute(
            stmt  # 上面优化后的selectinload查询
        )
        orders = result.scalars().unique().all()

    if not orders:
        # 防止穿透:空结果也缓存,但TTL短
        await redis_client.set(cache_key, b"[]", ex=30)
        return []

    # 3. 聚合计算(原本在循环里重复计算,现在提前聚合)
    summary = aggregate_orders(orders)

    # 4. 写入缓存,TTL 300秒
    await redis_client.set(cache_key, orjson.dumps(summary), ex=300)

    return summary

另外,注意一个坑:FastAPI的async路由里,如果用了同步的redis-py,会阻塞事件循环。必须用redis.asyncio,或者用run_in_executor。我第一次用同步客户端,压测时P99直接飙到1.2秒,后来排查发现是事件循环被Redis的IO阻塞了。

6. 踩坑与二次优化:序列化与连接复用

第一个坑:orjson.dumps返回bytes,redis_client.set需要bytes,这个没问题。但orjson.loads接受bytes和str,注意别传错类型。

第二个坑:SQLAlchemy对象直接序列化会报错。我一开始在aggregate_orders里返回ORM对象列表,然后orjson.dumps直接炸了——“TypeError: Object of type SalesOrder is not JSON serializable”。解法:在聚合函数里转成普通dict,只保留前端需要的字段。

第三个优化:数据库连接池调参。SQLAlchemy默认pool_size=5, max_overflow=10,在300并发压测下不够用。调成pool_size=20, max_overflow=20, pool_timeout=30,并在PostgreSQL侧限制max_connections为100,避免连接风暴。

7. 压测数据对比:560ms → 40ms

wrk压测60秒,300并发,结果如下:

指标 优化前 优化后(N+1消除) 优化后(+Redis缓存)
平均延迟 512ms 148ms 38ms
P95延迟 560ms 210ms 42ms
P99延迟 1.2s 480ms 95ms
QPS 12 45 480
每请求SQL数 129 5 0(缓存命中)
数据库CPU 85% 30% 8%

缓存命中率:压测场景下,所有请求都打同一个日期,缓存命中率100%。真实业务中,历史日期查询命中率约80%,当天数据命中率较低(因为TTL只有5分钟)。整体P95维持在40ms左右。

一个意外的发现:优化前,Uvicorn单worker只能跑12 QPS,因为事件循环被阻塞(同步SQLAlchemy调用)。我之前用的是asyncio模式,但SQLAlchemy的session是同步的,等于假异步。后续干脆换成了async_sessionmaker + asyncpg驱动,彻底解决阻塞问题。

8. 总结与反思

这次调优的核心思路就三步:

  1. 先profiling,别猜。cProfile直接告诉我N+1是元凶,省了瞎折腾的时间。
  2. 数据库层面解决根本问题selectinload消除了99%的SQL语句,这是最大收益。
  3. 缓存是放大器。在数据库查询优化到150ms后,Redis把延迟进一步压到40ms,QPS从45跳到480。

几个经验教训

  • FastAPI+SQLAlchemy,务必用async版本(asyncpg),否则高并发下事件循环被阻塞,性能惨不忍睹。
  • selectinload不是银弹。如果关联表数据量极大(单表百万行),IN查询也可能慢,这时候需要改用subqueryload或手动分页。
  • 缓存一定要考虑穿透和雪崩。我这里用了空结果缓存+短TTL,并且给缓存key加了版本号,方便业务上主动失效。
  • 压测时注意预热。第一次请求会加载SQLAlchemy映射和连接池,建议先跑10次请求再开始正式压测。

调完这个接口,监控面板终于绿了。下一步计划优化另一个更慢的导出接口(目前耗时2.3秒),思路类似,但可能要引入任务队列异步生成文件。到时候再写一篇记录。