一、问题背景:一个"看起来很简单"的接口

事情是这样的,我们有个订单列表接口 GET /api/v1/orders,功能非常朴素:按用户 ID 分页查询订单,每条订单带上商品信息和用户昵称。逻辑上就是几个查询拼一拼,代码不到 40 行。

上线初期没什么量,谁也没在意。直到运营做了一次促销活动,流量涨了大概 5 倍,监控开始报警:

  • P95 延迟:820ms
  • P99 延迟:1.6s
  • 错误率:4.7%(主要是客户端超时重试)
  • 单实例 QPS 到 210 左右就打满

用户投诉"订单页转圈圈",老板在群里 @ 了三次。行吧,开始干活。

二、环境与版本

先把环境交代清楚,不同版本的坑差别很大:

  • Python 3.11.6
  • FastAPI 0.110.0
  • Uvicorn 0.27.1(--workers 4
  • SQLAlchemy 2.0.28
  • asyncpg 0.29.0
  • PostgreSQL 14.10
  • Redis 7.2
  • 压测工具:wrk 4.2.0 + locust 2.20
  • 部署:4C8G 容器,单实例

三、方案设计:先测,别猜

我见过太多人一上来就说"加缓存吧",然后缓存加了一堆,慢的地方一点没变。性能优化的第一步永远是定位,不是动手

我的排查路线:

  1. 压测复现:用 wrk 在本地稳定复现 800ms 的 P95
  2. 应用层 profiling:cProfile 看函数耗时分布,py-spy 抓火焰图
  3. SQL 层 profiling:打开 SQLAlchemy 慢查询日志 + PostgreSQL pg_stat_statements
  4. 确定瓶颈后针对性优化:ORM 加载策略 → 索引 → 缓存
  5. 回归压测:对比数据

四、核心实现:从定位到优化

4.1 压测复现

先用 wrk 稳定复现问题:

wrk -t8 -c100 -d60s --latency \
  "http://127.0.0.1:8000/api/v1/orders?user_id=12345&page=1&size=20"

初始结果:

Latency Distribution
  50%  312.45ms
  75%  589.12ms
  90%  743.88ms
  99%    1.62s
Requests/sec: 208.34

4.2 py-spy 抓火焰图

这是我认为最省事的 profiling 方式,不需要改代码、不需要重启:

py-spy record -o profile.svg --pid $(pgrep -f uvicorn | head -1) --duration 30

火焰图上一眼就能看出来,sqlalchemy/orm/loading.py 的时间占比接近 70%,而且是很多次小查询堆叠,典型的 N+1。

同时打开 SQLAlchemy 的 echo,日志刷屏:

engine = create_async_engine(DATABASE_URL, echo=True)

打印出来是这样的:

SELECT * FROM orders WHERE user_id=$1 LIMIT 20;      -- 1 次
SELECT * FROM products WHERE id=$1;                   -- 20 次
SELECT * FROM users WHERE id=$1;                      -- 20 次

一个请求 41 次 SQL,每次 15-20ms 网络往返,加起来 700ms+,完全对上了。

4.3 优化一:干掉 N+1

原来的代码(简化版):

@router.get("/orders")
async def list_orders(user_id: int, page: int = 1, size: int = 20):
    async with AsyncSession(engine) as session:
        result = await session.execute(
            select(Order)
            .where(Order.user_id == user_id)
            .offset((page - 1) * size)
            .limit(size)
        )
        orders = result.scalars().all()

        items = []
        for order in orders:  # 每次循环都查一次数据库
            product = await session.get(Product, order.product_id)
            user = await session.get(User, order.user_id)
            items.append({
                "id": order.id,
                "amount": order.amount,
                "product_name": product.name,
                "user_name": user.nickname,
            })
        return items

改成 selectinload 一次性把关联数据拉进来:

from sqlalchemy.orm import selectinload

@router.get("/orders")
async def list_orders(user_id: int, page: int = 1, size: int = 20):
    async with AsyncSession(engine) as session:
        stmt = (
            select(Order)
            .options(
                selectinload(Order.product),
                selectinload(Order.user),
            )
            .where(Order.user_id == user_id)
            .order_by(Order.created_at.desc())
            .offset((page - 1) * size)
            .limit(size)
        )
        result = await session.execute(stmt)
        orders = result.scalars().all()

        return [
            {
                "id": o.id,
                "amount": o.amount,
                "product_name": o.product.name,
                "user_name": o.user.nickname,
            }
            for o in orders
        ]

SQL 从 41 条降到 3 条:

SELECT * FROM orders WHERE user_id=$1 ORDER BY created_at DESC LIMIT 20;
SELECT * FROM products WHERE id IN ($1, $2, ...);
SELECT * FROM users WHERE id IN ($1, $2, ...);

压测结果:P95 从 820ms → 340ms。有进步,但还不够。

4.4 优化二:加索引

打开 pg_stat_statements 看慢查询:

SELECT query, calls, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

发现 orders 表上 user_id + created_at 的复合索引没建,全表扫描 12 万行。补上:

CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);

顺带把 products.idusers.id 主键索引确认了一遍(这俩本来就有)。

这一刀下去,P95 从 340ms → 180ms。

4.5 优化三:加缓存

180ms 其实已经能接受了,但这个接口的读取特征非常明显:

  • 数据几乎不变(订单创建后很少更新)
  • 同一个 user_id 反复请求(用户刷新、翻页)
  • 允许秒级延迟

典型缓存友好场景。上 Redis,缓存粒度选"用户 + 页码":

import json
import redis.asyncio as redis

redis_client = redis.from_url("redis://localhost:6379/0", decode_responses=True)

CACHE_TTL = 60  # 秒

@router.get("/orders")
async def list_orders(user_id: int, page: int = 1, size: int = 20):
    cache_key = f"orders:{user_id}:{page}:{size}"

    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    async with AsyncSession(engine) as session:
        stmt = (
            select(Order)
            .options(selectinload(Order.product), selectinload(Order.user))
            .where(Order.user_id == user_id)
            .order_by(Order.created_at.desc())
            .offset((page - 1) * size)
            .limit(size)
        )
        result = await session.execute(stmt)
        orders = result.scalars().all()

        data = [
            {
                "id": o.id,
                "amount": o.amount,
                "product_name": o.product.name,
                "user_name": o.user.nickname,
            }
            for o in orders
        ]

    await redis_client.setex(cache_key, CACHE_TTL, json.dumps(data))
    return data

写入侧记得失效缓存:

async def create_order(...):
    # ... 创建订单逻辑
    # 用 SCAN 清理该用户的所有分页缓存
    async for key in redis_client.scan_iter(match=f"orders:{user_id}:*"):
        await redis_client.delete(key)

压测结果:命中缓存的请求 P95 45ms,未命中的 180ms 左右。整体 P95 落在 60ms 以内。

五、踩坑与优化

坑 1:selectinload vs joinedload 选错

一开始我用了 joinedload,结果分页 LIMIT 20 因为 JOIN 产生笛卡尔积,实际捞回来的行数爆炸。selectinload 会先查主表再 IN 查关联表,分页语义才正确。记住:分页场景用 selectinload,一对一且明确唯一时才能用 joinedload

坑 2:缓存雪崩

TTL 都是 60s,同一时刻大量 key 一起过期,瞬间把数据库打回原形。解决:TTL 加随机抖动:

import random
ttl = CACHE_TTL + random.randint(-10, 10)
await redis_client.setex(cache_key, ttl, json.dumps(data))

坑 3:selectinload 的 IN 列表太大

如果一页 20 条还没事,但如果有接口一页拉 1000 条,IN 里 1000 个 ID,asyncpg 的参数绑定会很难看。可以用 selectinload(...).selectin_polymorphic 或干脆限制单页 size ≤ 100。

坑 4:py-spy 采样看不到 await 挂起

py-spy 对纯异步代码的等待时间展示不直观,如果哪一步卡在 IO,火焰图上不一定看得出来。配合 loguru 打点,或者用 aiomonitor 看协程状态更靠谱。

坑 5:uvicorn worker 数

4C8G 的机器我一开始上 8 个 worker,反而更慢。原因是数据库连接池每个 worker 一份,8 worker × pool_size 20 = 160 连接,PostgreSQL 的 max_connections 默认 100,直接打满连接。后来改成 4 worker × pool_size 10 = 40 连接,稳定很多。

六、效果数据

同一台机器,同一份压测脚本,wrk -t8 -c100 -d60s

阶段 P50 P95 P99 QPS 错误率
优化前 312ms 820ms 1.62s 208 4.7%
干掉 N+1 128ms 340ms 620ms 512 0.8%
+ 索引 62ms 180ms 320ms 890 0.1%
+ Redis 缓存 18ms 45ms 92ms 1420 0%

缓存命中率稳定在 87% 左右(60s TTL,用户行为集中在短时间内)。

顺便说下,这个优化过程大概花了两个下午,其中一半时间在压测和验证,真正改代码的时间不到 2 小时。性能优化就是这样,80% 的时间花在定位,20% 的时间花在动手

七、总结

回头看,这个案例里没有什么"黑科技",就是三个经典手段按顺序执行:

  1. profiling 定位:py-spy + SQLAlchemy echo + pg_stat_statements,三个工具组合拳,5 分钟就能把瓶颈锁定
  2. ORM 查询优化:N+1 是 ORM 性能杀手,selectinload 是解药,但要分清使用场景
  3. 数据库索引:80% 的慢查询因为没有对的复合索引,EXPLAIN ANALYZE 是最好的朋友
  4. 缓存:不是万能药,但在读多写少 + 容忍秒级延迟的场景下,收益巨大

最后一句忠告:上线前压一遍,比上线后救火便宜一百倍。如果你手上也有慢接口,不妨从 py-spy 开始,先看真问题在哪。