一、问题背景:一个“简单”接口为什么这么慢
事情是这样的,上周我们订单系统做压测,有个GET /api/v1/orders?user_id=xxx的接口,逻辑就是查订单表关联用户和商品信息,总共就三个表。结果压测报告一出来,我人傻了:P95延迟2300ms,QPS只有380,数据库CPU才40%,这明显不是硬件瓶颈。
用wrk单机压测,命令很简单:
wrk -t8 -c200 -d30s --latency http://localhost:8000/api/v1/orders?user_id=12345
结果:
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.98s 1.21s 3.54s 68.00%
Req/Sec 47.62 12.34 111.00 72.00%
这个接口的逻辑本身不复杂,就是一个三表联查。我当时第一反应是“是不是SQL写烂了”,但看了下SQLAlchemy的日志,SQL执行时间只有80ms,那问题出在哪?
二、环境与版本:先交代清楚技术栈
- Python 3.11.5(注意不是3.10,后面有个坑跟这个有关)
- FastAPI 0.104.1 + Uvicorn 0.24.0(worker=4,每个worker配了
--limit-concurrency 1024) - SQLAlchemy 2.0.23 + asyncpg 0.29.0(异步驱动)
- Redis 7.0.12(Cluster模式,3主3从)
- PostgreSQL 15.3(max_connections=200)
- 压测工具:wrk 4.2.0 + 一台4核8G的云主机(跟应用服务器同规格)
生产环境是K8s里跑的,4个Pod副本。但压测时为了排除网络因素,直接在宿主机上跑应用。
三、方案设计:先用Profiling把锅甩明白
3.1 第一板斧:cProfile定位CPU热点
用cProfile跑一次单请求,输出排序后前10行:
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): # 跑100次取平均
client.get("/api/v1/orders?user_id=12345")
pr.disable()
stats = pstats.Stats(pr)
stats.sort_stats('cumulative').print_stats(20)
关键输出(已脱敏):
ncalls tottime percall cumtime percall filename:lineno
100 0.024 0.000 2.301 0.023 /app/routers/orders.py:38 (get_orders)
100 0.012 0.000 1.892 0.019 /app/services/order_service.py:55 (fetch_orders)
300 0.045 0.000 1.451 0.005 sqlalchemy/orm/loading.py:98 (load_on_identity)
100 0.008 0.000 0.892 0.009 sqlalchemy/orm/strategies.py:402 (load_collection)
100 0.006 0.000 0.451 0.005 asyncpg/protocol/protocol.pyx:231 (bind_execute)
看到没!load_on_identity和load_collection占了1.45 + 0.89 = 2.34s,这俩是SQLAlchemy ORM在懒加载关联对象。说明代码里用了relationship懒加载,每个订单去查一次用户和商品,典型的N+1查询。
3.2 第二板斧:Flask-Profiler看慢查询(虽然用的是FastAPI)
虽然项目是FastAPI,但我习惯加个flask-profiler做辅助(别问为什么,老项目迁移过来的)。配置如下:
# middleware.py
import flask_profiler
from flask import Flask
flask_app = Flask(__name__)
flask_app.config["flask_profiler"] = {
"storage": {
"engine": "sqlalchemy",
"db_url": "postgresql+psycopg2://user:pass@localhost/profiler_db"
},
"basicAuth": ("admin", "admin123"),
"ignore": ["^/static/.*"],
}
flask_profiler.init_app(flask_app)
虽然丑,但能直观看到每个SQL的耗时分布。果然,SELECT * FROM order_items WHERE order_id = ?这类语句执行了300次(100个订单*3个关联表),单次虽然只要0.3ms,但300次累积就爆炸。
3.3 解决方案:联合索引 + selectinload + Redis缓存
定位到问题后,我设计了三个方向的优化:
- 数据库层:给
order_items表加(order_id, product_id)联合索引,避免回表 - ORM层:用
selectinload一次性加载所有关联对象,消灭N+1 - 缓存层:对用户维度的订单列表加Redis缓存,TTL=60s,用
Cache-Aside模式
四、核心实现:代码改造全记录
4.1 SQLAlchemy 2.0的selectinload优化
改造前(错误示范):
# 老的:懒加载,每个order都会触发一次数据库查询
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()
# 这里访问orders[0].items会触发SELECT ... WHERE order_id = ?
return orders
改造后:
# 新的:用selectinload一次性加载关联
from sqlalchemy.orm import selectinload
async def fetch_orders_optimized(user_id: int):
async with async_session() as session:
result = await session.execute(
select(Order)
.options(
selectinload(Order.user),
selectinload(Order.items).selectinload(OrderItem.product)
)
.where(Order.user_id == user_id)
)
orders = result.scalars().unique().all() # 注意要加unique()
return orders
这里有个大坑:scalars().all()在selectinload场景下必须加.unique(),否则会报InvalidRequestError: The unique() method must be invoked on this Result。原因是join出来的行数会膨胀,必须去重。
4.2 Redis缓存层
import json
import redis.asyncio as aioredis
from fastapi import Depends
redis_client = aioredis.from_url(
"redis://redis-cluster:6379/0",
encoding="utf-8",
decode_responses=True,
socket_connect_timeout=3,
socket_timeout=3,
retry_on_timeout=True,
max_connections=100 # 注意这个,后面踩坑了
)
async def get_orders_cached(user_id: int):
cache_key = f"user_orders:{user_id}:v2" # v2版本号,防止旧缓存数据
# Cache-Aside: 先查Redis
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,查数据库
orders = await fetch_orders_optimized(user_id)
# 序列化并写入缓存,TTL 60秒
serialized = json.dumps([order.to_dict() for order in orders], default=str)
await redis_client.setex(cache_key, 60, serialized)
return orders
注意json.dumps里的default=str,因为订单时间字段是datetime,默认json序列化会报错。
4.3 FastAPI路由改造
from fastapi import APIRouter, Query
router = APIRouter(prefix="/api/v1")
@router.get("/orders")
async def get_orders(
user_id: int = Query(..., ge=1),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""优化的订单列表接口"""
# 分页参数塞进缓存key,避免不同页互相干扰
cache_key = f"user_orders:{user_id}:{page}:{page_size}:v2"
cached = await redis_client.get(cache_key)
if cached:
return JSONResponse(content=json.loads(cached), headers={"X-Cache": "HIT"})
orders = await fetch_orders_optimized(user_id, page, page_size)
data = {
"code": 0,
"data": [order.to_dict() for order in orders],
"total": len(orders) # 实际应该count,这里简化
}
await redis_client.setex(cache_key, 60, json.dumps(data, default=str))
return JSONResponse(content=data, headers={"X-Cache": "MISS"})
五、踩坑与优化:你们可能也会遇到
5.1 连接池爆了
第一次加Redis缓存后,压测直接报redis.exceptions.ConnectionPoolError: Connection pool exhausted。看日志发现max_connections=100,但FastAPI的异步事件循环里,100个连接瞬间被200个并发请求抢光了。
解决:把max_connections调到500,同时加信号量限制:
import asyncio
_semaphore = asyncio.Semaphore(50) # 控制并发Redis操作
async def safe_redis_get(key):
async with _semaphore:
return await redis_client.get(key)
5.2 PostgreSQL索引没生效
加了联合索引后,用EXPLAIN ANALYZE看执行计划:
Seq Scan on order_items (cost=0.00..1234.56 rows=300 width=24)
索引没生效!原因是我在OrderItem.order_id上建了普通索引,但查询条件是order_id IN (...),需要用多列索引:
CREATE INDEX CONCURRENTLY idx_order_items_order_product
ON order_items(order_id, product_id);
注意CONCURRENTLY,线上环境建索引不能锁表。
5.3 Python 3.11的坑
json.dumps对datetime对象序列化,在Python 3.11里default=str会把所有字段转字符串,包括数字。导致前端拿到"total": "100"这种字符串。解决:
def json_serializer(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
raise TypeError(f"Type {type(obj)} not serializable")
六、效果数据:对比压测结果
优化完成后再跑一次wrk,同一台机器、同样的参数:
优化前:
Latency Distribution
50% 1.92s
75% 2.10s
90% 2.25s
99% 2.78s
Requests/sec: 380.24
Transfer/sec: 2.3MB
优化后(数据库索引+selectinload):
Latency Distribution
50% 680ms
75% 720ms
90% 780ms
99% 1.02s
Requests/sec: 1450
优化后(+Redis缓存):
Latency Distribution
50% 145ms
75% 155ms
90% 170ms
99% 210ms
Requests/sec: 4200
数据库的慢查询日志从平均每5秒一条降到几乎为零。QPS从380到4200,提升了11倍;P95延迟从2.3s降到0.18s,提升了12.8倍。
七、总结
这次调优最大的感触是:性能问题别靠猜,先用profiler把数据拿出来。cProfile第一次跑完我就知道问题在ORM懒加载,而不是查询本身。另外缓存设计要注意缓存粒度,我们一开始把整个用户的所有订单缓存在一个key里,结果分页参数改了缓存全失效,后来改成user_id + page + page_size才解决问题。
最后贴一下优化后的完整依赖列表(requirements.txt关键部分):
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy[asyncio]==2.0.23
asyncpg==0.29.0
redis==5.0.1
flask-profiler==1.8.2 # 辅助监控用
如果你也在调优FastAPI接口,建议先跑cProfile,再查慢查询日志,最后才考虑加缓存。顺序反了容易白忙活。