1. 事情是这样开始的
上周运维丢给我一份压测报告:订单查询接口GET /api/v1/orders/{order_id},并发100时P95延迟502ms,QPS只有382。这个接口是给C端小程序用的,用户下单后要查订单状态,平均每单会请求3-5次。按这个性能,双十一流量一来基本就雪崩了。
我第一反应是:FastAPI + uvicorn多进程部署,CPU也没打满(4核只用了60%),那问题一定出在IO上——要么是数据库查询慢,要么是代码里有阻塞调用。但先说环境版本:
- Python 3.10.12
- FastAPI 0.104.1
- uvicorn 0.24.0(4 worker + 每个worker 2线程)
- SQLAlchemy 2.0.23(ORM模式)
- PostgreSQL 14.5,连接池用psycopg2的
ThreadedConnectionPool - Redis 7.0(后面才加的)
2. 第一步:用profile工具定位瓶颈,别瞎猜
很多人一上来就优化SQL,但我习惯先跑一遍profile,用数据说话。这里推荐py-spy,不用改代码,直接挂在运行中的进程上采样:
# 安装并采样运行中的FastAPI进程
pip install py-spy
py-spy record -p $(pgrep -f uvicorn | head -1) -o profile.svg --duration 30
压测的同时采集30秒,生成火焰图。结果非常直观:SQLAlchemy的refresh和lazy load占了71%的时间。具体来说,Order模型有一个items一对多关联,默认是lazy='select',每次访问order.items都会发一条新SQL。一个订单有5-8个明细,压测下相当于1次查询变成了1+7次,典型的N+1问题。
再查了PostgreSQL慢查询日志,发现单条SELECT * FROM order_items WHERE order_id = ...执行只要1.2ms,但被调用了上千次,累积起来就爆炸了。问题不在数据库,而在ORM的懒加载策略。
3. 第二刀:SQLAlchemy查询重写,干掉N+1
方案很简单:用selectinload或者joinedload把关联表一次性查出来。注意SQLAlchemy 2.0的写法变了,推荐用select()而不是session.query():
# 优化前:懒加载,触发N+1
async def get_order(db: AsyncSession, order_id: int):
order = await db.get(Order, order_id)
# 这里访问 order.items 会发额外的SQL
return order
# 优化后:用 selectinload 预取关联
from sqlalchemy.orm import selectinload
from sqlalchemy import select
async def get_order_optimized(db: AsyncSession, order_id: int):
stmt = (
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
selectinload会先查订单表,再用WHERE order_id IN (...)一次性查出所有明细,SQL数量从1+7变成2条。实测压测结果:P95从502ms降到217ms,QPS从382涨到864。效果明显,但还不够——因为我发现数据库CPU使用率居高不下,单条SQL虽然快,但每次请求都要查库,频繁的上下文切换和网络往返依然昂贵。
4. 第三刀:Redis缓存,但别缓存一切
数据库查询已经优化到不能再优化了(单条SQL执行计划走了主键索引,0.8ms),那剩下的路就是加缓存。但缓存不是无脑把整个对象塞进Redis——订单数据可能被多个服务修改(比如支付回调更新状态),你需要考虑缓存一致性。
我的策略是两级缓存:
- 进程内缓存(cachetools.TTLCache):TTL 5秒,容量1024。因为uvicorn有4个worker,进程内缓存命中率约25%,但能挡住最热的数据。
- Redis缓存:TTL 60秒,key设计为
order:{order_id},value用JSON序列化。Redis挂了会自动降级到数据库,不影响核心功能。
核心实现代码(FastAPI依赖注入版本):
import json
import redis.asyncio as aioredis
from cachetools import TTLCache
from fastapi import Depends
# 进程内缓存,线程安全
local_cache = TTLCache(maxsize=1024, ttl=5)
# Redis连接池(全局单例)
redis_client = aioredis.from_url(
"redis://localhost:6379/0",
max_connections=20,
decode_responses=True
)
async def get_cached_order(db: AsyncSession, order_id: int):
# L1: 进程内缓存
cached = local_cache.get(order_id)
if cached:
return cached
# L2: Redis缓存
redis_key = f"order:{order_id}"
raw = await redis_client.get(redis_key)
if raw:
order_data = json.loads(raw)
local_cache[order_id] = order_data # 回填L1
return order_data
# L3: 数据库查询(已优化为selectinload)
order = await get_order_optimized(db, order_id)
if order:
order_data = order_to_dict(order) # 自定义序列化
await redis_client.setex(redis_key, 60, json.dumps(order_data))
local_cache[order_id] = order_data
return order_data
return None
5. 踩坑记录:缓存穿透、序列化与压测看门狗
这里必须分享两个坑,你们大概率也会遇到:
坑1:缓存穿透。压测时发现并发100下,如果请求一个不存在的订单ID(比如负数),所有请求都会打到数据库,瞬间把PostgreSQL连接池打满。解决方法是缓存空值:order:{id}存null,TTL缩短到10秒。
坑2:序列化性能。一开始用pydantic的model_dump_json()序列化,发现压测时CPU飙到90%。换成手写字典推导式后,序列化时间从2.1ms降到0.3ms。Pydantic在数据校验上强,但序列化性能确实拉胯,适合做DTO不适合做缓存对象。
压测看门狗:我用locust写了个简单的压测脚本,每5秒打印P95和QPS,方便实时观察调优效果。压测机配置:4核8G,和服务器同内网,避免网络抖动干扰。
6. 最终效果数据与调优清单
最终压测结果(并发100,持续5分钟,总请求30万):
| 指标 | 优化前 | 加selectinload | 加Redis+L1缓存 |
|---|---|---|---|
| P50 | 210ms | 98ms | 42ms |
| P95 | 502ms | 217ms | 89ms |
| QPS | 382 | 864 | 2150 |
| 数据库连接数 | 80+ | 45 | 12(缓存命中率92%) |
总结调优清单:
- 先profile再优化:
py-spy火焰图比直觉靠谱,我见过太多人瞎优化索引,结果瓶颈在GIL。 - ORM懒加载是性能杀手:写SQLAlchemy模型时默认用
lazy='raise',强制开发者显式声明加载策略。 - 缓存要分级:进程内缓存扛热点,Redis扛分布式一致性,数据库兜底。TTL设短一点没关系,宁可多查一次DB也不要拿到脏数据。
- 压测要盯P95和连接数:平均值会骗人,P95才反映真实体验;数据库连接数暴涨往往比延迟高更致命。
最后说句实话:这套优化不是我灵光一现,是走了一遍完整链路——定位(profiling)→ 消除(ORM优化)→ 降频(缓存)→ 验证(压测)。如果你的API也慢,先按这个流程走一遍,八成能解决80%的问题。