一、问题背景:接口慢到被运维报警

事情发生在周三下午,监控系统突然报警:/api/v1/report/summary 接口的P95延迟飙升到2.1秒,而平时只有300ms。这个接口是给BI看板用的,每天早9点和下午3点有两次访问高峰。查了一下监控面板,CPU使用率只有35%,内存正常,但数据库连接数打到了上限(我们配的max_connections=50)。

第一反应是SQL慢,但看了慢查询日志,最慢的SQL也只有120ms。问题显然不在单条SQL上——大概率是应用层逻辑出了问题。

二、环境与版本:技术栈说明

先交代下环境,方便大家复现:

Python 3.11.4
FastAPI 0.104.1
uvicorn 0.24.0 (worker=4, 每个worker异步模式)
SQLAlchemy 2.0.23 (asyncpg驱动)
PostgreSQL 14.5 (云RDS, 4核8G)
Redis 7.0.12 (单机, 最大内存2G)
Docker 24.0.5

服务部署在K8s集群(4个pod,每个pod 2核4G),流量入口是Nginx ingress。压测工具用的是wrk

三、定位瓶颈:py-spy和慢查询日志双重验证

先用py-spy抓一下进程的调用栈,看看时间都花在哪了:

# 进入容器,找到uvicorn主进程PID
kubectl exec -it my-api-pod-xxx -- sh
# 安装py-spy(生产环境临时用,用完就卸)
pip install py-spy==0.3.14
# 抓取20秒的调用栈,输出到文件
py-spy record -o /tmp/profile.svg -p $(pgrep -f uvicorn | head -1) --duration 20
# 或者直接dump当前栈
py-spy dump --pid $(pgrep -f uvicorn | head -1)

dump出来的栈显示大量线程阻塞在asyncpg的连接获取上,而不是SQL执行。这很奇怪——SQL明明不慢,为什么连接不够用?

再查代码,发现一个典型的坑:同步的requests调用阻塞了事件循环。报表服务里有个函数用了requests.get()去调用另一个内部服务,这个调用是同步的,在FastAPI的async路由里会阻塞整个事件循环。虽然并发上来了,但每个请求都要等这个外部服务100ms+,导致连接池被占满。

四、方案设计:三层优化策略

定位到三个问题,逐一解决:

  1. 同步阻塞:把requests.get()改为httpx.AsyncClient,利用异步IO避免阻塞事件循环。
  2. N+1查询:SQLAlchemy ORM查询时,关联的报表明细用了懒加载(lazy="select"),导致查完主表后,每条明细都触发一次查询。改为selectinloadjoinedload
  3. 缓存策略:报表数据有20分钟延迟更新,完全可以加缓存。设计两级缓存:本地内存缓存(cachetools)+ Redis分布式缓存,并处理缓存击穿和雪崩。

五、核心实现:代码改造细节

5.1 异步化改造

先看优化前的代码(伪代码):

# 优化前:同步requests阻塞事件循环
@app.get("/api/v1/report/summary")
async def get_summary(request: Request):
    # 同步调用外部服务,阻塞事件循环!
    resp = requests.get("http://auth-service/api/check", timeout=0.5)
    if resp.status_code != 200:
        raise HTTPException(status_code=403)
    # 查询数据库
    data = await db.query(ReportSummary).filter(...).all()
    return {"data": data}

优化后:

# 优化后:使用httpx异步客户端
import httpx
from contextlib import asynccontextmanager

@asynccontextmanager
async def get_http_client():
    async with httpx.AsyncClient(timeout=0.5) as client:
        yield client

@app.get("/api/v1/report/summary")
async def get_summary(request: Request):
    # 异步调用外部服务,不阻塞事件循环
    async with get_http_client() as client:
        resp = await client.get("http://auth-service/api/check")
        if resp.status_code != 200:
            raise HTTPException(status_code=403)
    # 查询数据库(异步)
    data = await db.query(ReportSummary).filter(...).all()
    return {"data": data}

5.2 N+1查询优化

原代码用的是:

# 优化前:懒加载导致N+1
stmt = select(Report).where(Report.tenant_id == tenant_id)
reports = await db.execute(stmt)
for report in reports.scalars().all():
    # 每次访问report.items都会触发一次数据库查询
    print(len(report.items))

优化后:

# 优化后:使用selectinload预加载关联表
from sqlalchemy.orm import selectinload

stmt = (
    select(Report)
    .options(selectinload(Report.items))  # 一条IN查询替代N条SELECT
    .where(Report.tenant_id == tenant_id)
)
reports = await db.execute(stmt)
for report in reports.scalars().all():
    print(len(report.items))

5.3 两级缓存实现

缓存策略如下:

  • 一级缓存:本地内存(cachetools.TTLCache),TTL=60秒,用于单pod内快速响应。
  • 二级缓存:Redis,TTL=300秒,用于跨pod共享。
  • 击穿保护:使用Redis SET NX EX实现互斥锁,只有一个线程查数据库并回填缓存。
from cachetools import TTLCache
import redis.asyncio as aioredis

# 本地缓存,每个worker独立
local_cache = TTLCache(maxsize=128, ttl=60)
# Redis连接池
redis_pool = aioredis.ConnectionPool.from_url(
    "redis://localhost:6379/0", max_connections=20
)
redis_client = aioredis.Redis(connection_pool=redis_pool)

async def get_report_data(tenant_id: str):
    # 1. 查本地缓存
    cache_key = f"report_summary:{tenant_id}"
    if cache_key in local_cache:
        return local_cache[cache_key]

    # 2. 查Redis(加锁避免击穿)
    async with redis_client.lock(f"lock:{cache_key}", timeout=5):
        # 双重检查:锁获取后再查一次Redis
        cached = await redis_client.get(cache_key)
        if cached:
            data = json.loads(cached)
            local_cache[cache_key] = data  # 回填本地
            return data

        # 3. 查数据库
        data = await query_db(tenant_id)
        # 4. 回填缓存
        await redis_client.set(cache_key, json.dumps(data), ex=300)
        local_cache[cache_key] = data
        return data

注意:Redis的lock是异步上下文管理器,需要redis-py 4.5+版本支持。

六、踩坑与优化记录

6.1 坑1:httpx客户端复用

一开始没有用get_http_client上下文管理器,而是每次请求都新建httpx.AsyncClient。结果压测时发现TCP连接风暴——每个请求都创建新连接,导致TIME_WAIT连接数暴增。后来改为模块级单例客户端:

# 模块级复用
_client = None

async def get_client():
    global _client
    if _client is None:
        _client = httpx.AsyncClient(timeout=0.5, limits=httpx.Limits(max_connections=100))
    return _client

但要注意:httpx.AsyncClient不是线程安全的,在FastAPI的异步环境里没问题(单线程事件循环),但如果用了ThreadPoolExecutor跑同步代码就要小心。

6.2 坑2:selectinload在大表上的性能问题

selectinload会生成一条WHERE id IN (..., ...)的SQL。如果主表有1万条,IN列表就有1万个参数,PostgreSQL对IN参数个数有限制(默认max_locks_per_transaction)。我们的报表主表最多几百条,所以没问题。但如果数据量大,建议用lazy="raise"强制报错,避免误用。

6.3 坑3:Redis锁的边界条件

redis_client.lock()默认是阻塞等待的,如果锁被占用了会一直等到timeout。但我们的场景是:如果Redis挂了,锁获取失败怎么办?需要加try-except回退到数据库查询,不能因为缓存故障导致业务不可用。

try:
    async with redis_client.lock(...):
        ...
except Exception as e:
    # Redis不可用,直接查数据库
    logger.error(f"Redis lock failed: {e}")
    return await query_db(tenant_id)

七、压测数据与效果对比

wrk压测,模拟300并发,持续30秒:

wrk -t 12 -c 300 -d 30s --latency http://api.example.com/api/v1/report/summary
指标 优化前 优化后 提升幅度
P50延迟 620ms 85ms 7.3倍
P95延迟 2100ms 180ms 11.7倍
P99延迟 3800ms 340ms 11.2倍
QPS(吞吐量) 120 560 4.7倍
错误率 0.8% 0.1% 87.5%下降

数据库连接数从峰值50降到15,CPU使用率从35%降到20%——因为减少了重复查询。Redis命中率在压测期间稳定在87%左右。

八、总结与后续计划

这次调优的核心思路就三步:异步化消除阻塞 → 预加载消灭N+1 → 缓存兜底扛流量。对于FastAPI/Flask这类单线程事件循环框架,最隐蔽的性能杀手就是同步阻塞调用,建议使用py-spy定期抓栈检查。

下一步准备做两件事:
1. 把Redis替换成Redis Cluster,避免单点故障。
2. 对报表接口做stale-while-revalidate模式,进一步降低缓存过期时的峰值延迟。

希望这篇文章对你有帮助,如果你们也遇到类似问题,欢迎在评论区交流。代码已上传GitHub仓库:[链接],记得给个star。