一、问题背景:一个“简单”订单接口为何拖垮整个服务?

上周三上线了一个订单列表API,功能很简单:根据用户ID查询最近30天订单,返回商品名称、价格和状态。测试环境一切正常,但压测环境一跑,监控面板立刻亮红灯——P95延迟2.3秒,错误率飙到8%,数据库连接池被打满。

我第一反应是“又是慢SQL”,但查了慢查询日志发现并没有特别离谱的语句。用top看CPU,发现Python进程占用高达380%,这不对劲。于是我开始系统性地定位瓶颈。

二、环境与版本:调优前的“家底”

先交代一下生产环境配置,避免大家踩同样的坑:

  • Python 3.11.5(注意:3.11的字典和JSON序列化比3.10快约20%,但仍有优化空间)
  • FastAPI 0.104.1(Uvicorn 0.24.0,单Worker)
  • Flask 3.0.0(Gunicorn 21.2.0,3个Worker)
  • PostgreSQL 15.3(单机,32GB内存,SSD)
  • Redis 7.2(单实例,用于缓存)
  • 压测工具:wrk 4.2.0,-t4 -c100 -d30s(4线程,100并发,持续30秒)
  • ORM:SQLAlchemy 2.0.21(async模式)

调优前基线数据(同一台压测机,同一数据集):

指标 FastAPI Flask
QPS 152 98
P95延迟 2.3s 3.1s
CPU占用 380% 290%

三、Profiling定位:别猜,用工具说话

我用了两个工具:py-spy(无侵入式,适合生产环境)和cProfile(本地详细分析)。

第一步:py-spy dump 抓取CPU占用高的线程:

# 找到进程ID
$ py-spy top --pid 12345 --duration 10

输出片段:

  %Own   %Total  OwnTime  TotalTime  Function
  45.2%  45.2%   4.5s     4.5s      json.dumps  (json/encoder.py:199)
  32.1%  77.3%   3.2s     3.2s      sqlalchemy.orm.session.execute (session.py:171)
  12.4%  89.7%   1.2s     1.2s      asyncpg.connect (protocol.py:84)

看到没?JSON序列化占45%,SQLAlchemy执行占32%。这解释了我的疑问——不是单纯慢SQL,而是序列化和ORM开销过大。

第二步:cProfile 定位具体函数,代码里跑一下:

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

client = TestClient(app)

with cProfile.Profile() as pr:
    for _ in range(100):
        client.get("/orders?user_id=123")
    stats = pstats.Stats(pr)
    stats.sort_stats("cumulative").print_stats(20)

关键输出:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
100    0.002    0.000    12.345  0.123  app/api/orders.py:15(get_orders)
100    0.001    0.000    8.234   0.082  app/services/order_service.py:22(fetch_orders)
1200   0.003    0.000    7.891   0.006  app/repositories/order_repo.py:45(_query_orders)
1200   0.001    0.000    6.234   0.005  sqlalchemy/orm/loading.py:198(load_on_ident)

核心发现fetch_orders内部有1200次SQL查询——用户查了100次,每次返回30个订单,每个订单又查商品表(N+1问题)。加上SQLAlchemy默认的lazy="select",每个订单商品都要单独发一条SQL。

四、方案设计:三步走

  1. 优化ORM查询:用selectinload一次性加载关联表,把N+1变成两次SQL。
  2. 加Redis缓存:热点用户(活跃买家)的订单列表缓存30秒,减少数据库压力。
  3. 调整部署方式:FastAPI用Gunicorn+Uvicorn多Worker,Flask确保Gunicorn配置正确。

五、核心实现:代码与配置

5.1 SQLAlchemy查询优化

优化前(罪魁祸首)

# order_repo.py
async def fetch_orders(user_id: int):
    async with async_session() as session:
        result = await session.execute(
            select(Order).where(Order.user_id == user_id)
        )
        orders = result.scalars().all()
        # 这里每个order都会触发一次商品查询!
        for order in orders:
            await session.refresh(order, ["items"])
        return orders

优化后

# order_repo.py
from sqlalchemy.orm import selectinload

async def fetch_orders_optimized(user_id: int):
    async with async_session() as session:
        stmt = (
            select(Order)
            .where(Order.user_id == user_id)
            .options(
                selectinload(Order.items),  # 一次性join加载
                selectinload(Order.items).joinedload(Item.product)  # 如果还有嵌套
            )
            .limit(30)  # 明确限制数量
        )
        result = await session.execute(stmt)
        return result.scalars().unique().all()

为什么快selectinload会生成WHERE order_id IN (...)的查询,把30次查询合并为1次批量查询。数据库往返次数从31次降为2次。

5.2 Redis缓存策略

实现(FastAPI和Flask通用思路):

# cache_service.py
import json
import redis.asyncio as aioredis
from fastapi import Request

redis_client = aioredis.from_url(
    "redis://localhost:6379",
    encoding="utf-8",
    decode_responses=True,
    max_connections=20
)

async def get_cached_orders(user_id: int):
    cache_key = f"orders:{user_id}"
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    orders = await fetch_orders_optimized(user_id)
    # 只缓存热点用户(比如最近7天有订单的用户)
    await redis_client.setex(cache_key, 30, json.dumps(orders))
    return orders

踩坑:千万别缓存所有用户!冷用户的数据会浪费内存,且缓存命中率低。我加了个逻辑:只有该用户近7天有订单才缓存,命中率从12%提升到67%。

5.3 部署配置对比

FastAPI(Gunicorn + Uvicorn Worker)

gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000 --timeout 60 --max-requests 1000 \
  --max-requests-jitter 100 --worker-connections 1000

Flask(Gunicorn同步Worker + 关闭debug)

gunicorn app:app -w 4 --bind 0.0.0.0:5000 \
  --worker-class sync --timeout 60 --max-requests 2000

关键参数解释
- -w 4:4个Worker进程,充分利用多核CPU(压测机是4核)。
- --max-requests:每个Worker处理1000个请求后重启,防止内存泄漏(Python的长期运行内存增长问题)。
- --worker-connections:Uvicorn异步Worker的并发连接数。

六、踩坑与二次优化

坑1:Redis连接池打爆。加了缓存后,压测一开始Redis就报Connection pool exhausted。原因是我没设置max_connections,默认只有10。调大后解决。

坑2:JSON序列化还是慢。即使数据库查询优化了,json.dumps依然占CPU约25%。我改用orjson(用Rust编写的JSON库,比标准库快3-5倍):

import orjson

def serialize_orders(orders):
    # 直接返回bytes,FastAPI会自动处理
    return orjson.dumps(
        [{"id": o.id, "items": [i.name for i in o.items]} for o in orders]
    )

坑3:Flask的同步Worker在长查询时阻塞。虽然Gunicorn有4个Worker,但每个Worker是单线程的,如果查询耗时1秒,该Worker就卡住。解决方法是把Flask的SQLAlchemy改为异步(或加gevent worker),但我用了个取巧办法:把耗时操作丢给线程池

from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=8)

@app.route("/orders")
def get_orders():
    user_id = request.args.get("user_id")
    future = executor.submit(fetch_orders_blocking, user_id)
    orders = future.result(timeout=5)
    return jsonify(orders)

七、效果数据:调优前后对比

压测条件:wrk -t4 -c100 -d30s,模拟100个并发用户,每个用户查询最近30天订单。

指标 FastAPI调优前 FastAPI调优后 Flask调优前 Flask调优后
QPS 152 748 98 412
P95延迟 2.3s 183ms 3.1s 520ms
平均延迟 1.1s 95ms 1.8s 210ms
数据库查询次数/请求 31 2 31 2
Redis缓存命中率 0% 67% 0% 61%
CPU占用(峰值) 380% 220% 290% 180%

额外观察
- FastAPI调优后QPS提升392%,P95延迟下降92%
- Flask调优后QPS提升320%,但P95延迟仍有520ms——为什么?因为Flask的同步模型在100并发下,4个Worker各处理25个请求,每个请求如果涉及IO等待(Redis/DB),Worker就被占用。后来我试了gevent worker,延迟降到220ms,但稳定性不如Uvicorn。

八、总结与建议

这次调优让我深刻体会到:性能瓶颈往往是“组合拳”而不是单一原因。我一开始只盯着SQL,结果发现JSON序列化占了大头。建议各位开发者:

  1. 先用py-spy抓现场,别猜。生产环境用py-spy dump看热函数,开发环境用cProfile
  2. ORM的N+1查询是默认陷阱,所有关联对象都要显式用selectinloadjoinedload
  3. 缓存不是银弹,只缓存热点数据,否则内存和Redis连接都是瓶颈。
  4. 部署方式影响巨大:FastAPI必须用Uvicorn的异步Worker,Flask用Gevent或异步化改造,否则多Worker也扛不住IO密集。

最后,调优后别忘了压测回归——我调优完第二天,业务方说要加一个“导出Excel”功能,差点又崩了。下一篇文章我会分享大文件下载API的流式传输优化,敬请关注。

(完)