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映射
我习惯先跑一个简单的压测脚本(locust 或 ab),但更重要的是拿到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. 踩坑总结与最终效果
几个值得说的问题:
-
ab压测的并发数要合理。 我一开始用-c 200,发现QPS反而不如-c 50,原因是GIL竞争和连接池不够用。最后确定-c 50是最佳点,同时把PostgreSQL连接池从默认的10调到50。 -
Uvicorn单进程是瓶颈。 单Worker下QPS卡在800左右,CPU只能用到1核。换Gunicorn + 4 Worker后,QPS翻倍。注意:如果用了SQLite这种文件型数据库,多Worker会锁冲突,但PostgreSQL没问题。
-
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(链接略),欢迎拍砖。有问题评论区聊,我基本每两天看一次。