一、问题背景

事情是这样的。我们有个内部商品中台,对外提供 /api/v1/products/search 接口,Flask 老服务,后来新业务线用 FastAPI 重写了一版网关层,两套栈并存。上线三个月后,运营那边开始抱怨「商品列表页打开要转圈好几秒」。

我先看了一眼监控:接口 P99 延迟 2.3s,平均 860ms,QPS 峰值大概 120。数据库那边更惨,PostgreSQL 的 CPU 使用率长期在 85%~92% 之间晃,慢查询日志一天刷出几万条。这已经不是「优化一下」的问题了,是快要出事故了。

目标定得很明确:P99 压到 300ms 以内,QPS 撑到 1000 以上,数据库 CPU 降到 40% 以下。

二、环境与版本

先把家底交代清楚,不然复现不了:

  • Python 3.11.6
  • FastAPI 0.109.2 + Uvicorn 0.27.1(网关层)
  • Flask 2.3.3 + Gunicorn 21.2.0,worker 数 4,sync worker(老服务层)
  • SQLAlchemy 2.0.25(ORM),psycopg2-binary 2.9.9
  • PostgreSQL 14.10,16C32G,shared_buffers 8GB
  • Redis 7.2.4,单机
  • 压测工具:Locust 2.20.1
  • Profiling:cProfile(内置)、py-spy 0.3.14、pg_stat_statements

三、方案设计

我的思路是三步走,先测量再动手,别凭感觉瞎改:

  1. 定位:用 py-spy 做在线采样,拿到火焰图;用 pg_stat_statements 抓最耗时的 SQL;用 cProfile 在压测环境复现。
  2. 分层优化:应用层解决 N+1 和同步阻塞,数据库层重建索引、改写慢 SQL,缓存层加 Redis 两级缓存。
  3. 验证:每改一项,用 Locust 跑同一套压测脚本,记录 P50/P95/P99 和 QPS,做前后对比。

这里有个原则我想强调一下:一次只改一个变量。我第一次图省事,索引和缓存一起上,结果 QPS 涨了但不知道是谁的功劳,后面回滚排查特别痛苦。

四、核心实现

4.1 Profiling:先看清楚谁在拖后腿

py-spy 是这次的头号功臣,因为它能 attach 到生产进程上采样,不用改代码不用重启:

# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid $(pgrep -f "uvicorn app.main:app") --duration 30 --rate 200

# 直接看实时热点,top 模式
py-spy top --pid $(pgrep -f "gunicorn")

火焰图一出来,问题一目了然:sqlalchemy/orm/loading.py 占了 60% 以上的采样,而且集中在 _load_for_path 上。这就是典型的 N+1——查商品列表拿到 20 个商品,然后每个商品再单独查一次分类和库存。

再看 pg_stat_statements:

SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

排名第一的是一条 SELECT ... FROM product_sku WHERE product_id = $1,调用次数 40 万+,平均 3.2ms。单条不算慢,但乘以次数就是灾难。

4.2 数据库查询优化:干掉 N+1 + 重建索引

原来 Flask 里那段代码大概是这样,很经典的写法:

# 改造前:N+1 重灾区
products = Product.query.filter(Product.status == 1).limit(20).all()
result = []
for p in products:
    skus = ProductSku.query.filter_by(product_id=p.id).all()  # 每个商品一次查询
    result.append({
        "id": p.id,
        "name": p.name,
        "skus": [{"id": s.id, "price": s.price} for s in skus]
    })

20 个商品 = 1 + 20 次查询,接口里还有分类、品牌,实际是 1 + 60 次。改成 SQLAlchemy 2.0 的 selectinload 预加载,一次 IN 查询搞定:

from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession

async def search_products(db: AsyncSession, keyword: str, limit: int = 20):
    stmt = (
        select(Product)
        .where(Product.status == 1, Product.name.ilike(f"%{keyword}%"))
        .options(
            selectinload(Product.skus),
            selectinload(Product.category),
        )
        .order_by(Product.sort_weight.desc())
        .limit(limit)
    )
    result = await db.execute(stmt)
    return result.scalars().unique().all()

查询次数从 61 次降到 3 次(商品、SKU、分类各一次 IN 查询)。

索引这块,ilike '%keyword%' 前置通配符走不了 B-Tree,我们加了 pg_trgm 扩展建 GIN 索引:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_product_name_trgm
ON product (name gin_trgm_ops);
-- 复合索引,覆盖过滤+排序
CREATE INDEX CONCURRENTLY idx_product_status_weight
ON product (status, sort_weight DESC) INCLUDE (name, category_id);

注意 CONCURRENTLY,生产环境不加这个锁表能锁到你怀疑人生,我第一次就差点把线上搞挂。

4.3 缓存策略:Redis 两级缓存

不是所有数据都值得缓存。我们的策略是:

  • 商品详情:热点数据,TTL 300s,主动失效
  • 搜索结果:按 keyword + 分页做 key,TTL 60s(避免脏数据太久)
  • 分类/品牌字典:几乎不变,TTL 3600s

用 cache-aside 模式,读的时候先查 Redis,miss 了查库再回填:

import json
import hashlib
from redis.asyncio import Redis

redis: Redis = None  # 启动时初始化

async def get_product_detail(db: AsyncSession, product_id: int) -> dict:
    cache_key = f"product:detail:{product_id}"
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    product = await db.get(Product, product_id, options=[selectinload(Product.skus)])
    if not product:
        return None

    data = serialize_product(product)
    await redis.setex(cache_key, 300, json.dumps(data, ensure_ascii=False))
    return data

写操作时删缓存而不是更新缓存(避免并发写覆盖),并且加了个简单的防击穿:缓存 miss 时用 SET NX 抢一个短锁,抢不到就等 50ms 重试,最多 3 次。

4.4 同步阻塞:Flask 那层的坑

FastAPI 层是 async 的,但 Flask 层 Gunicorn 用的是 sync worker,4 个 worker 意味着并发上限就 4,一旦有慢请求全堵死。我们先把 Flask 侧的慢查询按上面同样方式改掉,然后把 Gunicorn 的 worker 换成 gthread,--threads 8,同时把 --timeout 从 30 调到 60:

gunicorn -w 4 -k gthread --threads 8 --timeout 60 \
  --max-requests 2000 --max-requests-jitter 200 \
  -b 0.0.0.0:8000 app:application

--max-requests 那个是为了防内存泄漏,跑久了 Python 进程 RSS 会涨,定期回收。

五、踩坑与优化

说几个真踩过的坑,都是血泪:

坑一:selectinload 和 limit 一起用。selectinload 是二次查询,如果你在子查询里也加了 limit,行为会很怪。我们一开始对 skus 加了 limit(5),结果 SQLAlchemy 直接把 limit 应用到了整体,返回数据错乱。正确做法是用 with_loader_criteria 或者干脆在应用层截断。

坑二:缓存序列化开销。一开始用 json.dumps,商品对象有几十个字段,序列化耗时占了接口时间的 15%。后来换 orjson,同样的数据快了约 3 倍,pip install orjson 就完事。

坑三:Redis 连接池没配。默认连接池大小不够,压测到 800 QPS 时开始报 ConnectionError: Too many connections。改成 max_connections=200,socket_timeout=1,问题消失。

坑四:索引建了但没用上。GIN trgm 索引建完,EXPLAIN ANALYZE 一看还是 Seq Scan。原因是 ilike 的 pattern 参数是运行时拼的,planner 估不准。最后在查询里显式 SET LOCAL enable_seqscan = off 做了对比验证,确认索引生效后就没再动,实际是统计信息过期,ANALYZE product 之后 planner 就正常走了。

六、效果数据

Locust 压测脚本:100 并发用户,spawn rate 10/s,持续 5 分钟,请求体就是随机 keyword 搜商品列表。

指标 优化前 优化后
P50 780ms 62ms
P95 1.9s 140ms
P99 2.3s 180ms
QPS 120 1100
错误率 0.3% 0%
DB CPU 90% 25%
单请求 SQL 数 61 3

分项贡献大概是这样:N+1 修复贡献了约 55% 的延迟下降,索引贡献 20%,缓存贡献 20%,Gunicorn 线程模型调整贡献 5%。缓存命中率稳定在 87% 左右,符合预期。

七、总结

这次调优最大的体会是:别猜,去测。我一开始凭直觉以为是网络或者框架开销,差点去折腾 uvicorn 的 worker 数,结果 py-spy 一采样,问题明明白白写在 ORM 里。

几个可以复用的经验:

  1. py-spy 在线采样对生产零侵入,是定位 Python 服务性能问题的第一选择。
  2. N+1 是 ORM 项目最常见的性能杀手,selectinload / joinedload 要成为肌肉记忆。
  3. 索引不是建了就行,EXPLAIN ANALYZE 验证 + ANALYZE 更新统计信息缺一不可。
  4. 缓存要设计失效策略和防击穿,不然就是给自己埋雷。
  5. 压测一定要用同一套脚本、同样的并发模型,否则数据没有可比性。

目前这套方案跑了两个月,P99 稳定在 200ms 以内。下一步打算把搜索结果缓存改成布隆过滤器预判 + 本地 LRU 两级,进一步压 Redis 的 RT。有进展再写一篇。