1. 问题背景:一个“简单”的聚合接口为何这么慢?
事情是这样的,上周运维同事反馈说生产环境有个接口经常超时,我一看监控面板,好家伙——/api/v1/orders/summary 这个接口在100并发下P95耗时1180ms,数据库CPU直接飙到80%,再往上压就要雪崩了。
这个接口逻辑看起来很简单:根据用户ID查询订单列表,然后按状态分组统计数量。数据库表也就几十万行,按理说不该这么慢。
环境说明:
- Python 3.11.2
- FastAPI 0.104.1 + Uvicorn 0.24.0
- SQLAlchemy 2.0.23(ORM模式)
- PostgreSQL 14.5(单机,16核32G)
- Redis 7.0(单机)
2. 第一步:用Profiling工具找出“真凶”
很多同学遇到接口慢,第一反应是“加缓存”或者“加索引”,但我建议先做Profiling,别拍脑袋。
我用的是两板斧:
2.1 cProfile(函数级CPU耗时)
python -m cProfile -o profile.out -s cumulative your_server.py
然后写个小脚本触发接口:
import requests
import pstats
from cProfile import Profile
from pstats import SortKey
# 先启动服务,然后压测脚本请求接口
# 这里我们直接分析profile.out文件
p = pstats.Stats('profile.out')
p.strip_dirs().sort_stats(SortKey.CUMULATIVE).print_stats(20)
关键输出如下:
ncalls tottime percall cumtime percall filename:lineno(function)
100 0.003 0.000 0.982 0.010 /app/api/orders.py:45(get_summary)
100 0.002 0.000 0.861 0.009 /app/services/order_service.py:33(fetch_orders)
100 0.001 0.000 0.721 0.007 /app/models/order.py:56(load_related_items)
分析:load_related_items 这个函数占了大头。点进去一看,好家伙,这是个SQLAlchemy的relationship,默认是懒加载(lazy=True)。在循环里访问order.items,每次触发一条SELECT,这就是经典的N+1查询问题。
2.2 py-spy(线程级定位卡点)
有时候cProfile看不到网络IO等阻塞,我习惯再用py-spy抓一下:
# 安装
pip install py-spy
# 对运行中的进程采样30秒
py-spy record --pid 12345 -o py_spy.svg --duration 30
生成的火焰图显示,有55%的时间卡在psycopg2的socket_connect上。说明数据库连接池也出问题了——每次请求都新建连接,没有复用。
3. 第二步:数据库查询优化(SQL + ORM)
3.1 修复N+1查询
原代码(问题版):
# app/services/order_service.py
from sqlalchemy.orm import Session
def fetch_orders(db: Session, user_id: int):
orders = db.query(Order).filter(Order.user_id == user_id).all()
# 这里在循环里访问 relationship,触发N+1
result = []
for o in orders:
items = o.items # 这里每个order发一次SELECT
result.append({
'order_id': o.id,
'status': o.status,
'items': [{'sku': it.sku, 'qty': it.qty} for it in items]
})
return result
优化后:
# app/services/order_service.py
from sqlalchemy.orm import Session, joinedload
def fetch_orders_optimized(db: Session, user_id: int):
# 使用joinedload一次性join出items,避免N+1
orders = db.query(Order).options(
joinedload(Order.items)
).filter(Order.user_id == user_id).all()
return orders
3.2 添加联合索引
之前表里只有主键索引和user_id单列索引,查询条件WHERE user_id = ?其实已经走了索引,但排序和JOIN还是慢。加个联合索引:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_status
ON orders (user_id, status) INCLUDE (id, created_at);
这里我用了INCLUDE而不是普通多列索引,因为PostgreSQL 11+支持覆盖索引,对于只需要回表取极少字段的查询能省一次IO。
3.3 连接池配置
FastAPI + SQLAlchemy默认连接池参数太保守了(pool_size=5, max_overflow=10)。我压测时发现连接排队严重,调整如下:
# app/db.py
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
"postgresql://user:pass@localhost/db",
poolclass=QueuePool,
pool_size=20,
max_overflow=10,
pool_timeout=30,
pool_pre_ping=True, # 防止死连接
echo=False
)
注意:pool_pre_ping=True 很重要,否则MySQL/PostgreSQL空闲8小时断连后,连接池里的连接全部失效。
4. 第三步:Redis缓存策略(命中率85%)
数据库优化之后,P95从1180ms降到了340ms,但还是不够。因为这个接口是报表类,90%的请求查的都是最近一天的汇总数据,热点非常集中。
我的思路:按用户+日期做两级缓存,热点数据缓存60秒,冷数据缓存5秒。
# app/cache/order_cache.py
import json
import redis
from fastapi import Request
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
CACHE_TTL_HOT = 60 # 热点缓存60秒
CACHE_TTL_COLD = 5 # 冷数据5秒
def get_summary_cache(user_id: int, date_str: str):
"""先查Redis,命中直接返回"""
key = f"summary:{user_id}:{date_str}"
cached = redis_client.get(key)
if cached:
return json.loads(cached)
return None
def set_summary_cache(user_id: int, date_str: str, data: dict, is_hot: bool):
key = f"summary:{user_id}:{date_str}"
ttl = CACHE_TTL_HOT if is_hot else CACHE_TTL_COLD
redis_client.setex(key, ttl, json.dumps(data))
# 判断是否热点用户(比如最近1小时下过单的活跃用户)
def is_hot_user(user_id: int) -> bool:
# 这里用Redis的SETBIT或者HyperLogLog,简单起见用ZSET记录活跃度
return redis_client.zscore('active_users', str(user_id)) is not None
然后在FastAPI路由里接入:
# app/api/orders.py
from fastapi import APIRouter, Depends
from app.cache.order_cache import get_summary_cache, set_summary_cache, is_hot_user
router = APIRouter()
@router.get("/v1/orders/summary")
async def get_summary(user_id: int, date: str):
# 1. 查缓存
cached = get_summary_cache(user_id, date)
if cached:
return {"data": cached, "source": "cache"}
# 2. 查数据库
data = await query_db(user_id, date)
# 3. 写缓存
is_hot = is_hot_user(user_id)
set_summary_cache(user_id, date, data, is_hot)
return {"data": data, "source": "db"}
踩坑提醒:
一开始我用了cachetools.TTLCache做本地缓存,结果多进程下每个worker都有自己的缓存,命中率只有40%。改成Redis后统一了,命中率稳定在85%左右。
5. 压测数据对比:wrk + 真实流量回放
压测工具用wrk,100并发,持续60秒:
wrk -t8 -c100 -d60s --latency http://localhost:8000/v1/orders/summary?user_id=10001&date=2024-06-01
优化前(基线)
Requests/sec: 210
Latency Distribution
50% 610ms
75% 890ms
90% 1040ms
95% 1180ms
99% 1450ms
优化后(DB优化 + Redis缓存)
Requests/sec: 2400
Latency Distribution
50% 45ms
75% 68ms
90% 82ms
95% 90ms
99% 120ms
数据库监控:
- 优化前:CPU 80%,慢查询日志每条平均200ms
- 优化后:CPU 12%,慢查询日志消失了(>100ms的查询为0)
缓存命中率:85%(监控面板显示hit_rate=0.85)
6. 总结与踩坑清单
这次调优下来,核心收益是P95从1180ms降到90ms,QPS提升11倍。整理几个关键点:
- Profiling先行:别猜,用cProfile/py-spy看数据。这次如果不做Profile,大概率会去加索引或者换ORM,根本解决不了N+1问题。
- ORM的懒加载是隐形杀手:SQLAlchemy的
lazy=True在循环里访问关系属性,会产生大量单条SELECT。用joinedload或者selectinload根治。 - 连接池一定要调:默认参数只适合开发环境。生产环境需要根据并发量调整
pool_size和max_overflow。 - 缓存要分级:热点数据TTL长一点,冷数据短一点。用Redis统一缓存,别用本地缓存搞多进程不一致。
- 压测要固定请求分布:这次压测我用了真实用户的user_id分布,而不是随机数,否则缓存命中率会虚高。
最后说一句:性能调优不是玄学,是科学。每一步都要有数据支撑,改完一定要回归压测。如果你也在调类似服务,欢迎留言交流细节。
(完)