一、问题背景

事情是这样的。我们有一个内部数据服务,主框架是 FastAPI(0.110.0),对外提供设备状态查询接口。接口本身逻辑不复杂:根据 device_id 查设备信息,再关联查最近的状态记录和告警列表。

上线初期数据量小,响应一直在 200ms 左右,没人关注。但业务接入方越来越多,单设备的关联记录涨到几千条之后,接口开始拉胯:监控上看 P95 到了 2.3s,Prometheus 里 http_request_duration_seconds 的 99 分位直接爆表。更麻烦的是压测一上 50 QPS,就出现大面积 502,Gunicorn 的 worker 全被慢请求占满。

这篇文章就是我把这个过程从头到尾记下来,包括走了哪些弯路。

二、环境与版本

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

  • Python 3.11.6
  • FastAPI 0.110.0 + Uvicorn 0.27.1
  • SQLAlchemy 2.0.28(async 模式,asyncpg 0.29.0)
  • PostgreSQL 15.4
  • Redis 7.2.4(redis-py 5.0.1)
  • Gunicorn 21.2.0,4 workers,uvicorn.workers.UvicornWorker
  • 压测工具:wrk 4.2.0 + locust 2.24.0
  • 机器:4C8G 容器,数据库独立实例

部署方式是 gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 app:app,没上 Kubernetes,就一台机器。

三、方案设计:先定位,再动手

我给自己定了个原则:没 profiling 数据之前不改一行代码。凭感觉优化是最容易白干活的。

排查路径分三步:

  1. CPU/调用栈层面:用 py-spy 采样,看时间花在哪
  2. SQL 层面:打开 SQLAlchemy 的 echo,统计单次请求的 SQL 条数
  3. 缓存层面:判断哪些数据能缓存、缓存粒度多大、失效策略怎么定

工具选型上,py-spy 是无侵入的,生产环境也能跑;cProfile 精度更高但开销大,只在本地复现时用。

四、核心实现

4.1 用 py-spy 抓现场

线上直接采样 30 秒:

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

生成的火焰图一看就明白了:超过 60% 的时间卡在 asyncpgfetch 上,还有约 15% 在 Pydantic 的 model_validate。基本可以断定是数据库查询问题。

4.2 复现并数 SQL 条数

本地写了个最小复现脚本,打开 SQL echo:

# reproduce.py
import asyncio
import time
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from models import Device, StatusRecord, Alert

engine = create_async_engine(
    "postgresql+asyncpg://user:pwd@localhost:5432/devdb",
    echo=True,          # 打开 SQL 日志
    pool_size=20,
    max_overflow=10,
)
Session = async_sessionmaker(engine, expire_on_commit=False)


async def get_device_detail(device_id: int):
    async with Session() as session:
        # 第一层:查设备
        device = await session.get(Device, device_id)

        # 第二层:查状态记录(这里就是 N+1 的坑)
        records = (await session.execute(
            select(StatusRecord).where(StatusRecord.device_id == device_id)
        )).scalars().all()

        # 第三层:每条状态记录再查告警
        for r in records:
            alerts = (await session.execute(
                select(Alert).where(Alert.record_id == r.id)
            )).scalars().all()
            r.alerts = alerts

        return device, records


async def main():
    t0 = time.perf_counter()
    device, records = await get_device_detail(1024)
    print(f"records={len(records)}, elapsed={time.perf_counter()-t0:.3f}s")


if __name__ == "__main__":
    asyncio.run(main())

跑一次输出:records=2847, elapsed=2.31s,SQL 日志刷了 2849 条。典型的 N+1,每条状态记录单独查一次告警。

4.3 用 selectinload 改写查询

SQLAlchemy 2.0 的关系加载用 selectinload 最合适——它会把关联查询合并成 WHERE record_id IN (...) 的形式,一次拿完。

from sqlalchemy.orm import selectinload
from sqlalchemy import select

async def get_device_detail_optimized(device_id: int, limit: int = 50):
    async with Session() as session:
        stmt = (
            select(Device)
            .options(
                selectinload(Device.records).selectinload(StatusRecord.alerts)
            )
            .where(Device.id == device_id)
        )
        device = (await session.execute(stmt)).scalar_one_or_none()
        if device is None:
            return None, []

        # 只返回最近 limit 条,避免全量序列化
        records = sorted(device.records, key=lambda r: r.created_at, reverse=True)[:limit]
        return device, records

注意这里还有个细节:原逻辑返回全量 2847 条,光 Pydantic 序列化就要 400ms+。业务上其实只展示最近 50 条,所以顺手加了 limit。这一步比 SQL 优化省得还多

4.4 加两级缓存

设备基础信息变化极少,适合缓存。我用了进程内 LRU + Redis 两层:

import json
import hashlib
from functools import lru_cache
from redis.asyncio import Redis

redis = Redis(host="localhost", port=6379, decode_responses=True)

def _key(device_id: int) -> str:
    return f"device:detail:v1:{device_id}"

async def get_device_detail_cached(device_id: int):
    key = _key(device_id)
    cached = await redis.get(key)
    if cached:
        return json.loads(cached)

    device, records = await get_device_detail_optimized(device_id)
    if device is None:
        return None

    payload = {
        "device": {"id": device.id, "name": device.name, "model": device.model},
        "records": [
            {
                "id": r.id,
                "status": r.status,
                "created_at": r.created_at.isoformat(),
                "alerts": [{"id": a.id, "level": a.level} for a in r.alerts],
            }
            for r in records
        ],
    }
    # TTL 60s,加随机抖动防止雪崩
    import random
    ttl = 60 + random.randint(0, 10)
    await redis.setex(key, ttl, json.dumps(payload))
    return payload

TTL 设 60 秒是因为状态记录每分钟会更新一次,再短没必要,再长业务方会说数据不新鲜。随机抖动是为了避免同一批 key 同时过期。

4.5 调整 Gunicorn 配置

4C 机器上 -w 4 其实是偏少的。FastAPI 是 IO 密集,worker 数可以开到 2 * CPU + 1 = 9。但别一次拉满,得配合压测看。最终用了:

gunicorn app:app \
  -k uvicorn.workers.UvicornWorker \
  -w 8 \
  --max-requests 5000 \
  --max-requests-jitter 500 \
  --timeout 30 \
  --graceful-timeout 30 \
  -b 0.0.0.0:8000

--max-requests 是防止长时间运行后内存碎片,每 5000 个请求重启一个 worker。

五、踩坑与优化

坑一:selectinload 不是万能的。 我一开始对 Device.records 用了 joinedload,结果 JOIN 出 2800 行,反序列化时 SQLAlchemy 的 identity map 去重开销巨大,反而比 N+1 慢。一对多关系一定要用 selectinload

坑二:缓存 key 没带版本号,灰度时踩过雷。 后来统一加 v1 前缀,字段结构变了就升版本,老 key 自然过期,不用手动清。

坑三:Pydantic v2 的 model_validate 对大批量数据有开销。 原来返回全量时,序列化占了近 20% 时间。现在我直接构造 dict 返回,绕开 Pydantic 的模型校验,只在入口做校验。这个 trade-off 需要你评估业务是否接受。

坑四:Gunicorn worker 从 4 加到 8 后,数据库连接数不够用了。 asyncpg 每个 worker 默认池 20,8 个 worker 就是 160 连接,超过了 Postgres 的 max_connections=100。最后把 pool_size 降到 10,max_overflow 到 5,总连接控制在 120 以内,同时把 Postgres 的 max_connections 提到 200。

六、效果数据

用 wrk 压测,命令:wrk -t4 -c100 -d60s http://localhost:8000/api/devices/1024

阶段 P50 P95 P99 QPS 错误率
优化前 890ms 2300ms 3800ms 52 3.1%
SQL 优化后 210ms 480ms 720ms 190 0%
加缓存后 45ms 180ms 260ms 420 0%

CPU 占用从优化前的 95%(持续打满)降到 40% 左右,内存稳定在 1.2G。数据库这边慢查询日志清空,pg_stat_statements 显示最耗时查询从 2100ms 降到 12ms。

缓存命中率稳定在 87%(60 秒 TTL 下的合理值)。

七、总结

回头看,这次调优的核心就三件事:

  1. 先测再改py-spy 5 分钟就能告诉你瓶颈在哪,别瞎猜。
  2. N+1 是接口性能的头号杀手。SQLAlchemy 里优先用 selectinload,别用 joinedload 处理一对多。
  3. 缓存粒度要跟业务对齐。TTL 不是越短越好,要看数据更新频率和业务容忍度。

另外提一句,如果你们也在用 Flask 而不是 FastAPI,思路完全一样。Flask 用 flask-sqlalchemyselectinload 写法几乎一致,profiling 也可以用 flask-profiler 或直接上 py-spy。唯一区别是 Flask 没有 async,worker 数可以开得更激进,但要注意数据库连接池别撑爆。

性能优化没有银弹,只有一个个具体的、可度量的改动。希望这篇记录能帮你少走点弯路。