一、问题背景:接口慢到被运维报警
事情发生在周三下午,监控系统突然报警:/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+,导致连接池被占满。
四、方案设计:三层优化策略
定位到三个问题,逐一解决:
- 同步阻塞:把
requests.get()改为httpx.AsyncClient,利用异步IO避免阻塞事件循环。 - N+1查询:SQLAlchemy ORM查询时,关联的报表明细用了懒加载(
lazy="select"),导致查完主表后,每条明细都触发一次查询。改为selectinload或joinedload。 - 缓存策略:报表数据有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。