1. 问题背景:一个“还行”的接口,压测就原形毕露
事情是这样的,上个月我们有个订单服务要上线,基于FastAPI 0.104 + SQLAlchemy 2.0 + PostgreSQL 15。功能都写完了,自测接口响应300ms左右,感觉“还行”。结果用Locust压测,200并发一上来,P95直接飙到2.1秒,数据库连接池被打满,CPU飙到85%,一堆超时告警。
我第一时间怀疑是数据库问题,但看慢查询日志,单条SQL都在50ms以内,不像是慢SQL。后来用cProfile一测,发现时间根本不在SQL上——90%的时间花在了对象序列化和懒加载触发上。这就是典型的“代码看着没问题,实际跑起来全是坑”。
2. 环境与版本:别问,问就是这套组合
先说环境,方便你复现:
- Python 3.11.6(注意,3.12的GIL改动对性能有影响,但生产环境我们保守用3.11)
- FastAPI 0.104.1(0.100之后性能提升明显,老版本直接升级)
- SQLAlchemy 2.0.23(2.0的ORM查询和1.x差别巨大,建议直接上2.0)
- uvicorn 0.24.0(worker用--workers 4,注意和--reload互斥)
- PostgreSQL 15.3(pg_stat_statements插件必开)
- Redis 7.0.13(缓存用,后面细说)
- 压测工具:Locust 2.19.1(比wrk好用,能看P95分布)
压测机8核16G,应用服务器4核8G,数据库独立4核8G。网络走内网,延迟 CPU计算 > 内存分配 > 锁竞争`。我们这次主要处理I/O(N+1查询)和CPU(序列化开销)。
4. 核心实现:三步走,每一步都有数据支撑
4.1 第一步:用cProfile和py-spy定位真正的瓶颈
先写个脚本压一下单次请求,用cProfile抓CPU时间:
# profile_api.py
import cProfile
import pstats
import asyncio
from httpx import AsyncClient
async def hit_api():
async with AsyncClient(base_url="http://127.0.0.1:8000") as client:
# 模拟真实请求:订单详情 + 用户信息 + 商品列表
for _ in range(200): # 跑200次求平均
r = await client.get("/api/v1/orders/12345?include_items=true")
assert r.status_code == 200
if __name__ == "__main__":
profiler = cProfile.Profile()
profiler.enable()
asyncio.run(hit_api())
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(20) # 只看前20行
输出结果(截取关键部分):
ncalls tottime percall cumtime percall filename:lineno(function)
200 0.002 0.000 4.213 0.021 serializers.py:38 (OrderOutSchema.dump)
200 0.001 0.000 3.897 0.019 sqlalchemy/orm/loading.py:112 (instance_processor)
1200 0.045 0.000 2.456 0.002 sqlalchemy/orm/relationships.py:210 (_emit_lazyload)
看到了吗?_emit_lazyload被调了1200次,每次大概2ms,累计2.456秒。这就是经典的N+1问题——查询订单后,访问order.user和order.items,每一条都触发一次新的SQL查询。200次请求,1200次懒加载,平均每次请求额外打6次数据库。
另外OrderOutSchema.dump耗时4.2秒,说明Pydantic v2的序列化也有开销,但主要原因是懒加载的数据在序列化时才去查库,等于把I/O延迟放到了序列化阶段。
结论:先解决N+1,再考虑序列化优化。
4.2 第二步:用selectinload重写ORM查询,消灭N+1
SQLAlchemy 2.0推荐用selectinload(不是老的joinedload——join会把结果集膨胀,selectin是分成两条SQL,一条查主表,一条用IN查关联表,每条都走索引,大数据量下更稳)。
# repositories/order_repository.py
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models import Order, OrderItem, User
async def get_order_with_details(session, order_id: int) -> Order:
stmt = (
select(Order)
.options(
selectinload(Order.user), # 原来是懒加载,现在强制预加载
selectinload(Order.items), # 原来是懒加载,现在强制预加载
)
.where(Order.id == order_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
改动就三行,但效果立竿见影。原来200次请求打了200 * (1 + 6) = 1400条SQL,现在变成200 * 2 = 400条,且每条都是走主键索引的查询。
顺便优化了Pydantic序列化——把response_model的字段明确写出来,不要用model_config = ConfigDict(from_attributes=True)让Pydantic自动遍历所有属性,因为遍历过程也会触发懒加载。
# schemas/order_schema.py
from pydantic import BaseModel
class OrderItemOut(BaseModel):
sku: str
price: float
quantity: int
class OrderOut(BaseModel):
id: int
order_no: str
total_amount: float
items: list[OrderItemOut]
# 关键:手动指定字段来源,避免Pydantic遍历ORM对象的所有属性
@classmethod
def from_orm_with_items(cls, order: Order) -> "OrderOut":
return cls(
id=order.id,
order_no=order.order_no,
total_amount=order.total_amount,
items=[
OrderItemOut(
sku=item.sku,
price=item.price,
quantity=item.quantity
)
for item in order.items
]
)
4.3 第三步:加Redis缓存,把热点查询挡住
N+1解决后,接口P95降到600ms左右。但QPS还是上不去,因为数据库单条查询虽然快,但并发一高,连接池就饱和。这时候上Redis,只缓存两类数据:
- 商品信息(SKU、价格)——变化频率极低,缓存5分钟。
- 订单列表的前2页——用户翻页行为集中在前面,缓存1分钟。
用普通装饰器即可,但注意缓存穿透和雪崩。我们加了一个简单的cache_decorator:
# utils/cache.py
import json
import hashlib
from redis import asyncio as aioredis
redis_client = aioredis.from_url(
"redis://127.0.0.1:6379/0",
decode_responses=True,
max_connections=50, # 连接池上限,默认10太小
socket_connect_timeout=2, # 连接超时,防止Redis挂了拖垮API
socket_timeout=2,
)
def cache_response(ttl: int, prefix: str):
def decorator(func):
async def wrapper(*args, **kwargs):
# 生成缓存key:函数名 + 参数hash
key = f"{prefix}:{hashlib.md5(str(kwargs).encode()).hexdigest()}"
cached = await redis_client.get(key)
if cached:
return json.loads(cached)
result = await func(*args, **kwargs)
# 只缓存成功响应,避免缓存空结果
if result.status_code == 200:
await redis_client.setex(key, ttl, json.dumps(result.body))
return result
return wrapper
return decorator
注意两个细节:
- decode_responses=True,否则拿到的是bytes,每次要decode,浪费CPU。
- socket_timeout=2,如果Redis挂掉,API不能跟着挂,直接放行查数据库。
5. 踩坑与优化:你以为优化完了?还早
踩坑1:selectinload和分页的冲突。如果你用order_by + limit,selectinload会在主查询之后追加一条IN查询,但分页的limit只作用于主表,没问题。但如果你用distinct,就会出问题——SQLAlchemy会先查主表再查关联表,distinct可能导致关联表数据错乱。解决方案:先查主表ID列表,再用where_in查子表,手动组装。
踩坑2:Redis缓存了但没设过期时间。上线第二天,商品价格改了三小时没生效,用户投诉。后来加了个delete_keys_by_pattern的管理接口,改价格时主动清缓存。
踩坑3:uvicorn workers数不是越多越好。我们4核8G的机器,一开始配了--workers 8,结果上下文切换开销大,QPS反而下降。实测4个worker最优(=CPU核心数),每个worker开--limit-concurrency 1000,配合数据库连接池pool_size=10, max_overflow=20,刚好不超。
踩坑4:压测时别用--reload。我们第一次压测忘了关--reload,文件监控线程每秒钟扫一次目录,CPU白白吃掉10%。生产环境一定用--workers 4 --no-access-log,关掉访问日志也能省5%CPU。
6. 效果数据:从2.1s到180ms,全程有记录
同一个压测脚本,200并发跑5分钟,数据如下:
| 指标 | 调优前 | 调优后 | 提升 |
|---|---|---|---|
| P50延迟 | 890ms | 85ms | 10.5x |
| P95延迟 | 2.1s | 180ms | 11.7x |
| P99延迟 | 3.4s | 320ms | 10.6x |
| QPS | 230 | 2140 | 9.3x |
| 数据库连接池使用率 | 98%(饱和) | 42% | - |
| CPU使用率 | 85% | 53% | - |
数据库层面,pg_stat_statements显示:
- 总SQL执行次数从1400次/请求降到2次/请求。
- 单条SQL平均耗时从52ms降到8ms(主要是因为没有懒加载的额外往返)。
Redis命中率:压测期间约87%。商品信息缓存命中率98%(5分钟TTL),订单列表命中率65%(1分钟TTL),整体命中率在可接受范围。
7. 总结:调优的顺序比技巧更重要
这次调优最大的感触是:先定位再动手,别用直觉猜瓶颈。我见过太多人上来就加Redis,结果发现瓶颈是序列化,缓存加了个寂寞。
我的调优顺序建议:
1. Profiling(cProfile/py-spy) → 找到时间花在哪。
2. 数据库层(pg_stat_statements/慢查询日志) → 解决N+1和缺索引。
3. 序列化层(Pydantic/手动字段映射) → 减少CPU开销。
4. 缓存层(Redis) → 挡住高频热点查询。
5. 并发层(uvicorn workers/连接池参数) → 最后调,因为要基于前几步的实际负载来定。
另外,压测数据一定要记录,每次改动跑一轮,用数据说话。没有对比数据的调优,都是耍流氓。
最后说一句,FastAPI性能本身不差,差的是开发者怎么用ORM和序列化。SQLAlchemy 2.0的selectinload + Pydantic v2的model_validate是绝配,但前提是你得理解懒加载的触发时机。希望这篇能帮你少踩几个坑。