一、问题背景:一个本该很简单的列表接口

事情是这样的,上周三下午,运维同学在企业微信里@我:"生产环境的报表服务响应变慢了,监控面板上一片红,特别是那个/api/v1/orders/summary接口,平均响应时间已经1200ms了,并且还在涨。"

我赶紧打开Grafana看了一眼,确实,这个接口的QPS只有50左右,但P99延迟已经突破了2秒。更离谱的是,数据库连接池的使用率达到了100%,大量请求在等待获取连接。

这个接口的逻辑本身很简单:根据时间范围聚合订单数据,返回每日的订单量和销售额。按理说,这种聚合查询不应该这么慢。唯一的嫌疑点就是——它查询的orders表已经有2800万行数据了。

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

先交代一下具体的环境版本,方便大家复现和参考:

- Python: 3.11.5
- FastAPI: 0.104.1
- Uvicorn: 0.24.0 (workers=4, keepalive=60)
- SQLAlchemy: 2.0.21
- PostgreSQL: 15.3 (16核32G, SSD)
- Redis: 7.2.1 (单实例, maxmemory 4GB)
- 压测工具: wrk 4.2.0
- Profiling: py-spy 0.3.14, PostgreSQL pg_stat_statements

服务器是8核16G的容器,一共部署了4个uvicorn worker。数据库是独立的RDS实例。

三、第一轮定位:Py-Spy直接抓现场

遇到性能问题,我从来不猜,直接上Profiling工具。这里我强烈推荐 py-spy,它是一个采样型profiler,不需要改代码,直接附加到运行中的进程上就行。

先找到PID,然后执行:

# 附加到最忙的那个worker进程上,采样30秒
py-spy dump --pid 12345 --duration 30 > profile_output.txt

# 或者生成火焰图
py-spy record --pid 12345 --duration 30 --output profile.svg

采样结果让我有点意外。CPU并没有消耗在SQLAlchemy的ORM映射层,而是大量消耗在PostgreSQL驱动的等待上

再配合数据库侧的pg_stat_statements查看慢查询:

SELECT query, calls, total_exec_time, mean_exec_time 
FROM pg_stat_statements 
ORDER BY total_exec_time DESC 
LIMIT 5;

结果把我逗笑了——排第一的那个查询,平均执行时间980ms,总调用次数15000次。SQL长这样(脱敏后):

SELECT date(created_at) as day, count(*) as order_cnt, sum(amount) as total_amount
FROM orders
WHERE created_at >= $1 AND created_at  str:
    """生成稳定的缓存key,参数排序避免乱序"""
    sorted_params = json.dumps(params, sort_keys=True, ensure_ascii=False)
    hash_val = hashlib.md5(sorted_params.encode()).hexdigest()
    return f"{prefix}:{hash_val}"

async def get_cached_or_origin(key: str, ttl: int, fetch_func):
    """缓存穿透保护:用set nx ex 实现分布式锁,防止缓存击穿"""
    # 先查缓存
    cached = await redis_client.get(key)
    if cached:
        return json.loads(cached)

    # 缓存未命中,加锁防止并发打到数据库
    lock_key = f"lock:{key}"
    acquired = await redis_client.set(lock_key, "1", nx=True, ex=5)
    if not acquired:
        # 拿不到锁,短暂等待后重试一次
        await asyncio.sleep(0.05)
        cached = await redis_client.get(key)
        if cached:
            return json.loads(cached)
        raise HTTPException(status_code=503, detail="服务繁忙,请重试")

    try:
        # 只有拿到锁的请求才会查数据库
        data = await fetch_func()
        await redis_client.setex(key, ttl, json.dumps(data))
        return data
    finally:
        await redis_client.delete(lock_key)

然后在路由里这样用:

# main.py
from fastapi import FastAPI, Depends
from datetime import datetime, timedelta

app = FastAPI(title="Report Service", version="2.3.0")

@app.get("/api/v1/orders/summary")
async def get_order_summary(start: datetime, end: datetime):
    # 参数校验:最大查询范围不能超过31天
    if end - start > timedelta(days=31):
        raise HTTPException(status_code=400, detail="查询范围不能超过31天")

    params = {"start": start.isoformat(), "end": end.isoformat()}
    cache_ttl = 300  # 5分钟缓存

    async def fetch_from_db():
        # 这里使用同步engine,但通过run_in_threadpool避免阻塞event loop
        def _query():
            with SessionLocal() as session:
                result = session.execute(
                    text("""
                        SELECT date(created_at) as day, 
                               count(*) as order_cnt, 
                               sum(amount) as total_amount
                        FROM orders
                        WHERE created_at >= :start AND created_at < :end
                        GROUP BY date(created_at)
                        ORDER BY day
                    """),
                    {"start": start, "end": end}
                )
                return [dict(row) for row in result]

        return await run_in_threadpool(_query)

    key = make_cache_key("order_summary", params)
    return await get_cached_or_origin(key, cache_ttl, fetch_from_db)

这里有个关键点:run_in_threadpool是FastAPI提供的工具,它会把同步函数丢给anyio的线程池去跑,从而不阻塞event loop。实测下来,配合pool_size=20的同步引擎,性能优于纯异步SQLAlchemy(因为异步驱动在复杂聚合查询上反而有额外开销)。

七、压测数据与最终效果

最后用wrk做了三轮压测,每轮60秒,分别对应优化前、加索引后、加索引+Redis后:

wrk -t8 -c100 -d60s --latency http://localhost:8000/api/v1/orders/summary?start=2024-01-01&end=2024-01-07
场景 平均延迟 P95 P99 QPS CPU使用率
优化前(无索引) 1150ms 1980ms 2350ms 85 92%
加索引后 180ms 320ms 480ms 320 55%
加索引+Redis 35ms 68ms 120ms 1560 28%

最终线上实际数据:在QPS 50的常态流量下,P99从2000ms+降到了80ms左右,数据库连接池使用率从100%降到了15%。Redis命中率稳定在95%以上,内存占用不到200MB。

八、总结与反思

这次调优的核心教训有三点:

  1. 永远先profile再优化。我见过太多人一上来就改代码,结果改了半天发现瓶颈在数据库。py-spy + pg_stat_statements是黄金组合。
  2. 索引是性价比最高的优化手段。一条CREATE INDEX语句,解决了70%的问题。
  3. 缓存要防击穿和雪崩。我用的是单机Redis + SET NX EX分布式锁,虽然简单,但足够应对这个场景。如果QPS再高一个量级,可能需要考虑Redis Cluster和更细粒度的缓存策略。

另外有个小细节:生产环境我最终没有用asyncpg驱动,而是用了psycopg2 + 线程池。原因是在这个聚合查询场景下,async驱动的性能优势不明显,反而在连接管理上更容易出问题。工具没有绝对的优劣,关键看场景。

这次优化从发现问题到上线,总共花了4个小时。希望这篇文章能帮你少走一些弯路。


作者注:文中所有代码和SQL都经过脱敏处理,实际生产环境中的表结构、索引策略请根据数据分布自行调整。如果你有更好的方案,欢迎评论区交流。