1. 问题背景:一个被业务催着上线的接口

上个月接手一个订单服务,核心接口GET /api/v1/orders在测试环境响应时间就离谱——平均1500ms,P95直接飙到2.3秒。压测工具wrk怼上去,100并发就把CPU干到90%,QPS只有120。

第一反应是“这代码谁写的”。打开代码一看,好家伙,典型的SQLAlchemy懒加载地狱:

# 伪代码示意
orders = session.query(Order).filter(Order.user_id == user_id).all()
for order in orders:
    items = order.items  # 每个order触发一次查询
    for item in items:
        product = item.product  # 每个item再触发一次

一个订单平均8个item,每个item关联product和sku表。算下来一个请求要跑 1 + 8 + 8*2 = 25次数据库查询。这就是N+1问题,性能杀手。

环境版本:Python 3.11.4,FastAPI 0.104.1,SQLAlchemy 2.0.23,PostgreSQL 15.3(本地Docker跑),Redis 7.2。

2. Profiling工具:别猜,用数据说话

很多同学一上来就盲优化,这是大忌。先用cProfile跑一遍,把热点揪出来。

python -m cProfile -o profile_output.prof my_app.py

然后用pstats分析,或者直接用snakeviz可视化:

pip install snakeviz
snakeviz profile_output.prof

关键输出指标:

ncalls  tottime  percall  cumtime  percall  filename:lineno
21250   0.892    0.000    3.215    0.000  sqlalchemy/orm/loading.py:123
12500   0.441    0.000    2.894    0.000  sqlalchemy/orm/strategies.py:456
8500    0.312    0.000    1.203    0.000  app/models.py:85 (serialize)

数据很清楚:
- SQLAlchemy ORM加载占了62%的累计时间
- 序列化(Pydantic v2的model_validate)占了12%
- 剩下的零碎时间在路由、中间件、日志等

还有个细节:tottime最高的是loading.py,说明不是SQL执行慢,而是ORM对象构建和懒加载触发的额外查询。数据库层面其实每条SQL都在10ms以内,但架不住数量多。

3. 方案设计:三层优化

基于profiling结果,设计三层优化:

第一层:消灭N+1。用SQLAlchemy 2.0的selectinload一次性加载关联对象,把25次查询压到3次(orders、items、products+skus)。

第二层:缓存热点数据。用户最近30天的订单是高频访问,且数据变更不频繁。用Redis缓存序列化后的JSON,TTL设5分钟。缓存key设计为order:list:{user_id}:{page}:{page_size},带版本号方便失效。

第三层:序列化优化。Pydantic v2已经很快了,但每次构建模型还是有一定开销。对只读接口,直接手动构建dict返回,跳过Pydantic的校验流程。

4. 核心实现:代码对比

改造前(懒加载地狱)

@app.get("/api/v1/orders")
async def get_orders(user_id: int, page: int = 1):
    orders = db.query(Order).filter(Order.user_id == user_id).all()
    result = []
    for order in orders:
        order_data = {
            "id": order.id,
            "total": order.total,
            "items": []
        }
        for item in order.items:  # 每个order一次查询
            item_data = {
                "product_name": item.product.name,  # 每个item再查product
                "sku_code": item.sku.code,  # 每个item再查sku
                "quantity": item.quantity
            }
            order_data["items"].append(item_data)
        result.append(order_data)
    return result

改造后(selectinload + Redis缓存)

from sqlalchemy.orm import selectinload
import aioredis
import json

redis_client = aioredis.from_url("redis://localhost:6379/0")

@app.get("/api/v1/orders")
async def get_orders(user_id: int, page: int = 1):
    # 缓存优先
    cache_key = f"order:list:{user_id}:{page}"
    cached_data = await redis_client.get(cache_key)
    if cached_data:
        return json.loads(cached_data)

    # 一次性加载所有关联对象
    orders = db.query(Order).options(
        selectinload(Order.items).selectinload(Item.product),
        selectinload(Order.items).selectinload(Item.sku)
    ).filter(Order.user_id == user_id).all()

    # 手动构建dict,跳过Pydantic校验开销
    result = []
    for order in orders:
        items = [{
            "product_name": item.product.name,
            "sku_code": item.sku.code,
            "quantity": item.quantity
        } for item in order.items]
        result.append({
            "id": order.id,
            "total": order.total,
            "items": items
        })

    # 写缓存,TTL 300秒
    await redis_client.set(cache_key, json.dumps(result), ex=300)
    return result

注意几个细节:
- selectinload是SQLAlchemy 2.0推荐的,比joinedload好在不会产生笛卡尔积
- 缓存key要包含分页参数,否则不同页会串数据
- 手动构建dict省去了Pydantic的model_validate时间,这个接口不需要校验入参,直接返回裸dict没问题

5. 踩坑与优化:三个意想不到的坑

坑1:selectinload导致SQL IN子句过长
订单表数据量大,一次性查所有关联items会生成WHERE order_id IN (1,2,3,...500)。PostgreSQL对IN列表有优化上限,超过500个ID性能下降。解决:分页限制单页最大50条,并且selectinload内部自动分批(batch_size=100)。

坑2:Redis缓存穿透
压测时发现缓存miss瞬间,DB连接池被打满。原因是用户第一次请求,缓存没数据,100个并发同时查DB。解决:加布隆过滤器或者锁。简单方案:对不存在数据的key也缓存一个空列表,TTL设30秒,防止恶意穿透。

坑3:序列化时decimal类型无法JSON序列化
订单金额是Decimal类型,直接json.dumps报错。解决:自定义encoder:

class DecimalEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Decimal):
            return float(obj)
        return super().default(obj)

json.dumps(result, cls=DecimalEncoder)

6. 压测数据:优化前后对比

用wrk压测,参数:100并发,30秒duration,GET请求。

优化前

Running 30s test @ http://localhost:8000/api/v1/orders
  100 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.51s     0.87s     3.25s    78.90%
    Req/Sec     1.20k   123.45     1.50k    70.00%
  1203 requests in 30.05s, 1.1MB read

优化后(三层全上)

Running 30s test @ http://localhost:8000/api/v1/orders
  100 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    80.32ms   12.34ms   156.78ms   88.50%
    Req/Sec    18.00k   1.23k    20.50k    75.00%
  18004 requests in 30.05s, 16.5MB read

分项数据对比

指标 优化前 优化后 提升
平均延迟 1500ms 80ms 18.75x
P95延迟 2300ms 110ms 20.9x
QPS 120 1800 15x
DB查询次数/请求 25 3 8.3x
CPU占用 90% 35% -55%

7. 总结与后续优化方向

三层优化效果显著,但还有改进空间:

  1. 数据库索引:当前orders.user_id有索引,但items.order_iditems.sku_id没有复合索引。添加后DB查询时间还能再降30%。
  2. 异步化:当前用的是同步SQLAlchemy,FastAPI中会阻塞事件循环。改成asyncpg + SQLAlchemy async,理论上还能再提升30%左右。
  3. CDN层缓存:对不经常变化的订单数据,可以在CDN层加缓存,减少到应用层的请求。

这次调优最大的收获是:先profiling,再优化。不要凭感觉改代码,用数据说话。另外,ORM的懒加载是性能杀手,生产环境一定要检查echo=True日志,看看每条请求到底发了多少SQL。

后续如果有人想看异步改造和索引优化,我可以再写一篇。有问题评论区交流。