一、问题背景:一个简单的用户画像接口,为什么这么慢?
上周三,业务方反馈:“用户画像详情页加载要3秒,用户都跑了。”我查了Grafana监控,/api/profile/{user_id}这个接口的P99延迟高达3.1秒,QPS只有12,而业务数据量才5万条用户记录。
直觉告诉我:这绝对不是数据库撑不住,而是代码写废了。我本地启动服务,用wrk -t4 -c20 -d30s压了一下,平均响应2.3秒——确定是应用层的问题。
环境与版本:
- Python 3.11 + FastAPI 0.104.1
- SQLAlchemy 2.0.23 + asyncpg 0.29.0
- Redis 7.2 + redis-py 5.0.1
- PostgreSQL 15.4
- 服务器:4C8G云服务器,SSD磁盘
二、Profiling:用Py-Spy找出元凶
不猜,直接上火焰图。用py-spy record -o flame.svg -p $(lsof -ti:8000) --duration 60采集1分钟。
火焰图最宽的两块:
1. sqlalchemy.orm.loading._load_scalar_from_collection 占了42%的CPU时间
2. asyncpg.prepare 占了18%
很明显,ORM懒加载导致了大量重复查询。检查数据库日志,发现一个用户画像查询竟然发了47条SQL——典型的N+1问题。
我还用slow_query_log确认了慢查询:SELECT * FROM user_orders WHERE user_id = %s 单次执行2ms,但执行了43次,加上其他关联表,总耗时1.8秒。
结论:瓶颈在ORM的懒加载,而不是数据库本身。数据库索引是全的,但ORM不听话。
三、方案设计:异步+预加载+缓存三层优化
优化策略分三步走:
- ORM查询优化:用
selectinload一次性加载所有关联关系,消除N+1 - 异步改造:FastAPI原生支持async,但中间件和DB驱动必须全链路异步。把
psycopg2替换为asyncpg,并把业务逻辑中的time.sleep()全部换成asyncio.sleep() - 缓存兜底:对热点用户画像做Redis缓存,设置TTL=300秒,缓存穿透时用互斥锁(setnx)防击穿
架构图(文本版):
Client -> FastAPI (async) -> asyncpg -> PostgreSQL
-> Redis (cache-aside)
四、核心实现:从同步ORM到异步+缓存
4.1 异步数据库配置
# config.py
import os
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost:5432/prod_db")
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=False, # 生产环境关掉echo
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
关键参数说明:
- pool_size=10:连接池初始大小,4C机器建议8-12
- pool_pre_ping=True:每次取连接前检测有效性,避免断连
- expire_on_commit=False:异步模式下必须关掉,否则session.commit后所有对象过期,下次访问会触发懒加载
4.2 预加载:selectinload解决N+1
# service.py
from sqlalchemy import select
from sqlalchemy.orm import selectinload
async def get_user_profile(db: AsyncSession, user_id: int) -> dict:
stmt = (
select(User)
.options(
selectinload(User.orders),
selectinload(User.addresses),
selectinload(User.devices).load_only(Device.device_id, Device.last_login),
)
.where(User.id == user_id)
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return None
# 此时orders/addresses/devices已全部加载,不会再有懒加载查询
return {
"user_id": user.id,
"name": user.name,
"order_count": len(user.orders),
"orders": [{"id": o.id, "amount": o.amount} for o in user.orders],
"addresses": [{"city": a.city, "detail": a.detail} for a in user.addresses],
}
踩坑记录:一开始我用了joinedload,结果左连接导致orders表笛卡尔积,查询返回了重复数据。selectinload走的是IN查询,对一对多和多多对多更友好,但注意IN查询的列表不能太大(默认500,SQLAlchemy 2.0可以调selectinload_limit参数)。
4.3 缓存层:Cache-Aside + 互斥锁
# cache.py
import asyncio
from redis import asyncio as aioredis
import json
redis = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
CACHE_TTL = 300 # 5分钟
LOCK_TTL = 10 # 锁超时10秒
async def get_user_profile_cached(db: AsyncSession, user_id: int) -> dict:
cache_key = f"profile:{user_id}"
# 1. 先查缓存
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# 2. 缓存未命中,加分布式锁,防止缓存击穿
lock_key = f"lock:{cache_key}"
lock = await redis.setnx(lock_key, "1")
if lock:
await redis.expire(lock_key, LOCK_TTL)
try:
# 3. 查数据库
profile = await get_user_profile(db, user_id)
if profile:
await redis.setex(cache_key, CACHE_TTL, json.dumps(profile))
return profile
finally:
await redis.delete(lock_key)
else:
# 4. 其他进程在查库,等待并重试
await asyncio.sleep(0.1)
return await get_user_profile_cached(db, user_id) # 递归重试
设计考量:
- setnx实现互斥锁,避免高并发下同时查库打爆数据库
- 锁的TTL设为10秒,防止进程崩溃导致死锁
- 重试机制用递归+await,不会阻塞事件循环
五、压测数据:从2.3秒到95ms
压测工具:wrk -t4 -c20 -d60s --latency http://localhost:8000/api/profile/12345
5.1 优化前(同步+懒加载)
Requests/sec: 12.34
Latency Distribution
50% 2.30s
75% 2.61s
90% 2.88s
99% 3.12s
5.2 优化后(异步+selectinload)
Requests/sec: 187.56
Latency Distribution
50% 98.4ms
75% 112.7ms
90% 138.2ms
99% 245.1ms
5.3 优化后(异步+selectinload+Redis缓存命中)
Requests/sec: 780.34
Latency Distribution
50% 9.8ms
75% 12.3ms
90% 18.7ms
99% 210.4ms
效果总结:
| 指标 | 优化前 | 优化后(无缓存) | 优化后(缓存命中) |
|------|--------|---------------|-----------------|
| QPS | 12 | 187 | 780 |
| P50延迟 | 2.3s | 98ms | 9.8ms |
| P99延迟 | 3.1s | 245ms | 210ms |
| DB查询数/请求 | 47 | 3 | 0 |
注意:缓存命中时P99高于P50是因为存在缓存穿透的情况(第一次请求或缓存过期),这部分请求走了数据库,延迟在200ms左右,拉高了P99。
六、总结与反思
核心收益:
1. 火焰图是性能优化的导航仪,不要靠猜
2. ORM的懒加载在同步代码里可能只是慢,在异步代码里会阻塞事件循环,直接拖垮吞吐
3. selectinload替代joinedload,避免笛卡尔积,对一对多关系更安全
4. 缓存层必须考虑击穿,setnx互斥锁是简单有效的方案
还留下的问题:
- 缓存空值没做,如果用户不存在,每次都要查库。后续可以加cache_null=True,对不存在的user_id缓存空对象(TTL设短一点,比如60秒)
- 缓存没有预热机制,上线后第一次请求还是会慢。可以用后台任务在服务启动时预热Top 1000用户
- 数据库连接池大小没做动态调整,后续考虑用HikariCP风格的自动缩放
个人感受:很多团队一谈优化就上缓存、上消息队列,其实90%的性能问题都是代码层面的,比如ORM滥用、I/O阻塞、不合理的锁粒度。这次优化没改一行业务逻辑,只是改了数据访问方式,效果立竿见影。希望这篇能帮到被慢接口折磨的你。
--
附:完整代码已上传GitHub [repo-link],包含压测脚本和docker-compose一键启动环境。