一、问题背景

事情是这样的:我们给内部运营团队做了个订单查询接口 /api/orders,用 FastAPI + SQLAlchemy + PostgreSQL。上线头两周没什么人用,一切正常。后来运营那边接了个后台看板,每30秒轮询一次这个接口,用户量一上来,问题就暴露了。

监控面板上 P99 直接冲到 1200ms,QPS 到 50 左右时,接口开始大面积超时(我们设的 5s timeout)。更离谱的是,CPU 才用了 30%,数据库连接数却打满了。第一反应是"数据库慢查询",但看完慢日志发现单条 SQL 都是毫秒级的——这就说明问题不在单条 SQL,而在调用次数或者Python 侧的阻塞。

这篇文章就把整个排查和优化的过程完整记下来,给遇到类似问题的同学一个参考。

二、环境与版本

先把环境列清楚,不同版本行为差异挺大的:

  • Python 3.11.6
  • FastAPI 0.110.0
  • Uvicorn 0.27.1(--workers 4,单机 4 核 8G)
  • SQLAlchemy 2.0.28(同步 ORM,不是 async)
  • psycopg2-binary 2.9.9
  • PostgreSQL 14.11
  • redis-py 5.0.1 / Redis 7.2
  • py-spy 0.3.14,cProfile(标准库)

注意:我们当时没有上 async SQLAlchemy,这是后面踩坑的一个关键点。

接口大致长这样(简化版):

@app.get("/api/orders")
def list_orders(user_id: int, page: int = 1, size: int = 20):
    offset = (page - 1) * size
    orders = db.query(Order).filter(Order.user_id == user_id)\
               .order_by(Order.created_at.desc())\
               .offset(offset).limit(size).all()
    result = []
    for o in orders:
        result.append({
            "id": o.id,
            "amount": o.amount,
            "status": o.status,
            "user_name": o.user.name,          # 触发懒加载
            "items_count": len(o.items),        # 又触发懒加载
        })
    return {"total": db.query(Order).filter(Order.user_id == user_id).count(),
            "data": result}

看着挺正常的对吧?问题就藏在这里面。

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

我的原则是:没有 profiling 数据就不要瞎优化。凭直觉改代码,往往改了个寂寞,还引入新 bug。

排查分三步走:

  1. 火焰图定位热点:用 py-spy 抓生产环境(或压测环境)的实时火焰图,看时间花在哪。
  2. 单请求粒度分析:用 cProfile 跑单次请求,看函数调用次数和累计耗时。
  3. 数据库侧验证:开 echo=True 或 pg_stat_statements,数一数一次请求到底发了几条 SQL。

定位结果

先说结论,避免大家看得云里雾里:

  • N+1 查询:一页 20 条订单,每条触发 o.user 和 o.items 各一次查询,一次请求 = 1(列表)+ 1(count)+ 20×2 = 42 条 SQL。
  • 同步阻塞:FastAPI 的 def 路由跑在线程池里,但 SQLAlchemy 同步查询会占满线程,4 workers × 默认线程数不够扛。
  • 无缓存:count 查询每次都全表扫,翻页时尤其慢。

压测脚本我用的是 locust,也贴一下,方便复现:

# locustfile.py
from locust import HttpUser, task, between

class OrderUser(HttpUser):
    wait_time = between(0.1, 0.3)
    host = "http://127.0.0.1:8000"

    @task
    def query_orders(self):
        self.client.get("/api/orders?user_id=10086&page=1&size=20")

启动:locust -f locustfile.py --headless -u 100 -r 10 -t 60s

优化前数据:P50=320ms,P99=1200ms,RPS=48,失败率 6%。

四、核心实现:三刀下去

第一刀:干掉 N+1,用 selectinload

SQLAlchemy 2.0 的 selectinload 是解决 N+1 最省事的方案,它用一条 IN 查询批量捞关联数据,而不是逐条查。

from sqlalchemy.orm import selectinload
from sqlalchemy import func, select

@app.get("/api/orders")
def list_orders(user_id: int, page: int = 1, size: int = 20):
    offset = (page - 1) * size
    stmt = (
        select(Order)
        .options(
            selectinload(Order.user),
            selectinload(Order.items),
        )
        .where(Order.user_id == user_id)
        .order_by(Order.created_at.desc())
        .offset(offset).limit(size)
    )
    orders = db.execute(stmt).scalars().all()

    # count 单独走缓存,见第二刀
    total = get_order_count(user_id)

    return {
        "total": total,
        "data": [
            {
                "id": o.id,
                "amount": o.amount,
                "status": o.status,
                "user_name": o.user.name,
                "items_count": len(o.items),
            }
            for o in orders
        ],
    }

这一刀下去,SQL 从 42 条降到 3 条(列表 + user + items)加 count。单请求耗时从 320ms 掉到 90ms 左右。

第二刀:缓存 count 和首页数据

count 查询其实是全表扫,翻页时数据没变的话没必要每次算。用 Redis 缓存 30 秒,key 带上 user_id。

import json
import redis
from fastapi import FastAPI

r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)
COUNT_TTL = 30

def get_order_count(user_id: int) -> int:
    key = f"order:count:{user_id}"
    cached = r.get(key)
    if cached is not None:
        return int(cached)
    total = db.execute(
        select(func.count()).select_from(Order).where(Order.user_id == user_id)
    ).scalar_one()
    r.setex(key, COUNT_TTL, total)
    return total

首页缓存更激进一点:page=1&size=20 的完整响应缓存 10 秒,因为看板轮询基本都打在这个页面上。key 用 order:list:{user_id}:{page}:{size},值直接存 JSON 字符串。

有个坑:缓存和数据库一致性。我们订单写入频率不高(每分钟几条),所以 TTL 30s 完全能接受。如果写入频繁,就得在写路径上主动 delete key,别偷懒。

第三刀:连接池 + 线程池调优

SQLAlchemy 默认连接池 pool_size=5,max_overflow=10。4 workers 下总共就 20 个连接,QPS 一高就排队。调成:

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

同时启动命令改成 uvicorn main:app --workers 4 --limit-concurrency 200 --backlog 2048。limit-concurrency 限制单 worker 并发,避免线程池被打爆后请求堆积。

五、踩坑与优化

坑 1:selectinload 和 limit 一起用。如果对 items 用 joinedload(JOIN 方式),再配合 limit,SQLAlchemy 会先生成一个大 JOIN 再截断,导致返回条数不对。所以关联集合一定用 selectinload,别用 joinedload。

坑 2:Redis 序列化开销。一开始我用 json.dumps 存整个响应,20 条数据大概 8KB,读的时候 json.loads 也要 1-2ms。后来改成只缓存 count,列表部分靠查询本身快,反而整体更稳。

坑 3:py-spy 抓不到热点。第一次抓火焰图只看到 psycopg2 在等网络,看不出具体是哪条 SQL。后来配合 pg_stat_statements 才确认是 N+1。

坑 4:count 缓存穿透。用户第一次请求某个 user_id 时缓存为空,并发打进来会同时查库。加了个简单的互斥锁(r.set(key, ..., nx=True) 做短锁),或者干脆用 cachetools 本地兜底。

六、效果数据

压测条件统一:locust 100 并发,持续 60s,同一台机器。

指标 优化前 优化后
P50 320ms 42ms
P95 780ms 70ms
P99 1200ms 85ms
RPS 48 620
失败率 6% 0%
单请求 SQL 数 42 3(命中缓存时 1)

生产环境上线后观察一周,P99 稳定在 85-110ms 之间,Redis 命中率约 92%,数据库连接数从峰值 40 降到 8。

七、总结

回头看这次优化,真正有效的是三件事,按收益排序:

  1. selectinload 干掉 N+1:贡献了大约 70% 的收益,也是最容易踩的坑。
  2. 连接池调优:解决了高并发下的排队问题,让 QPS 能真正上去。
  3. 缓存 count 和热点页:锦上添花,但要注意一致性。

如果你们也在用 Flask,思路完全一样:joinedload/selectinload、连接池参数、Flask-Caching 或直接上 Redis,profiling 工具换成 Flask-DebugToolbar 或 py-spy 即可。核心心法就一句:先测量,再优化,别猜。