一、问题背景:从一次线上告警说起

周一早上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。

三、方案设计:三步定位瓶颈

我的优化策略分三步:

  1. Profile先行:用cProfile和PyCharm Profiler抓取火焰图,找出CPU热点
  2. 数据库层优化:检查N+1查询、缺失索引、大字段排序
  3. 缓存兜底:对热点数据做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

踩坑记录

  1. 缓存穿透:当客户ID不存在时,每次都查DB。解决办法:缓存空值,过期时间设短(比如5秒)
  2. 缓存雪崩:所有key同时过期。在过期时间上加随机偏移:expire_time = 60 + random.randint(0, 30)
  3. 管道使用:一开始用for循环挨个get,200个客户要200次RTT。改用pipeline后,Redis耗时从15ms降到3ms
  4. 序列化陷阱: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会增加,适合加在网关层)。

六、总结

几点真实经验:

  1. Profile是第一步,也是最重要的一步。我见过太多人凭感觉优化SQL,结果热点在序列化。用cProfile或PyCharm Profiler花10分钟看火焰图,比瞎猜强一百倍。
  2. ORM不是原罪,懒加载才是。SQLAlchemy 2.0的selectinloadjoinedload能解决90%的N+1问题,但记住:一次性加载数据量太大时反而慢(比如关联子表有几千条记录),这时要用lazy="raise"强制报错,防止代码偷偷懒加载。
  3. 缓存要防穿透和雪崩,但没有必要引入复杂的分布式锁。对于CRM这种读多写少的场景,最简单的setex + 随机过期时间就足够。
  4. FastAPI vs Flask:如果从零开始我肯定选FastAPI,异步支持太香了。但迁移成本要算清楚——我们这个项目花了半天改代码,但换来的是后续扩展更轻松。

最后,建议每个API在交付前至少跑一次压测,把P95和QPS记到文档里。不然下次改个字段,性能回退了都不知道。


优化工具清单
- Profiling: cProfile + pstats, PyCharm Profiler
- 慢SQL排查: MySQL slow_query_log + EXPLAIN
- 压测: Locust(比ab和wrk更易用,支持Python脚本动态参数)
- 监控: Prometheus + Grafana(业务侧)

(文中代码已脱敏,实际生产环境请替换为真实业务字段)