一、问题背景:一个“看起来没问题”的接口

上个月接手了一个FastAPI写的订单统计服务,核心接口/api/v1/orders/summary,逻辑不复杂:按天聚合订单金额、数量,外加TOP10商品。但压测时发现一个诡异现象:并发从50涨到300,延迟不是线性增长,而是直接“崩”了——P99从300ms飙到2.2s,数据库CPU 100%。

最坑的是单测、联调环境都正常,一上生产(16核32G,MySQL 8.0.28,Redis 6.2)就现原形。拿生产流量回放,用py-spy抓了3秒的堆栈,发现三分之二的调用栈卡在sqlalchemylazy load上,另外三分之一浪费在json.dumps上。

当时的环境版本:FastAPI 0.95.1,SQLAlchemy 2.0.15,Python 3.10.11,Uvicorn 0.21.1(workers=4)。

二、第一步:用Profiling定位瓶颈,别靠猜

很多同学一上来就加缓存、换索引,这是典型的“拍脑袋优化”。我习惯先用两把尺子量:

  1. py-spy(采样式profiler):不改代码,直接看生产进程的实时调用栈。安装:pip install py-spy,采样命令:
sudo py-spy record -o profile.svg --pid 12345 --duration 5

生成火焰图,一眼就能看出热点函数。

  1. cProfile(函数级profiler):适合单请求分析。在FastAPI里加个中间件,对指定路径做profiling:
# middleware_profiler.py
import cProfile
import io
import pstats
from fastapi import Request

async def profiler_middleware(request: Request, call_next):
    if request.url.path == "/api/v1/orders/summary":
        profiler = cProfile.Profile()
        profiler.enable()
        response = await call_next(request)
        profiler.disable()
        s = io.StringIO()
        ps = pstats.Stats(profiler, stream=s).sort_stats("cumulative")
        ps.print_stats(30)  # 只看累计耗时前30的函数
        with open("profile.log", "a") as f:
            f.write(f"\n[{request.client.host}] {s.getvalue()}")
        return response
    return await call_next(request)

关键发现(截取profile.log部分输出):

ncalls  tottime  percall  cumtime  percall  filename:lineno
   5000    0.892    0.000   12.340    0.002  sqlalchemy/orm/loading.py:93
  15000    1.231    0.000   11.892    0.001  sqlalchemy/orm/relationships.py:224 (lazy load)
   5000    0.211    0.000    8.456    0.002  order_service.py:76 (build_summary)
   5000    3.120    0.001    3.120    0.001  json/encoder.py:179 (default)

看到了吗?15,000次lazy load,耗时11.8秒(占总时长52%)。这就是典型的N+1查询问题:我查了5000个订单,然后再为每个订单查一次商品明细。

三、数据库查询优化:干掉N+1,用窗口函数重写

原代码(伪代码)大概是这样的:

# 原始实现 - 存在N+1问题
async def build_summary(db: Session):
    orders = db.query(Order).filter(Order.created_at >= today_start).all()  # 1次查询
    total_amount = 0
    top_products = []
    for order in orders:  # N次循环
        for item in order.items:  # 每个order再触发一次查询 -> N+1问题
            total_amount += item.price * item.quantity
            top_products.append(...)
    return {"total_amount": total_amount, "top_products": top_products[:10]}

优化方案分两步:

第一步:干掉N+1。使用selectinload显式加载关系,或者干脆用聚合查询一把梭。我选择了后者,因为统计逻辑不需要ORM实体,只要标量值。

第二步:数据库端聚合。用SQL窗口函数ROW_NUMBER()取每个商品的总销售额排名,避免在Python里排序:

```python

optimized_query.py

from sqlalchemy import func, text

async def build_summary_optimized(db: Session):
# 1. 总金额和数量 - 单次聚合查询
total_result = db.execute(
text("""
SELECT
COALESCE(SUM(oi.price * oi.quantity), 0) AS total_amount,
COUNT(DISTINCT o.id) AS order_count
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= :start_ts AND o.status != 'cancelled'
"""),
{"start_ts": today_start}
).one()

# 2. TOP10商品 - 窗口函数,避免Python端排序
top_products = db.execute(
    text("""
        SELECT * FROM (
            SELECT 
                p.id, p.name,
                SUM(oi.quantity) AS total_qty,
                ROW_NUMBER() OVER (ORDER BY SUM(oi.price * oi.quantity) DESC) AS rn
            FROM order_items oi
            JOIN products p ON p.id = oi.product_id
            JOIN orders o ON o.id = oi.order_id
            WHERE o.created_at >= :start_ts AND o.status != 'cancelled'
            GROUP BY p.id, p.name
        ) t WHERE rn  400ms)
  • Redis缓存:25%(400ms -> 100ms)
  • Gzip压缩 + 精简字段:10%(100ms -> 90ms)
  • 连接池/JSON序列化tuning:5%

总结与教训

  1. 永远先Profile再优化。我见过太多人上来就加Redis,结果瓶颈在数据库索引,缓存根本没用。推荐py-spy抓生产现场,cProfile分析单请求。

  2. ORM是双刃剑。SQLAlchemy的lazy load非常隐蔽,生产环境务必加lazy="selectin"或直接用原生SQL做聚合。建议在开发环境开启echo=True,观察每次请求实际执行的SQL条数。

  3. 缓存不是银弹。要先优化数据库查询,再考虑加缓存。否则缓存穿透/击穿会让你更痛苦。

  4. 压测环境要贴近生产。本地8G内存的MySQL和生产的MySQL 8.0表现完全不同,建议用tc模拟网络延迟、用wrk打真实并发。

这次优化的核心思路就一句话:减少不必要的计算和网络传输。数据库少查5000次,网络少传36KB,延迟自然就下来了。你的项目里如果也有“莫名其妙慢”的接口,不妨先抓个火焰图看看。

以上,抛砖引玉,欢迎评论区交流你的优化经验。


参考配置
- 压测工具:wrk 4.2.0,参数 -t8 -c300 -d30s
- 监控:py-spy 0.3.14cProfile(Python 3.10内置)
- 数据库:MySQL 8.0.28,innodb_buffer_pool_size=8G
- 缓存:Redis 6.2.6,maxmemory 2Gmaxmemory-policy allkeys-lru