一、问题背景

上个月接手了一个电商中台项目,技术栈是 FastAPI 0.110.0 + SQLAlchemy 2.0.29 + PostgreSQL 15.3 + Redis 7.2。上线后运营反馈后台"订单列表"页面加载特别慢,尤其是筛选"待发货"状态的时候,转圈能转到怀疑人生。

我先用 curl 简单测了一下:

curl -w "\n总耗时: %{time_total}s\n" -H "Authorization: Bearer xxx" \
  "https://api.example.com/api/v1/orders?status=pending&page=1&size=20"

结果:

总耗时: 1.243s

这个接口逻辑很简单:查订单列表 + 关联查用户信息 + 关联查商品信息。按理说不该这么慢。更离谱的是,当我用 wrk 压测时(并发200,持续30秒),P99 直接飙到 1.8s,PostgreSQL 的 CPU 使用率冲到 95%,接口开始大量超时。

问题很明确:这个接口有性能瓶颈,而且瓶颈大概率在数据库层。但具体是慢查询、N+1、还是索引缺失?得用数据说话。

二、环境与版本

先说明一下环境,避免版本差异导致结论不可复现:

组件 版本
Python 3.11.8
FastAPI 0.110.0
Uvicorn 0.29.0 (workers=4)
SQLAlchemy 2.0.29
asyncpg 0.29.0
PostgreSQL 15.3
Redis 7.2.4
py-spy 0.3.14
wrk 4.2.0

部署环境是 4C8G 的云服务器,数据库单独一台 8C16G。压测机跟服务同内网,避免网络抖动干扰。

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

3.1 用 py-spy 抓火焰图

FastAPI 是异步框架,普通的 cProfile 对 async 代码支持不好,我直接用 py-spy 采样:

# 找到 uvicorn 进程
ps aux | grep uvicorn

# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 100

同时用 wrk 打流量:

wrk -t4 -c200 -d30s --latency \
  "http://127.0.0.1:8000/api/v1/orders?status=pending&page=1&size=20"

火焰图打开一看,asyncpg_wait_for_query 占了 68% 的采样,也就是说大部分时间都在等数据库返回。这就排除了 Python 层的 CPU 密集问题,锁定在 SQL 层面。

3.2 打开 PostgreSQL 慢查询日志

postgresql.conf 里加上:

log_min_duration_statement = 200   # 超过200ms记录
log_line_prefix = '%t [%p] user=%u,db=%d '

重启后看日志,发现这个接口一次请求竟然发了 23 条 SQL!典型的 N+1 问题。其中一条查询:

SELECT * FROM order_items WHERE order_id = 10086;

单条只要 2ms,但一次请求要执行 20 次(每页20条订单),加上主查询和其他关联查询,累计就上去了。

3.3 用 EXPLAIN ANALYZE 看主查询

主查询是这样的:

SELECT * FROM orders 
WHERE status = 'pending' 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 0;

跑一下:

EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

结果:

Seq Scan on orders  (cost=0.00..45231.00 rows=198234 width=...) 
                    (actual time=0.015..412.337 rows=20 loops=1)
  Filter: (status = 'pending'::text)
  Rows Removed by Filter: 198214

全表扫描,扫了 19 万行才筛出 20 条。orders 表有 20 万行数据,status 字段竟然没索引。这就是第二个瓶颈。

四、方案设计与核心实现

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

  1. N+1 查询 → 用 SQLAlchemy 的 selectinload 预加载
  2. 缺失索引 → 建复合索引 (status, created_at DESC)
  3. 热点数据 → 加 Redis 缓存,TTL 30 秒

4.1 改造前的代码

# 改造前:N+1 重灾区
@app.get("/api/v1/orders")
async def list_orders(status: str, page: int = 1, size: int = 20, db: AsyncSession = Depends(get_db)):
    offset = (page - 1) * size
    result = await db.execute(
        select(Order).where(Order.status == status)
        .order_by(Order.created_at.desc())
        .offset(offset).limit(size)
    )
    orders = result.scalars().all()

    data = []
    for o in orders:
        # 循环里查两次,20条订单 = 40次查询
        user = await db.get(User, o.user_id)
        items = (await db.execute(
            select(OrderItem).where(OrderItem.order_id == o.id)
        )).scalars().all()
        data.append({"order": o, "user": user, "items": items})
    return data

4.2 优化后的代码

# 优化后:预加载 + 缓存 + 只查需要的字段
from sqlalchemy.orm import selectinload
from sqlalchemy import select
import json, hashlib

CACHE_TTL = 30

@app.get("/api/v1/orders")
async def list_orders(
    status: str,
    page: int = 1,
    size: int = 20,
    db: AsyncSession = Depends(get_db),
    redis: Redis = Depends(get_redis),
):
    # 1. 缓存 key(用参数拼 hash)
    cache_key = "orders:" + hashlib.md5(
        f"{status}:{page}:{size}".encode()
    ).hexdigest()

    # 2. 先查缓存
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    # 3. 一次查询搞定关联(selectinload 会发 2 条 SQL 而非 40 条)
    offset = (page - 1) * size
    stmt = (
        select(Order)
        .options(
            selectinload(Order.user),
            selectinload(Order.items),
        )
        .where(Order.status == status)
        .order_by(Order.created_at.desc())
        .offset(offset)
        .limit(size)
    )
    result = await db.execute(stmt)
    orders = result.scalars().all()

    data = [
        {
            "id": o.id,
            "user": {"id": o.user.id, "name": o.user.name},
            "items": [{"sku": i.sku, "qty": i.qty} for i in o.items],
            "created_at": o.created_at.isoformat(),
        }
        for o in orders
    ]

    # 4. 写缓存
    await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
    return data

4.3 加索引

-- 复合索引,覆盖 WHERE + ORDER BY
CREATE INDEX CONCURRENTLY idx_orders_status_created 
ON orders (status, created_at DESC);

-- 关联表的外键索引(之前也没建)
CREATE INDEX CONCURRENTLY idx_order_items_order_id 
ON order_items (order_id);

CREATE INDEX CONCURRENTLY idx_orders_user_id 
ON orders (user_id);

注意用 CONCURRENTLY,生产环境建索引不锁表。

五、踩坑与进一步优化

坑1:selectinload vs joinedload

一开始我用的是 joinedload,结果发现当订单有多个 items 时,主查询会产生笛卡尔积,返回行数爆涨,内存直接吃掉几百 MB。selectinload 是发第二条 WHERE order_id IN (...) 查询,更稳妥。记住:一对多用 selectinload,多对一用 joinedload

坑2:缓存击穿

上线后发现热点 key 过期瞬间,几十个并发同时打到数据库。加了个简单的互斥锁:

lock_key = cache_key + ":lock"
if await redis.set(lock_key, "1", nx=True, ex=5):
    try:
        data = await query_db(...)
        await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
    finally:
        await redis.delete(lock_key)
else:
    await asyncio.sleep(0.1)
    cached = await redis.get(cache_key)
    return json.loads(cached) if cached else []

坑3:连接池配置

默认 pool_size=5 在高并发下不够用,改成:

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
    pool_recycle=3600,
)

坑4:COUNT 查询

分页前端要总数,SELECT COUNT(*) 在大表上很贵。我改成用 Redis 单独缓存总数,TTL 60 秒,或者干脆上 pg_class.reltuples 估算。

六、效果数据

优化前后用同样的 wrk 参数压测(4线程、200并发、30秒):

指标 优化前 优化后 提升
P50 680ms 42ms 16x
P99 1.82s 80ms 22x
QPS 152 1108 7.3x
单请求 SQL 数 23 2 -91%
DB CPU 95% 22% -
错误率 3.7% 0% -

单条接口的耗时从 1.243s 降到 78ms。火焰图上 _wait_for_query 的占比从 68% 掉到 15%。

七、总结

这次调优最深的感受是:不要凭感觉优化,一定要用数据定位。我一开始以为瓶颈在 FastAPI 的序列化,差点去换 orjson,结果 py-spy 一跑,全在等数据库。

三条经验:

  1. py-spy 是异步框架的 profiling 神器,比 cProfile 好用太多,生产环境也能直接跑。
  2. N+1 是 ORM 永恒的话题,SQLAlchemy 2.0 的 selectinload 一定要用起来,别在循环里查数据库。
  3. 索引不是加了就行,要按 WHERE + ORDER BY 的组合建复合索引,顺序错了照样全表扫。

最后提醒一句:缓存虽然香,但一致性要自己兜底。订单状态变更时记得主动删对应的缓存 key,否则用户会看到"幻觉订单"。这个坑我踩过,就不展开说了。

如果这篇文章帮你解决了一个慢接口,点个赞再走。有问题评论区聊。