一、问题背景

事情是这样的,我们有个商品详情接口 /api/v1/products/{id},上线初期数据量小,响应一直稳定在100ms出头。后来商品表涨到80万行,关联的SKU、规格、评价表也水涨船高,接口开始不对劲了。

监控上看到的现象:

  • P50 从 90ms 涨到 320ms
  • P99 直接干到 1200ms
  • QPS 超过 200 之后,uvicorn 的 worker 开始排队,502 频发

最离谱的是,这个接口逻辑看起来"很简单":查商品、查SKU、查规格、查评价数。代码是我半年前写的,当时觉得没问题,现在回头看全是坑。

技术栈先交代清楚:

Python 3.11.6
FastAPI 0.109.0
uvicorn 0.27.0 (4 workers, gunicorn 管理)
SQLAlchemy 2.0.25 (同步ORM)
PostgreSQL 14.10
Redis 7.2.3

注意,我们用的是同步 SQLAlchemy,不是 async。这点后面会聊到,先按下不表。

二、定位:别猜,用 py-spy 看火焰图

遇到性能问题,最忌讳的就是"我觉得是XX慢"。我一开始也怀疑是Redis或者网络,结果打脸。

先用最土但最有效的办法,在接口里埋点计时:

import time
from fastapi import APIRouter

router = APIRouter()

@router.get("/products/{product_id}")
def get_product(product_id: int):
    t0 = time.perf_counter()
    product = fetch_product(product_id)
    t1 = time.perf_counter()
    skus = fetch_skus(product_id)
    t2 = time.perf_counter()
    reviews = fetch_review_count(product_id)
    t3 = time.perf_counter()
    print(f"product={t1-t0:.1f}ms skus={t2-t1:.1f}ms reviews={t3-t2:.1f}ms")
    ...

跑了几次,日志大概是这个鬼样子:

product=12.3ms skus=680.5ms reviews=410.2ms

SKU 和评价查询是大头。但具体是哪条SQL?得请出 py-spy

pip install py-spy==0.3.14

# 找到 uvicorn worker 的 pid
ps aux | grep uvicorn

# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 200

打开 profile.svg,一眼就看到了:

get_product (87.2%)
  └── fetch_skus (54.1%)
        └── SQLAlchemy Session.execute (52.8%)
              └── psycopg2 cursor.execute (51.9%)
  └── fetch_review_count (30.6%)
        └── SQLAlchemy ...

87% 的时间在数据库,而且 fetch_skus 里不止一条SQL。翻了代码才发现,我写了个经典的N+1:

def fetch_skus(product_id):
    skus = db.query(SKU).filter(SKU.product_id == product_id).all()
    result = []
    for sku in skus:  # 每个SKU都查一次规格
        specs = db.query(Spec).filter(Spec.sku_id == sku.id).all()
        result.append({"sku": sku, "specs": specs})
    return result

一个商品平均 12 个SKU,加上其他查询,单个请求打了 43 条 SQL。这就是 P99 1200ms 的元凶。

三、方案设计

定位清楚后,优化思路分三层:

  1. SQL层:干掉 N+1,用 selectinload 预加载,减少往返
  2. 查询层:加合适的索引,避免全表扫描
  3. 缓存层:热点商品详情进 Redis,设合理 TTL 和空值缓存

原则:先优化SQL,再上缓存。因为缓存只能掩盖问题,SQL不优化,缓存击穿时数据库直接躺平。

四、核心实现

4.1 SQLAlchemy 预加载,消灭 N+1

SQLAlchemy 2.0 的 selectinload 会用一条 IN 查询把关联数据一次性捞回来,比 joinedload 更适合一对多(避免笛卡尔积膨胀)。

from sqlalchemy.orm import selectinload
from sqlalchemy import select, func

def fetch_product_detail(db, product_id: int):
    stmt = (
        select(Product)
        .options(selectinload(Product.skus).selectinload(SKU.specs))
        .where(Product.id == product_id)
    )
    product = db.execute(stmt).scalar_one_or_none()
    if product is None:
        return None

    # 评价数单独聚合,避免把全部评价拉回来
    review_count = db.execute(
        select(func.count(Review.id)).where(Review.product_id == product_id)
    ).scalar_one()

    return build_response(product, review_count)

改造后,SQL 数量从 43 条降到 4 条:1条商品、1条SKU、1条规格、1条评价数。

4.2 索引补齐

EXPLAIN 一看,specs.sku_idreviews.product_id 都没有索引,走的 Seq Scan。补上:

CREATE INDEX CONCURRENTLY idx_skus_product_id ON skus(product_id);
CREATE INDEX CONCURRENTLY idx_specs_sku_id ON specs(sku_id);
CREATE INDEX CONCURRENTLY idx_reviews_product_id ON reviews(product_id);

CONCURRENTLY 是因为线上表大,普通 CREATE INDEX 会锁表,业务得停。

4.3 Redis 缓存

热点商品占比很高(约 20% 的商品贡献 80% 的访问),所以缓存收益明显。

import json
import redis
from functools import wraps

rds = redis.Redis(
    host="redis.internal",
    port=6379,
    db=0,
    socket_timeout=0.05,      # 50ms 超时,快速失败
    socket_connect_timeout=0.05,
    max_connections=50,
)

CACHE_TTL = 300          # 正常缓存 5 分钟
NULL_TTL = 60            # 空值缓存 1 分钟,防穿透

def cache_product(func):
    @wraps(func)
    def wrapper(product_id: int):
        key = f"product:detail:{product_id}"
        try:
            cached = rds.get(key)
            if cached is not None:
                if cached == b"__NULL__":
                    return None
                return json.loads(cached)
        except redis.RedisError:
            pass  # Redis挂了就走DB,别让它拖垮接口

        data = func(product_id)

        try:
            if data is None:
                rds.setex(key, NULL_TTL, "__NULL__")
            else:
                rds.setex(key, CACHE_TTL, json.dumps(data, ensure_ascii=False))
        except redis.RedisError:
            pass

        return data
    return wrapper

关键点:Redis 超时设 50ms,且所有异常都吞掉。缓存是用来加速的,不能反过来成为故障源。

4.4 缓存击穿防护

热点key过期瞬间,大量请求会同时打到DB。加个简单的互斥锁:

import time

def get_with_lock(product_id: int):
    key = f"product:detail:{product_id}"
    lock_key = f"lock:product:{product_id}"

    cached = rds.get(key)
    if cached is not None:
        return json.loads(cached) if cached != b"__NULL__" else None

    # 抢锁,最多等 100ms
    acquired = rds.set(lock_key, "1", nx=True, ex=3)
    if not acquired:
        time.sleep(0.05)
        cached = rds.get(key)
        if cached is not None:
            return json.loads(cached) if cached != b"__NULL__" else None
        # 兜底:拿不到缓存直接查库

    try:
        data = fetch_product_detail(db, product_id)
        rds.setex(key, CACHE_TTL if data else NULL_TTL,
                  json.dumps(data) if data else "__NULL__")
        return data
    finally:
        if acquired:
            rds.delete(lock_key)

五、踩坑与优化

坑1:selectinload 在数据量大时 IN 列表过长。一个商品SKU不多,但如果反过来查"某分类下所有商品及其SKU",IN 列表可能上千。这种情况要用 selectinload 配合分批,或者 joinedload。我们的场景SKU数少,没这问题。

坑2:Redis 序列化用 json.dumps 会丢精度。商品价格是 Decimaljson.dumps 默认报错。得加 default=str,或者用 orjson。我换了 orjson,序列化速度还快了一倍:

import orjson

rds.setex(key, CACHE_TTL, orjson.dumps(data))
# 读取时
return orjson.loads(cached)

坑3:uvicorn worker 数不是越多越好。我们4核机器,一开始配了8个worker,结果上下文切换太频繁。改成 workers = 2 * cores + 1 = 9?不对,那是CPU密集型的公式。FastAPI 是IO密集型,实际压测下来 4 workers 最稳,8个反而因为GIL和内存竞争变慢。

坑4:同步SQLAlchemy阻塞事件循环。这是我们没动的地方——因为用的是同步ORM,FastAPI 会自动把 def 接口丢到线程池执行,所以没阻塞 loop。但如果接口写成 async def 又调用同步DB,那就会卡死整个事件循环。要么全同步用 def,要么全异步用 async def + asyncpg,别混着来。

六、压测数据

用 locust 压测,机器配置 4C8G,PostgreSQL 单独一台 8C16G。

压测脚本

from locust import HttpUser, task, between
import random

class ProductUser(HttpUser):
    wait_time = between(0.01, 0.05)

    @task
    def get_product(self):
        pid = random.randint(1, 800000)
        self.client.get(f"/api/v1/products/{pid}", name="/products/{id}")

优化前后对比(持续压 5 分钟):

指标 优化前 优化后 提升
P50 320ms 42ms 7.6x
P95 890ms 71ms 12.5x
P99 1200ms 86ms 14x
QPS (稳定) 180 1450 8x
平均SQL数/请求 43 4(命中缓存时0) -
缓存命中率 - 82.3% -
错误率 4.2% 0% -

CPU 使用率从压测时的 95% 降到 38%,PostgreSQL 的活跃连接数从 180 降到 25。

关键收益其实不是缓存,是 N+1 的修复。只做SQL优化(不加缓存)时,P99 就已经降到 210ms 了,缓存是把最后这段又砍了一刀。

七、总结

几点经验,给遇到类似问题的同学:

  1. 先 profiling 再动手。py-spy 五分钟能省你五小时瞎猜。
  2. N+1 是API性能的头号杀手。ORM 用着爽,但一定要 review 有没有循环里查库。
  3. 索引要跟着查询走,别凭感觉加。EXPLAIN 是你的朋友。
  4. 缓存是加速器不是救火队。SQL 不优化就上缓存,缓存一挂直接雪崩。
  5. Redis 客户端超时要设短,异常要吞掉,别让缓存故障拖垮主流程。
  6. 别盲目加 worker。IO密集型应用,worker 数超过核数太多反而更慢,实测为准。

这次优化后,接口稳定跑了两个月,P99 一直在 100ms 以内。下次准备把同步 SQLAlchemy 换成 asyncpg + SQLAlchemy 2.0 async,预计还能再压 20% 左右。有踩过 async 坑的朋友欢迎评论区交流。