1. 背景:从“能用”到“撑不住”的300ms接口

半年前接手一个商品聚合查询API,技术栈为FastAPI 0.78 + SQLAlchemy 1.4 + PostgreSQL 13 + Redis 6.2。接口逻辑很简单:根据品类ID返回商品列表及关联的SKU、标签、库存信息。压测时发现,单核4GB的容器下,并发200时延迟从300ms暴涨至850ms,CPU冲到95%。

初步怀疑是数据库问题,但查看慢查询日志,单条SQL都在10ms以内。真正的罪魁祸首藏在应用层——一个看似无害的循环。

2. 环境与版本

组件 版本 配置
Python 3.9.12 Gunicorn + Uvicorn workers=4
FastAPI 0.78.0 无特殊配置
SQLAlchemy 1.4.36 sessionmaker(autocommit=False)
PostgreSQL 13.4 shared_buffers=1GB, effective_cache_size=3GB
Redis 6.2.6 maxmemory=2GB, eviction=allkeys-lru
locust 2.12.1 压测客户端

3. 第一刀:用py-spy抓住“看不见的循环”

常规的cProfile对异步代码支持差,我用py-spy在压测时实时采样:

# 安装
pip install py-spy

# 采样30秒,生成火焰图
py-spy record -o flame.svg -p $(pgrep -f uvicorn) --duration 30

火焰图清晰显示:36% 的时间花在sqlalchemy.orm.loading._instance_processor28% 花在json.dumps15% 花在decimal.Decimal.__repr__

定位到代码:

# 原始代码:每次访问关系属性都会触发新查询
products = session.query(Product).filter(Product.category_id == cat_id).all()
for product in products:
    product.skus  # 这里!触发N+1
    product.tags  # 这里!又触发一次

每个product访问.skus时,SQLAlchemy惰性加载会发一条SELECT * FROM skus WHERE product_id = ?。接口返回50个商品,就多出50+条SKU查询和50+条标签查询,总计98条SQL

4. 数据库查询优化:Eager Loading + 批量查询

改造后的查询:

from sqlalchemy.orm import selectinload

def get_products_with_relations(cat_id: int):
    query = (
        session.query(Product)
        .options(
            selectinload(Product.skus),  # 一次IN查询加载所有SKU
            selectinload(Product.tags),
            selectinload(Product.inventory),
        )
        .filter(Product.category_id == cat_id)
    )
    return query.all()

selectinload会将关系查询合并为SELECT * FROM skus WHERE product_id IN (:id1, :id2, ...)。压测对比:

指标 改造前 改造后
单请求SQL数量 98条 4条(1主+3子查询)
数据库连接池占用 频繁等待 稳定在8-12连接
P50延迟 312ms 87ms
P99延迟 850ms 210ms

踩坑记录selectinload会生成IN子句,但PostgreSQL对IN列表超过1000个元素时性能下降。我实测在500个元素以内性能线性增长,超过后改用subqueryload

5. 缓存策略:二级缓存扛住热点数据

数据库优化后P99降到210ms,但高频接口(同一品类被大量用户查询)依然有压力。引入Redis二级缓存:

# 缓存策略:一级进程缓存(lru_cache)+ 二级Redis
from functools import lru_cache
import json
import aioredis

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

class ProductCache:
    # 一级缓存:进程内LRU,最多缓存200个品类
    @lru_cache(maxsize=200)
    def _local_cache(self, cat_id: int) -> str:
        return None  # 占位,实际逻辑在get方法中

    async def get(self, cat_id: int) -> list[dict]:
        # 1. 读本地缓存(本地字典,无序列化开销)
        local_key = f"cat:{cat_id}"
        if local_key in self._local_cache.cache:
            return self._local_cache.cache[local_key]

        # 2. 读Redis
        redis_key = f"products:cat:{cat_id}"
        cached = await redis.get(redis_key)
        if cached:
            data = json.loads(cached)
            self._local_cache.cache[local_key] = data
            return data

        # 3. 查数据库
        products = get_products_with_relations(cat_id)
        data = serialize_products(products)

        # 4. 写入Redis,TTL=60秒
        await redis.setex(redis_key, 60, json.dumps(data))
        self._local_cache.cache[local_key] = data
        return data

缓存命中率监控:用redis-cli info stats观察,命中率稳定在92%-95%。注意点:一级缓存使用lru_cache但未设置过期时间——因为二级缓存已控制TTL,一级缓存仅作为热数据加速。

6. JSON序列化:被忽略的28%耗时

火焰图中json.dumps占28%,排查发现原因是Decimal类型。SQLAlchemy返回的price字段是Decimal('99.99'),原生json模块无法序列化,我们之前用了自定义encoder:

# 原始自定义编码器,性能极差
class OldEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Decimal):
            return float(obj)  # 转换一次
        return super().default(obj)

每次调用float(obj)会触发Python的Decimal→float转换,且JSONEncoder每次只处理一个对象。换成批量预处理

# 优化方案:批量预处理数值字段
from decimal import Decimal
import orjson  # 比标准库快4-6倍

def serialize_products(products: list) -> list[dict]:
    result = []
    for p in products:
        d = p.__dict__.copy()
        d.pop('_sa_instance_state', None)
        # 批量转换所有Decimal字段
        for field in ['price', 'original_price', 'discount']:
            if isinstance(d.get(field), Decimal):
                d[field] = float(d[field])
        result.append(d)
    return result

# API中使用orjson
@app.get("/products/{cat_id}")
async def get_products(cat_id: int):
    data = await cache.get(cat_id)
    return orjson.dumps(data).decode()

性能对比(压测数据):

序列化方式 平均耗时 吞吐量
json.dumps + OldEncoder 28ms 1200 QPS
json.dumps + 预处理 9ms 3400 QPS
orjson + 预处理 3ms 5600 QPS

最终采用orjson,序列化耗时从28ms降至3ms。

7. 压测与最终效果

用locust做压测,配置如下:

# locustfile.py
from locust import HttpUser, task, between

class APIUser(HttpUser):
    wait_time = between(0.1, 0.5)

    @task
    def get_products(self):
        # 模拟真实场景:70%请求热门品类,30%随机
        cat_id = 1 if self.random.random() < 0.7 else self.random.randint(1, 100)
        self.client.get(f"/products/{cat_id}")

最终数据对比(并发200,运行5分钟):

指标 优化前 优化后 提升倍数
P50延迟 312ms 9ms 34.7x
P99延迟 850ms 18ms 47.2x
QPS 1200 9600 8.0x
CPU使用率 95% 42% -53%

8. 总结与踩坑清单

  • Profiling第一:别信直觉,用py-spy火焰图找到真瓶颈。我浪费了两天优化数据库索引,结果问题是N+1和序列化。
  • SQLAlchemy N+1selectinload适合关联数<500的场景,超过用subqueryload。注意joinedload可能会产生笛卡尔积。
  • 缓存层级:本地缓存+Redis二级,本地缓存不要设过期(让Redis控制),否则会造成缓存击穿。
  • JSON序列化:别用标准库的default回调,批量预处理数值类型,然后上orjson。
  • 压测模拟真实流量:热点数据占比要接近生产,否则缓存策略测不准。

最后送一句:API优化不是堆硬件,而是用profiling找到那根最粗的瓶颈,然后一刀砍下去。