一、问题背景:上线第二天就被运维拉群

事情是这样的。我们有个订单查询API,FastAPI + SQLAlchemy 2.0 + asyncpg + PostgreSQL 15,部署在K8s里3个Pod。上线第二天,运维甩了张监控图到群里:P99延迟3.7秒,数据库CPU 92%,QPS才180就扛不住了。

我第一反应是“代码没问题,是别人写的有问题”。结果查完代码,脸被打肿——是我自己上周写的。

这个API的逻辑很简单:根据用户ID查最近的20条订单,返回订单号和商品列表。但问题就藏在“商品列表”里。

先看下环境版本,避免你们说“版本不同没法复现”:

Python 3.11.4
FastAPI 0.104.1
SQLAlchemy 2.0.23
asyncpg 0.29.0
PostgreSQL 15.3 (Docker)
Redis 7.2 (Docker)

二、第一步:用profiling工具定位瓶颈,别瞎猜

很多人一上来就加缓存、加索引,但没数据支撑的优化都是玄学。我用了两把刀:

刀1:cProfile(函数级CPU分析)

写了个脚本直接压测接口,用cProfile抓函数调用耗时:

import cProfile
import pstats
import asyncio
from httpx import AsyncClient

async def bench():
    async with AsyncClient(base_url="http://localhost:8000") as client:
        for _ in range(50):  # 压50次
            await client.get("/api/orders?user_id=123&limit=20")

profiler = cProfile.Profile()
profiler.enable()
asyncio.run(bench())
profiler.disable()

stats = pstats.Stats(profiler)
stats.sort_stats("cumulative").print_stats(30)  # 打印累计耗时前30

输出关键那几行(我简化了):

ncalls  tottime  cumtime  filename:lineno
50      0.012    3.210    api.py:30 get_orders        # 总耗时3.2s/50次 ≈ 64ms/次
50      0.008    2.845    db.py:110 fetch_orders      # 数据库操作占88%
250     0.156    1.920    db.py:140 fetch_items       # 每个订单查一次商品
50      0.003    0.210    serializers.py:45 serialize # 序列化占6.5%

结论1:250次fetch_items调用,每次耗时7.7ms——这就是N+1查询。

刀2:py-spy(采样分析,线上用)

本地能跑cProfile,但线上不能随便挂。用py-spy dump看线程栈,确认是不是卡在数据库I/O:

# 找到PID
kubectl exec -it pod-name -- py-spy dump --pid 1

看到栈顶是asyncpg.protocol.prepareawait cursor.fetch,确认不是GIL问题,是数据库查询慢。

三、数据库查询优化:干掉N+1,从64ms降到9ms

问题根因:SQLAlchemy 2.0的lazy='subquery'在异步模式下会发额外查询。看代码:

# 优化前:每个order的items懒加载
async def fetch_orders(user_id: int, limit: int):
    async with async_session() as session:
        result = await session.execute(
            select(Order)
            .where(Order.user_id == user_id)
            .order_by(Order.created_at.desc())
            .limit(limit)
        )
        orders = result.scalars().unique().all()
        # 这里访问 order.items 时,每个触发一次SELECT
        return [{"id": o.id, "items": [i.name for i in o.items]} for o in orders]

优化方案:用selectinload一次性预加载items,同时加index

from sqlalchemy.orm import selectinload

# 优化后:用selectinload预加载
async def fetch_orders_fixed(user_id: int, limit: int):
    async with async_session() as session:
        result = await session.execute(
            select(Order)
            .options(selectinload(Order.items))  # 关键:一次IN查询
            .where(Order.user_id == user_id)
            .order_by(Order.created_at.desc())
            .limit(limit)
        )
        orders = result.scalars().unique().all()
        return [{"id": o.id, "items": [i.name for i in o.items]} for o in orders]

同时给orders.user_idorders.created_at建了复合索引:

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

效果:接口单次调用从64ms降到9ms(数据库查询部分从56ms降到4ms)。但离我目标还差得远,因为QPS 200时还是扛不住——问题在重复查询

四、缓存策略:Redis二级缓存 + 本地LRU

数据库优化后,P99仍有1.2s(因为QPS高时连接池打满)。接下来上缓存,我做了两层

  • L1本地缓存functools.lru_cache,TTL 5秒。适合热用户,避免每次请求都走Redis网络。
  • L2 Redis缓存:TTL 60秒。所有Pod共享,解决多实例一致性。

实现代码如下:

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

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

# 本地缓存装饰器(带TTL)
def ttl_lru_cache(ttl: int, maxsize: int = 128):
    def decorator(fn):
        cache = {}
        lock = asyncio.Lock()

        @lru_cache(maxsize=maxsize)
        def _wrapped(*args):
            return (asyncio.get_event_loop().time(), fn(*args))

        async def wrapper(*args, **kwargs):
            key = (args, tuple(sorted(kwargs.items())))
            now = asyncio.get_event_loop().time()
            async with lock:
                if key in cache:
                    ts, val = cache[key]
                    if now - ts < ttl:
                        return val
                val = await fn(*args, **kwargs)
                cache[key] = (now, val)
                return val

        return wrapper
    return decorator

# 实际使用:优先Redis,再本地
async def get_orders_cached(user_id: int, limit: int):
    cache_key = f"orders:{user_id}:{limit}"

    # 1. 查Redis
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 查数据库(走上面优化过的fetch_orders_fixed)
    data = await fetch_orders_fixed(user_id, limit)

    # 3. 写Redis,TTL 60秒
    await redis.setex(cache_key, 60, json.dumps(data))
    return data

踩坑记录

  • 千万别直接json.dumps SQLAlchemy对象,会报Object of type Order is not JSON serializable。必须先把ORM转dict。
  • Redis连接池默认100,压测时不够,调到500:Redis(..., max_connections=500)
  • 本地缓存必须加asyncio.Lock,否则并发下重复查库。

五、压测效果数据:P99从3.7s到180ms

locust压测,模拟真实场景:用户ID均匀分布(1000个用户),每个用户查20条订单。4核8G Pod,3个副本:

指标 优化前 优化后 提升
QPS(稳定) 180 940 5.2倍
P99延迟 3.7s 180ms 20.5倍
数据库CPU 92% 31% -66%
Redis内存 0MB 78MB 新增

压测命令(locustfile.py关键部分):

from locust import HttpUser, task, between
import random

class OrderUser(HttpUser):
    wait_time = between(0.1, 0.3)

    @task(2)
    def get_orders(self):
        user_id = random.randint(1, 1000)
        self.client.get(f"/api/orders?user_id={user_id}&limit=20")

    @task(1)
    def get_orders_cached(self):
        # 模拟热用户重复访问
        user_id = random.randint(1, 50)
        self.client.get(f"/api/orders?user_id={user_id}&limit=20")

最终结论
- 数据库优化解决的是“慢查询”问题,收益巨大(64ms→9ms)。
- 缓存解决的是“重复计算”问题,收益更大(QPS提升5倍)。
- 如果只做不做profiling,你可能会去调数据库参数,白白浪费时间。

六、总结与反思

  1. 先profile再动手:cProfile、py-spy、EXPLAIN ANALYZE,这三个工具比任何“经验”都靠谱。
  2. N+1查询是FastAPI+SQLAlchemy最常见的坑:异步模式下尤其严重,selectinload是首选。
  3. 缓存不是银弹:缓存击穿、雪崩、一致性。我们只做了缓存穿透防护(空值也缓存),没做击穿防护(加锁),因为QPS还不到1000。如果QPS上万,必须上singleflight
  4. 版本推荐:如果你用Flask,同样的思路适用,但异步性能和并发会差一截。FastAPI + asyncpg + selectinload是唯一能跑满PostgreSQL的连接池的方案。

最后说句实话:这个接口如果当初设计时就把items放进一个JSONB字段,根本不会有N+1问题。但现实业务里,订单和商品毕竟是两张表。所以,调优的本质是理解你的数据和访问模式,工具只是加速器。

如果你们也有类似问题,评论区聊聊你的P99是多少?我先来:现在我的P99稳定在180ms,QPS 940,数据库CPU 31%。