一、问题背景:上线当天就被运维喊去开会
事情是这样的,我们有个用户画像聚合API,部署在4核8G的容器里,用的是FastAPI + uvicorn + SQLAlchemy 2.0 + PostgreSQL 14。上线第一天,运维截图发过来:QPS 1200的时候,P99延迟已经飙到860ms,数据库连接池40个连接全部打满,CPU使用率倒是只有45%——典型的“活儿全堵在数据库上”。
我第一反应是“加个索引不就完了”,但查了一圈,该有的索引都有了。那问题出在哪?只能上工具。
二、环境与版本:先把底交代清楚
- Python 3.11.4(GIL问题在3.12有改善,但生产环境还没升)
- FastAPI 0.104.1 + uvicorn 0.24.0(worker=1,因为后面要用异步)
- SQLAlchemy 2.0.23(重点:2.0的ORM写法跟1.x差别很大)
- asyncpg 0.29.0(PostgreSQL异步驱动)
- Redis 7.2(用redis-py 5.0,连接池max_connections=50)
- 压测工具:wrk 4.2.0,单机跑,避免网络干扰
三、第一步:用py-spy和cProfile定位瓶颈
先上py-spy dump看看线程在干嘛:
py-spy dump --pid 12345
输出显示:主线程卡在asyncpg的_read_message上,等待数据库返回。同时有3个线程在sqlalchemy的_execute_context里。这告诉我两件事:一是数据库查询慢,二是ORM把它拆成了多次查询。
接着用cProfile抓CPU热点:
import cProfile
import pstats
from app.main import app
profiler = cProfile.Profile()
profiler.enable()
# 手动调用API入口函数
response = app.router.routes[0].endpoint(sample_request)
profiler.disable()
pstats.Stats(profiler).sort_stats('cumtime').print_stats(20)
结果触目惊心:UserProfileService.get_profile累计耗时2.3秒,其中fetch_user_orders被调用了47次——N+1查询实锤了。每个用户有30-50个订单,ORM默认懒加载,每次循环都发一条SQL。
四、方案设计:三层优化,从数据库到缓存
优化思路分三层:
- 数据库层:用
selectinload一次性加载关联对象,替代N+1 - 应用层:加Redis缓存,热点数据TTL 60秒,防止缓存雪崩加随机抖动
- 进程内层:加Caffeine风格的本地缓存(Python用
cachetools.TTLCache),命中率约35%,减少Redis往返
架构图大概是这样:
Client → FastAPI → 本地缓存(L1) → Redis(L2) → PostgreSQL
↑_____________命中即返回_____________|
L1失效时查L2,L2失效时查DB并回填。L1的TTL设为10秒(短一点,避免数据不一致),L2设为60秒。
五、核心实现:代码说话
5.1 修复N+1:SQLAlchemy 2.0的selectinload
踩坑:网上很多教程还在用lazy='joined',但SQLAlchemy 2.0里这样会导致LEFT JOIN OUTER,数据量一大反而更慢。正确做法是用selectinload——它会先查主表,再发一条WHERE id IN (...)的查询。
# services/profile_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select
async def get_profile_with_orders(db: AsyncSession, user_id: int):
# 修复前:每取一个order发一条SQL
# result = await db.execute(select(User).where(User.id == user_id))
# user = result.scalar_one()
# for order in user.orders: # 这里触发N次查询
# 修复后:两条SQL搞定
stmt = (
select(User)
.where(User.id == user_id)
.options(
selectinload(User.orders),
selectinload(User.addresses),
)
)
result = await db.execute(stmt)
return result.scalar_one()
5.2 两级缓存:TTLCache + Redis
# core/cache.py
from cachetools import TTLCache
import redis.asyncio as aioredis
import json
# L1: 进程内缓存,最多1024个key,TTL 10秒
local_cache = TTLCache(maxsize=1024, ttl=10)
# L2: Redis连接池
redis_client = aioredis.from_url(
"redis://localhost:6379/0",
max_connections=50,
decode_responses=True,
)
async def get_cached_profile(user_id: int):
# 查L1
cached = local_cache.get(user_id)
if cached:
return cached
# 查L2
redis_key = f"profile:{user_id}"
raw = await redis_client.get(redis_key)
if raw:
data = json.loads(raw)
local_cache[user_id] = data # 回填L1
return data
# 查DB(调用方负责回填)
return None
async def set_cached_profile(user_id: int, data: dict):
# L1回填
local_cache[user_id] = data
# L2回填,TTL加随机抖动防雪崩
import random
ttl = 60 + random.randint(0, 30)
await redis_client.setex(
f"profile:{user_id}",
ttl,
json.dumps(data)
)
注意:L1缓存必须用TTLCache而不是dict,否则内存会炸。另外cachetools的TTLCache不是线程安全的,但在FastAPI的异步单线程事件循环里,用asyncio.Lock包一下写入操作就行。
六、踩坑与优化:你以为完了?还早
坑1:异步上下文管理器泄漏
第一次压测时发现连接池还是被打满,后来发现是async with db_session()用成了db_session()。SQLAlchemy 2.0的AsyncSession必须用async with,否则连接不释放。
坑2:Redis序列化
一开始用pickle,压测时发现CPU占用高。换成json序列化后,CPU降了12%,因为JSON的序列化/反序列化比pickle快30%。
坑3:本地缓存穿透
TTLCache的get方法在key不存在时返回None,但这跟“缓存了空值”冲突。我的解决办法是:如果DB结果为空,也缓存一个空list,TTL缩短到5秒。
坑4:压测工具的选择
wrk是单线程的,压到3000 QPS时自己先成了瓶颈。换wrk2(支持固定QPS模式)后,数据才稳定。另外一定要用--latency参数,不然看不到P99。
七、效果数据:从1200到9800,数据库扛住了
用wrk2压测,固定QPS,持续60秒,结果如下:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| QPS | 1200 | 9800 | 8.2倍 |
| P99延迟 | 860ms | 41ms | 21倍 |
| 数据库CPU | 78% | 9.5% | -87% |
| 连接池占用 | 40/40 | 12/40 | -70% |
| Redis QPS | 0 | 5200 | 新增 |
关键数据:数据库的pg_stat_statements显示,SELECT * FROM orders WHERE user_id = $1这条语句的调用次数从每分钟4700次降到380次,缓存命中率稳定在91%。
八、总结:调优不是玄学,是科学
这次调优花了两天,但真正改代码只用了半天。核心收获:
- 先profiling再动手,别一上来就加索引/缓存,浪费感情
- ORM的懒加载是性能杀手,用
selectinload或subqueryload,别用joined(2.0里性能反而差) - 两级缓存是标配,本地缓存扛热点,Redis扛穿透,TTL加随机抖动防雪崩
- 压测要用wrk2,控制QPS才能测出真实吞吐,
wrk的并发模式会掩盖问题
最后一句话:“如果你的API慢,先查数据库,再查ORM,最后才考虑加缓存”——这次我是反着来的,结果走了弯路。希望这篇对你有用,有问题评论区见。