上周五下午,运维同学在群里丢了一张压测报告截图,说新上线的商品列表接口有点问题。我看了眼数据:P99 2.8s,TPS 180。当时心里咯噔一下——这接口是给首页用的,晚上大促就要开始了。

说实话,这个接口逻辑并不复杂:查商品表,关联分类、品牌、SKU,然后分页返回。之前开发时本地跑着挺快,一上压测就现原形。今天把这三天排查优化的过程完整记录下来,希望能帮到遇到类似问题的朋友。

1. 环境与版本说明

先交代一下运行环境,方便大家对照:

Python 3.10.12
FastAPI 0.95.1
uvicorn 0.21.1 (workers=4)
SQLAlchemy 2.0.15
PostgreSQL 14.2
Redis 7.0 (单机)
压测工具:wrk 4.2.0 (线程4,连接100,时长30s)
机器:8C16G 云主机,PG和Redis同机部署

压测命令统一使用:wrk -t4 -c100 -d30s http://localhost:8000/api/v1/products?page=1&size=20

2. 第一轮排查:cProfile定位CPU热点

先用cProfile跑了一次单请求分析,采样100次取中位数:

import cProfile
import pstats
from app.main import app
from fastapi.testclient import TestClient

client = TestClient(app)

profiler = cProfile.Profile()
profiler.enable()
for _ in range(100):
    client.get("/api/v1/products?page=1&size=20")
profiler.disable()

stats = pstats.Stats(profiler)
stats.sort_stats("cumtime").print_stats(20)

输出关键行(截取前5条):

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
100     0.182    0.002    8.734    0.087    /app/models.py:86(get_products)
100     0.001    0.000    7.912    0.079    /app/schemas.py:44(product_to_dict)
100     0.003    0.000    6.893    0.069    /app/services.py:31(ProductService.list)

明显了,product_to_dictget_products 占了大头。product_to_dict 里面做了什么?仔细看代码,原来每个商品要拼SKU列表和分类路径,分别查了两次数据库。20个商品,就是20*(1+2) = 60次查询。

这就是教科书级的N+1问题。

3. 第一轮优化:SQLAlchemy selectinload

先看原始查询代码(简化版):

# 优化前
products = db.query(Product).offset(offset).limit(size).all()
result = []
for p in products:
    skus = db.query(SKU).filter(SKU.product_id == p.id).all()
    category = db.query(Category).filter(Category.id == p.category_id).first()
    result.append({
        "id": p.id,
        "name": p.name,
        "skus": [s.to_dict() for s in skus],
        "category": category.name
    })

改成SQLAlchemy 2.0的selectinload

# 优化后
from sqlalchemy.orm import selectinload

stmt = (
    select(Product)
    .options(
        selectinload(Product.skus),
        selectinload(Product.category),
    )
    .offset(offset)
    .limit(size)
)
products = db.execute(stmt).scalars().all()
result = [p.to_dict() for p in products]

同时把Product.to_dict()写成了方法,内部直接遍历已加载的关系,不再触库。

压测一下(wrk 30s):

指标 优化前 优化后 变化
TPS 180 640 +255%
P50 480ms 150ms -69%
P99 2800ms 620ms -78%

数据库查询次数从每次请求60次降到了3次(主表+SKU批量IN+分类批量IN)。效果显著,但说实话还不够——P99还是600多毫秒,对首页来说不能忍。

4. 第二轮优化:Redis缓存策略

单纯靠SQL优化已经到瓶颈了,再往下就是加缓存。考虑到商品数据变更频率低(管理员后台修改),我们决定用Redis做两级缓存:列表缓存 + 单品缓存。

缓存策略设计:

  1. 列表缓存:key为products:list:{page}:{size},缓存5分钟
  2. 单品缓存:key为product:{id},缓存30分钟,后台修改商品时主动删除
  3. 缓存穿透保护:设置空值缓存(value为__EMPTY__,过期时间60秒)

核心实现:

import json
import aioredis
from fastapi import Depends

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

CACHE_LIST_TTL = 300
CACHE_ITEM_TTL = 1800
EMPTY_MARK = "__EMPTY__"

async def get_products_with_cache(page: int, size: int):
    cache_key = f"products:list:{page}:{size}"

    # 1. 尝试读缓存
    cached = await redis.get(cache_key)
    if cached:
        if cached == EMPTY_MARK:
            return []
        return json.loads(cached)

    # 2. 查数据库(注意:这里需要异步驱动,我们用了asyncpg)
    async with async_session() as session:
        stmt = (
            select(Product)
            .options(selectinload(Product.skus), selectinload(Product.category))
            .offset((page - 1) * size)
            .limit(size)
        )
        result = await session.execute(stmt)
        products = result.scalars().all()
        data = [p.to_dict() for p in products]

    # 3. 写入缓存
    if data:
        await redis.set(cache_key, json.dumps(data, ensure_ascii=False), ex=CACHE_LIST_TTL)
    else:
        await redis.set(cache_key, EMPTY_MARK, ex=60)

    return data

注意这里把数据库驱动换成了asyncpg,因为之前用psycopg2同步驱动会阻塞事件循环,高并发时反而更慢。换成异步驱动后,配合async_session(AsyncSession),整个请求链路都是异步的。

5. 踩坑记录:序列化与缓存一致性

这里踩了两个坑,值得单独拿出来说:

坑1:to_dict()返回datetime对象,json.dumps直接报错。 解决方式是自定义JSONEncoder:

from datetime import datetime, date

class CustomEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, (datetime, date)):
            return obj.isoformat()
        return super().default(obj)

# 使用
json.dumps(data, cls=CustomEncoder, ensure_ascii=False)

坑2:后台修改商品后,列表缓存还是旧的。 我们写了个装饰器,在商品更新接口中主动删除相关列表缓存:

def invalidate_product_cache(product_id: int):
    # 删除单品缓存
    await redis.delete(f"product:{product_id}")
    # 删除所有列表缓存(用scan匹配前缀)
    async for key in redis.scan_iter(match="products:list:*"):
        await redis.delete(key)

但这有个问题:如果列表页很多,scan会阻塞。后来改成了加版本号:products:list:v2:{page}:{size},修改商品时版本号+1,旧key自然过期。简单粗暴,实测够用。

6. 最终压测数据与总结

最终版本(异步SQLAlchemy + Redis缓存 + 版本号失效策略)压测结果:

指标 原始版 SQL优化版 缓存版
TPS 180 640 2100
P50 480ms 150ms 38ms
P95 1200ms 420ms 75ms
P99 2800ms 620ms 120ms
数据库QPS ~10800 ~1920 ~420

数据库负载降了20多倍,接口延迟降了23倍。大促当天跑完,监控面板上这条接口的曲线稳如老狗。

总结下来,API性能调优的顺序应该是:

  1. 先profiling,别瞎猜。cProfile + py-spy都能快速定位热点
  2. SQL优化优先,N+1查询是最大的坑,ORM的selectinload/joinedload要熟练
  3. 缓存是最后手段,但注意缓存穿透、雪崩、一致性这三个经典问题
  4. 异步化是隐藏福利,如果代码是同步阻塞的,换async驱动往往有意外惊喜

最后想说一点:不要过度设计。我们这个接口一开始用joinedload也行,但数据量大了以后selectinload更稳(不会产生笛卡尔积)。缓存TTL设5分钟,是因为商品数据确实变化不频繁,如果实时性要求高的接口,缓存策略还得重新考虑。

希望这篇能给正在搞API性能优化的朋友一些参考。有问题欢迎评论区交流。