1. 问题背景:一个接口拖垮了整个服务

上线第三天,运维同事丢给我一张Grafana截图:/api/v1/orders/summary 接口在晚高峰的P99延迟飙到2.1秒,CPU使用率直接打满4核。这个接口的作用是:根据用户传入的时间范围,聚合12张按月分表的订单数据,计算金额总和、订单数、客单价等指标。

第一反应是“数据量太大”?查了下,单表才80万行,总量不到1000万。第二反应是“代码写得烂”?打开自己两星期前写的代码,脸有点红——用的是SQLAlchemy ORM,先查出一个月的订单列表,再在Python里做过滤和聚合。

环境版本:
- Python 3.10.8
- FastAPI 0.95.2 + Uvicorn 0.21.1
- SQLAlchemy 2.0.15(ORM模式)
- PostgreSQL 14.5(16核CPU / 64GB内存)
- Redis 7.0(单机,默认配置)

2. 第一轮Profile:cProfile显示90%时间在ORM映射

我习惯先跑一个简单的压测脚本(locustab),但更重要的是拿到CPU热点。用 cProfile 直接跑一次真实请求:

python -m cProfile -s cumtime -o profile.out main.py
# 然后单独触发一次请求,最后用 pstats 分析

结果让我意外:SQLAlchemy的 Row 对象映射占了总耗时的63%,其次是 Session.commit() 占了15%。 也就是说,数据库本身只花了不到20%的时间,剩下的全被ORM的Python层吃掉了。

具体来说,我用了这样的代码(简化版):

# 问题代码:ORM逐行映射 + Python层聚合
from sqlalchemy.orm import Session
from models import Order

def get_orders_summary(start: str, end: str):
    total_amount = 0.0
    total_count = 0
    for month in month_list(start, end):
        rows = session.query(Order).filter(
            Order.created_at >= start,
            Order.created_at <= end
        ).all()  # 这里会加载所有字段到内存
        for row in rows:
            if row.status == "paid":  # 业务过滤
                total_amount += row.amount
                total_count += 1
    return {"amount": total_amount, "count": total_count}

问题很明确:
- .all() 一次性拉取所有行,内存暴涨(单月80万行 × 12个月 = 960万行)。
- 每个 Order 对象都要做Python类实例化,慢。
- 过滤逻辑(status == "paid")完全可以下推到SQL里。

3. 第一轮优化:原生SQL + fetchall() + 下推过滤

方案设计:
- 用 text() 写原生SQL,只SELECT需要的列(amount, status),把过滤条件直接写进 WHERE
- 用 fetchall() 拿元组,避免ORM映射。
- 聚合逻辑用 SUM(CASE WHEN ...) 直接在数据库端完成,Python只拿最终数字。

# 优化后:原生SQL + 数据库端聚合
from sqlalchemy import text
from database import engine

def get_orders_summary(start: str, end: str):
    sql = text("""
        SELECT 
            COUNT(*) AS total_count,
            SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount,
            SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_count
        FROM orders
        WHERE created_at BETWEEN :start AND :end
    """)
    with engine.connect() as conn:
        result = conn.execute(sql, {"start": start, "end": end}).fetchone()
    return {"amount": result.paid_amount, "count": result.paid_count}

压测数据(ab -n 10000 -c 50):

版本 QPS P99延迟 内存峰值
ORM版 180 2.1s 1.2GB
原生SQL版 520 680ms 380MB

提升明显,但还不够。QPS 520对于生产环境来说还是偏低,特别是这个接口是首页核心数据,每秒钟可能有几百次请求。

4. 第二轮优化:Redis缓存 + 缓存穿透防护

观察业务特性:这个接口的数据是分钟级更新的(订单量变化不敏感),完全可以加缓存。我用了Redis,key设计为 orders_summary:{start_date}:{end_date},TTL设60秒。

核心实现:

import json
import redis
from fastapi import APIRouter

router = APIRouter()
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)

CACHE_TTL = 60  # 秒

@router.get("/api/v1/orders/summary")
def summary(start: str, end: str):
    cache_key = f"orders_summary:{start}:{end}"
    # 先查缓存
    cached = cache.get(cache_key)
    if cached:
        return json.loads(cached)

    # 查数据库(此时用原生SQL版)
    data = get_orders_summary(start, end)

    # 写入缓存
    cache.setex(cache_key, CACHE_TTL, json.dumps(data))
    return data

踩坑1:缓存穿透
我一开始没加空值缓存。如果用户查一个没有数据的时间段(比如未来日期),每次都会打到数据库。后来改成:即使 data 为空,也缓存一个 {"amount":0,"count":0},TTL缩短到30秒。

踩坑2:缓存雪崩
所有key的TTL都是60秒,如果同一时间大量请求触发缓存重建,数据库还是会崩。我引入了 random.uniform(0.5, 1.5) 乘以基础TTL,让过期时间分散。

压测数据(加了Redis缓存 + 4个Gunicorn Worker):

启动命令:

gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120
版本 QPS P99延迟 数据库QPS
原生SQL版 520 680ms 520
+Redis缓存 1450 220ms 约30(只有缓存失效时才打到DB)
+4 Worker 1630 180ms 约30

5. 踩坑总结与最终效果

几个值得说的问题:

  1. ab 压测的并发数要合理。 我一开始用 -c 200,发现QPS反而不如 -c 50,原因是GIL竞争和连接池不够用。最后确定 -c 50 是最佳点,同时把PostgreSQL连接池从默认的10调到50。

  2. Uvicorn单进程是瓶颈。 单Worker下QPS卡在800左右,CPU只能用到1核。换Gunicorn + 4 Worker后,QPS翻倍。注意:如果用了SQLite这种文件型数据库,多Worker会锁冲突,但PostgreSQL没问题。

  3. Redis连接池要复用。 我第一次用 redis.Redis() 每次请求都新建连接,导致TCP握手开销,后来改成全局单例 + 连接池(max_connections=20)。

最终Grafana监控数据(线上运行24小时):
- 平均QPS:1350(高峰期)
- P99延迟:180ms(之前2.1s)
- CPU使用率:从95%降到40%(4核)
- 数据库慢查询:从每小时200+降到0(缓存生效)

最后效果对比:

指标 优化前 优化后 提升
QPS 180 1630 9.1倍
P99延迟 2.1s 180ms 11.7倍
DB负载 100% 约3% -

6. 小结

这次调优的核心就三句话:ORM不背锅,但别在Python里做数据库该干的事;缓存是王道,但要防穿透和雪崩;Uvicorn单进程跑不满CPU,上Gunicorn。

如果你也遇到类似问题,建议先别急着换框架(FastAPI本身没问题),按这个顺序排查:
1. cProfile 定位CPU热点(是不是ORM映射?)。
2. 下推过滤和聚合到SQL。
3. 加缓存(Redis TTL 60s + 随机过期)。
4. 多Worker部署。

代码全部放在GitHub(链接略),欢迎拍砖。有问题评论区聊,我基本每两天看一次。