一、问题背景:一个简单的用户画像接口,为什么这么慢?

上周三,业务方反馈:“用户画像详情页加载要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不听话。

三、方案设计:异步+预加载+缓存三层优化

优化策略分三步走:

  1. ORM查询优化:用selectinload一次性加载所有关联关系,消除N+1
  2. 异步改造:FastAPI原生支持async,但中间件和DB驱动必须全链路异步。把psycopg2替换为asyncpg,并把业务逻辑中的time.sleep()全部换成asyncio.sleep()
  3. 缓存兜底:对热点用户画像做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一键启动环境。