一、问题背景:半夜报警,接口突然“卡死”

上周四凌晨,监控告警把我从床上叫醒——/api/v1/orders接口P99延迟飙升至2.3秒,错误率突破5%。这个接口是订单列表查询,平时P95稳定在800ms左右。

第一反应看日志,发现慢查询集中在数据库端:同一个SQL执行了上百次,每次耗时12-15ms。典型的N+1查询症状。更诡异的是,当我把数据库连接池调大后,延迟反而更高了——这说明瓶颈不在连接数,而在查询本身的效率。

二、环境与版本:技术栈全景

优化前,服务端技术栈如下:

Python: 3.10.12
FastAPI: 0.104.1
Uvicorn: 0.24.0 (workers=4, loop="uvloop")
SQLAlchemy: 2.0.23 (async模式)
PostgreSQL: 15.3 (连接池使用asyncpg)
Redis: 7.2.1 (用于缓存与分布式锁)

压测工具采用wrk,命令如下:

wrk -t8 -c200 -d60s --latency http://localhost:8000/api/v1/orders?user_id=1024

基线数据:请求量约1120 req/s,P95延迟820ms,数据库CPU占用率78%,服务端CPU占用率45%。

三、方案设计:三层漏斗排查法

我的优化策略分三步走,每一步都基于数据驱动,不靠猜:

  1. Profiling定位热点:先用cProfile记录CPU调用栈,再用Py-Spy抓取运行时状态,确认是CPU密集还是IO密集。
  2. 数据库层优化:修正ORM查询策略,消除N+1,并利用EXPLAIN ANALYZE检查索引命中情况。
  3. 缓存层改造:引入两级缓存架构——本地内存缓存(LRU) + Redis分布式缓存,同时解决缓存击穿问题。

四、核心实现与代码演进

4.1 Profiling:直接证据链

先用py-spy dump抓取运行中的进程栈,连续采样5次:

py-spy dump --pid 12345 --duration 5

关键发现sqlalchemy/loading.py中的_instance_processor占据62%的采样时间。这证实了N+1查询——每次迭代都触发独立的数据库往返。

接着用cProfile跑单请求:

import cProfile
import pstats
from app.main import app
from starlette.testclient import TestClient

client = TestClient(app)
profiler = cProfile.Profile()
profiler.enable()
response = client.get("/api/v1/orders?user_id=1024")
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(30)  # 打印前30行

输出关键行:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
  2500    0.012    0.000    1.642    0.001  /.../sqlalchemy/orm/loading.py:765(_instance_processor)

2500次调用,累计耗时1.6秒。这就是罪魁祸首。

4.2 数据库查询优化:从N+1到批量加载

原始代码使用了懒加载:

# 优化前:懒加载导致N+1
async def get_orders(user_id: int, db: AsyncSession):
    result = await db.execute(
        select(Order).where(Order.user_id == user_id)
    )
    orders = result.scalars().all()
    # 每次访问order.items都会触发新的SQL
    return [
        {"id": o.id, "items": [item.name for item in o.items]}
        for o in orders
    ]

修正为显式批量加载,使用selectinload一次关联查询:

# 优化后:使用selectinload批量加载
from sqlalchemy.orm import selectinload

async def get_orders_optimized(user_id: int, db: AsyncSession):
    stmt = (
        select(Order)
        .where(Order.user_id == user_id)
        .options(
            selectinload(Order.items)  # 生成IN查询,一次加载所有items
        )
    )
    result = await db.execute(stmt)
    orders = result.scalars().all()
    return [
        {"id": o.id, "items": [item.name for item in o.items]}
        for o in orders
    ]

效果验证:数据库查询次数从原来的1 + N次降为1 + 1次。用EXPLAIN确认走了user_id_idx索引,且没有额外的SEQ SCAN

4.3 缓存策略:多级缓存与锁防击穿

SQL优化后P95延迟降至280ms,但仍不够。我发现这个接口的读多写少特性——同一用户的订单数据在5分钟内变化率低于0.1%。于是引入缓存:

# 两级缓存:本地LRU + Redis分布式缓存
from functools import lru_cache
import aioredis
import json

class OrderCache:
    def __init__(self, redis_url: str):
        self.redis = aioredis.from_url(redis_url)
        self.local_ttl = 30      # 本地缓存30秒
        self.redis_ttl = 300     # Redis缓存5分钟

    @lru_cache(maxsize=256)
    def _local_cache(self, key: str):
        return None  # 占位符,实际用字典存储

    async def get_or_set(self, user_id: int, db_loader):
        # 1. 查询本地缓存
        cache_key = f"orders:{user_id}"
        local_data = self._local_cache(cache_key)
        if local_data:
            return local_data

        # 2. 查询Redis
        redis_data = await self.redis.get(cache_key)
        if redis_data:
            # 回填本地缓存
            data = json.loads(redis_data)
            self._local_cache(cache_key, data)
            return data

        # 3. 缓存未命中 -> 查数据库,加分布式锁防击穿
        async with self.redis.lock(f"lock:{cache_key}", timeout=5):
            # 双重检查,防止锁等待后重复查询
            redis_data = await self.redis.get(cache_key)
            if redis_data:
                return json.loads(redis_data)
            data = await db_loader()
            await self.redis.set(cache_key, json.dumps(data), ex=self.redis_ttl)
            self._local_cache(cache_key, data)  # 注意设置ttl需要自定义
            return data

踩坑记录lru_cache不支持单独设置TTL且不可控,我改用cachetools.TTLCache替代:

from cachetools import TTLCache

self.local_cache = TTLCache(maxsize=512, ttl=30)

同时,为了压测时避免缓存干扰,我在测试脚本中加入了Cache-Control头模拟真实用户。

五、踩坑与优化:压测中的意外发现

坑1:Uvicorn workers数与连接池不匹配
最初配置workers=8,但每个worker独立维护数据库连接池,导致连接数爆炸。最终固定为workers=4,并设置max_overflow=20

坑2:Redis锁的续期问题
当数据库查询超过5秒(比如订单量大时),锁自动过期导致并发查询穿透。解决方案是租约续期,使用Redlock算法。但考虑到业务场景,我直接优化了SQL,确保单次查询低于200ms。

坑3:压测数据污染缓存
wrk产生的连续请求会让缓存命中率接近100%,掩盖真实问题。我在压测请求头中加入随机参数:

wrk -t8 -c200 -d60s --latency -H "Cache-Control: no-cache" http://localhost:8000/api/v1/orders?user_id=1024&_=random

六、效果数据:从820ms到97ms的完整记录

优化后的最终压测结果(使用同一台wrk机器):

指标 优化前 优化后 提升幅度
P95延迟 820ms 97ms 88.2%↓
吞吐量 1120 req/s 5150 req/s 359%↑
数据库QPS 2100 340 83.8%↓
服务端CPU 45% 23% 49%↓
错误率 5.2% 0.01% 99.8%↓

性能曲线:P99从2.3s降至150ms,完全满足SLA要求。

七、经验总结与教训

  1. ORM不背锅,但懒加载必须死:SQLAlchemy的N+1是常见陷阱,用selectinloadjoinedload做批量加载。
  2. Profiling先行:不要凭感觉优化。py-spy dump 30秒就能定位热点,比瞎猜高效十倍。
  3. 缓存要考虑击穿:高并发下必须用分布式锁保护,否则缓存失效瞬间会打垮数据库。
  4. 压测要模拟真实场景:关闭浏览器缓存,加随机参数,否则结果会过于乐观。

最后提醒:优化是无限游戏,这版缓存架构已经稳定运行两周,目前正在尝试用Cython编译热点函数,期待下一轮数据。

附录:完整优化后的关键依赖版本

fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy[asyncio]==2.0.23
asyncpg==0.29.0
redis==5.0.1
cachetools==5.3.2
py-spy==0.3.14

如果你也遇到类似性能问题,建议先跑一遍py-spy dump,再考虑加缓存。不要一上来就上Redis,先消除N+1,再谈缓存