一、问题背景:从一次线上告警说起
周一早上10:15,我们监控系统发出告警:/api/v1/customers 接口P95延迟达到1800ms,数据库连接池被打满。这个接口是CRM系统的核心入口,支持按状态、标签、时间范围筛选客户列表,前端表格需要一次性返回最多200条客户记录,每条记录附带订单统计、最近跟进记录等聚合数据。
最初代码是半年前某外包团队写的,用的是Flask 2.2 + SQLAlchemy 1.4 + MySQL 8.0。当时数据量小(5万客户),现在涨到80万,问题立刻暴露。我接手后第一件事:不优化代码,先量化瓶颈。
二、环境与版本:复现问题的基础设施
- 应用:Flask 2.2.5(后来迁移到FastAPI 0.104,原因后面说)
- ORM:SQLAlchemy 2.0.23(迁移时顺手升级)
- 数据库:MySQL 8.0.33,云RDS 4核16G,innodb_buffer_pool_size=12G
- 压测工具:Locust 2.20.1,单机模拟200并发
- 服务器:4核8G ECS,Python 3.11.6
压测命令很简单:locust -f locustfile.py --host=http://api.example.com --users 200 --spawn-rate 10 --run-time 60s。先跑基线,结果惨不忍睹:QPS 50左右,数据库CPU 98%,应用CPU 70%,P95延迟1800ms。
三、方案设计:三步定位瓶颈
我的优化策略分三步:
- Profile先行:用cProfile和PyCharm Profiler抓取火焰图,找出CPU热点
- 数据库层优化:检查N+1查询、缺失索引、大字段排序
- 缓存兜底:对热点数据做Redis缓存,设置合理过期时间
注意:不要一上来就加缓存,那会把问题掩盖。先搞清楚时间花在哪。
四、核心实现:从Flask迁移到FastAPI并逐步优化
4.1 第一步:Profile定位热点
在Flask视图函数里加cProfile装饰器:
import cProfile
import pstats
import io
from functools import wraps
def profile_endpoint(func):
@wraps(func)
def wrapper(*args, **kwargs):
pr = cProfile.Profile()
pr.enable()
result = func(*args, **kwargs)
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(20)
app.logger.info("Profile result:\n%s", s.getvalue())
return result
return wrapper
@app.route('/api/v1/customers', methods=['GET'])
@profile_endpoint
def get_customers():
# ... 原有逻辑
压测5分钟后抓日志,火焰图显示两大热点:
- SQLAlchemy ORM懒加载:
Customer对象访问orders属性时,每条记录触发一次SELECT * FROM orders WHERE customer_id = ?,200条客户产生201条SQL - 序列化开销:用
jsonify手动构造嵌套字典,Python层面循环耗时占CPU的35%
数据库慢日志也证实:平均每请求执行42条查询,总耗时1.6秒。
4.2 第二步:数据库查询优化
先给MySQL加索引:
ALTER TABLE orders ADD INDEX idx_customer_id (customer_id);
ALTER TABLE customers ADD INDEX idx_status_updated (status, updated_at DESC);
然后把懒加载改为显式联表查询。这里我直接迁移到FastAPI + SQLAlchemy 2.0,因为FastAPI的异步支持能更好利用IO并发(虽然Flask也能改,但异步改造太麻烦,且FastAPI自带OpenAPI文档方便调试)。
# main.py - FastAPI版本
from fastapi import FastAPI, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import selectinload, sessionmaker
DATABASE_URL = "mysql+asyncmy://user:pass@host:3306/crm"
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=20, max_overflow=10)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
async def get_db():
async with AsyncSessionLocal() as session:
yield session
@app.get("/api/v1/customers")
async def get_customers(
status: str = Query("active"),
limit: int = Query(200, le=200),
db: AsyncSession = Depends(get_db)
):
stmt = (
select(Customer)
.options(selectinload(Customer.orders))
.where(Customer.status == status)
.order_by(Customer.updated_at.desc())
.limit(limit)
)
result = await db.execute(stmt)
customers = result.scalars().all()
# 批量统计订单金额,避免逐条count
customer_ids = [c.id for c in customers]
stats_stmt = (
select(Order.customer_id, func.sum(Order.amount).label("total"), func.count(Order.id).label("cnt"))
.where(Order.customer_id.in_(customer_ids))
.group_by(Order.customer_id)
)
stats = await db.execute(stats_stmt)
stat_map = {row.customer_id: {"total": row.total, "cnt": row.cnt} for row in stats}
return [{
"id": c.id,
"name": c.name,
"status": c.status,
"order_stats": stat_map.get(c.id, {"total": 0, "cnt": 0})
} for c in customers]
关键改动:
- selectinload(Customer.orders):一次IN查询加载所有关联订单,替代N+1
- 聚合统计单独用GROUP BY批量查询,避免在Python里循环
- 异步引擎asyncmy + AsyncSession:IO等待时让出事件循环
4.3 第三步:缓存策略与踩坑
加了查询优化后P95降到350ms,但还不够。订单统计这种数据变化不频繁(每次下单后更新),适合缓存。用Redis做二级缓存,过期时间60秒:
import redis.asyncio as aioredis
import json
from fastapi import HTTPException
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
async def get_customer_stats_from_cache(customer_ids):
"""使用Redis pipeline批量获取缓存"""
pipe = redis_client.pipeline()
for cid in customer_ids:
await pipe.get(f"cust_stats:{cid}")
results = await pipe.execute()
# 命中率统计
hit = sum(1 for r in results if r is not None)
if hit / len(customer_ids) < 0.5:
print(f"缓存命中率过低: {hit}/{len(customer_ids)}") # 生产环境用logger
return results
@app.get("/api/v1/customers")
async def get_customers_cached(
status: str = Query("active"),
limit: int = Query(200, le=200),
db: AsyncSession = Depends(get_db)
):
# 先查缓存
cache_key = f"customers_list:{status}:{limit}"
cached_data = await redis_client.get(cache_key)
if cached_data:
return json.loads(cached_data)
# 数据库查询逻辑同上...
# 构造response后存入缓存
await redis_client.setex(cache_key, 60, json.dumps(response_data))
return response_data
踩坑记录:
- 缓存穿透:当客户ID不存在时,每次都查DB。解决办法:缓存空值,过期时间设短(比如5秒)
- 缓存雪崩:所有key同时过期。在过期时间上加随机偏移:
expire_time = 60 + random.randint(0, 30) - 管道使用:一开始用for循环挨个
get,200个客户要200次RTT。改用pipeline后,Redis耗时从15ms降到3ms - 序列化陷阱:ORM对象不能直接
json.dumps,必须转dict。用jsonify的FastAPI版本是fastapi.encoders.jsonable_encoder,但手写字典的构造效率更高——因为减少了反射调用。
五、压测数据与效果对比
用同一套Locust脚本跑三轮,每轮60秒,200用户并发:
| 指标 | Flask基线 | FastAPI+SQL优化 | +Redis缓存 |
|---|---|---|---|
| QPS | 52 | 180 | 820 |
| P50延迟 | 620ms | 180ms | 42ms |
| P95延迟 | 1800ms | 350ms | 90ms |
| 数据库CPU | 98% | 45% | 15% |
| 平均SQL数/请求 | 42 | 3 | 0(命中缓存) |
第三轮优化后,数据库CPU从98%降到15%,瓶颈转移到应用层。如果想继续压榨,可以加Nginx gzip压缩响应体(JSON体积从80KB降到20KB,但CPU会增加,适合加在网关层)。
六、总结
几点真实经验:
- Profile是第一步,也是最重要的一步。我见过太多人凭感觉优化SQL,结果热点在序列化。用cProfile或PyCharm Profiler花10分钟看火焰图,比瞎猜强一百倍。
- ORM不是原罪,懒加载才是。SQLAlchemy 2.0的
selectinload和joinedload能解决90%的N+1问题,但记住:一次性加载数据量太大时反而慢(比如关联子表有几千条记录),这时要用lazy="raise"强制报错,防止代码偷偷懒加载。 - 缓存要防穿透和雪崩,但没有必要引入复杂的分布式锁。对于CRM这种读多写少的场景,最简单的
setex+ 随机过期时间就足够。 - FastAPI vs Flask:如果从零开始我肯定选FastAPI,异步支持太香了。但迁移成本要算清楚——我们这个项目花了半天改代码,但换来的是后续扩展更轻松。
最后,建议每个API在交付前至少跑一次压测,把P95和QPS记到文档里。不然下次改个字段,性能回退了都不知道。
优化工具清单:
- Profiling: cProfile + pstats, PyCharm Profiler
- 慢SQL排查: MySQL slow_query_log + EXPLAIN
- 压测: Locust(比ab和wrk更易用,支持Python脚本动态参数)
- 监控: Prometheus + Grafana(业务侧)
(文中代码已脱敏,实际生产环境请替换为真实业务字段)