1. 问题背景:一个“简单”的聚合接口为何这么慢?

事情是这样的,上周运维同事反馈说生产环境有个接口经常超时,我一看监控面板,好家伙——/api/v1/orders/summary 这个接口在100并发下P95耗时1180ms,数据库CPU直接飙到80%,再往上压就要雪崩了。

这个接口逻辑看起来很简单:根据用户ID查询订单列表,然后按状态分组统计数量。数据库表也就几十万行,按理说不该这么慢。

环境说明:
- Python 3.11.2
- FastAPI 0.104.1 + Uvicorn 0.24.0
- SQLAlchemy 2.0.23(ORM模式)
- PostgreSQL 14.5(单机,16核32G)
- Redis 7.0(单机)

2. 第一步:用Profiling工具找出“真凶”

很多同学遇到接口慢,第一反应是“加缓存”或者“加索引”,但我建议先做Profiling,别拍脑袋。

我用的是两板斧:

2.1 cProfile(函数级CPU耗时)

python -m cProfile -o profile.out -s cumulative your_server.py

然后写个小脚本触发接口:

import requests
import pstats
from cProfile import Profile
from pstats import SortKey

# 先启动服务,然后压测脚本请求接口
# 这里我们直接分析profile.out文件
p = pstats.Stats('profile.out')
p.strip_dirs().sort_stats(SortKey.CUMULATIVE).print_stats(20)

关键输出如下:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
100     0.003    0.000    0.982    0.010   /app/api/orders.py:45(get_summary)
100     0.002    0.000    0.861    0.009   /app/services/order_service.py:33(fetch_orders)
100     0.001    0.000    0.721    0.007   /app/models/order.py:56(load_related_items)

分析load_related_items 这个函数占了大头。点进去一看,好家伙,这是个SQLAlchemy的relationship,默认是懒加载(lazy=True)。在循环里访问order.items,每次触发一条SELECT,这就是经典的N+1查询问题

2.2 py-spy(线程级定位卡点)

有时候cProfile看不到网络IO等阻塞,我习惯再用py-spy抓一下:

# 安装
pip install py-spy

# 对运行中的进程采样30秒
py-spy record --pid 12345 -o py_spy.svg --duration 30

生成的火焰图显示,有55%的时间卡在psycopg2socket_connect上。说明数据库连接池也出问题了——每次请求都新建连接,没有复用。

3. 第二步:数据库查询优化(SQL + ORM)

3.1 修复N+1查询

原代码(问题版):

# app/services/order_service.py
from sqlalchemy.orm import Session

def fetch_orders(db: Session, user_id: int):
    orders = db.query(Order).filter(Order.user_id == user_id).all()
    # 这里在循环里访问 relationship,触发N+1
    result = []
    for o in orders:
        items = o.items  # 这里每个order发一次SELECT
        result.append({
            'order_id': o.id,
            'status': o.status,
            'items': [{'sku': it.sku, 'qty': it.qty} for it in items]
        })
    return result

优化后:

# app/services/order_service.py
from sqlalchemy.orm import Session, joinedload

def fetch_orders_optimized(db: Session, user_id: int):
    # 使用joinedload一次性join出items,避免N+1
    orders = db.query(Order).options(
        joinedload(Order.items)
    ).filter(Order.user_id == user_id).all()
    return orders

3.2 添加联合索引

之前表里只有主键索引和user_id单列索引,查询条件WHERE user_id = ?其实已经走了索引,但排序和JOIN还是慢。加个联合索引:

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_status
ON orders (user_id, status) INCLUDE (id, created_at);

这里我用了INCLUDE而不是普通多列索引,因为PostgreSQL 11+支持覆盖索引,对于只需要回表取极少字段的查询能省一次IO。

3.3 连接池配置

FastAPI + SQLAlchemy默认连接池参数太保守了(pool_size=5, max_overflow=10)。我压测时发现连接排队严重,调整如下:

# app/db.py
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool

engine = create_engine(
    "postgresql://user:pass@localhost/db",
    poolclass=QueuePool,
    pool_size=20,
    max_overflow=10,
    pool_timeout=30,
    pool_pre_ping=True,  # 防止死连接
    echo=False
)

注意pool_pre_ping=True 很重要,否则MySQL/PostgreSQL空闲8小时断连后,连接池里的连接全部失效。

4. 第三步:Redis缓存策略(命中率85%)

数据库优化之后,P95从1180ms降到了340ms,但还是不够。因为这个接口是报表类,90%的请求查的都是最近一天的汇总数据,热点非常集中。

我的思路:按用户+日期做两级缓存,热点数据缓存60秒,冷数据缓存5秒

# app/cache/order_cache.py
import json
import redis
from fastapi import Request

redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

CACHE_TTL_HOT = 60   # 热点缓存60秒
CACHE_TTL_COLD = 5   # 冷数据5秒

def get_summary_cache(user_id: int, date_str: str):
    """先查Redis,命中直接返回"""
    key = f"summary:{user_id}:{date_str}"
    cached = redis_client.get(key)
    if cached:
        return json.loads(cached)
    return None

def set_summary_cache(user_id: int, date_str: str, data: dict, is_hot: bool):
    key = f"summary:{user_id}:{date_str}"
    ttl = CACHE_TTL_HOT if is_hot else CACHE_TTL_COLD
    redis_client.setex(key, ttl, json.dumps(data))

# 判断是否热点用户(比如最近1小时下过单的活跃用户)
def is_hot_user(user_id: int) -> bool:
    # 这里用Redis的SETBIT或者HyperLogLog,简单起见用ZSET记录活跃度
    return redis_client.zscore('active_users', str(user_id)) is not None

然后在FastAPI路由里接入:

# app/api/orders.py
from fastapi import APIRouter, Depends
from app.cache.order_cache import get_summary_cache, set_summary_cache, is_hot_user

router = APIRouter()

@router.get("/v1/orders/summary")
async def get_summary(user_id: int, date: str):
    # 1. 查缓存
    cached = get_summary_cache(user_id, date)
    if cached:
        return {"data": cached, "source": "cache"}

    # 2. 查数据库
    data = await query_db(user_id, date)

    # 3. 写缓存
    is_hot = is_hot_user(user_id)
    set_summary_cache(user_id, date, data, is_hot)

    return {"data": data, "source": "db"}

踩坑提醒
一开始我用了cachetools.TTLCache做本地缓存,结果多进程下每个worker都有自己的缓存,命中率只有40%。改成Redis后统一了,命中率稳定在85%左右。

5. 压测数据对比:wrk + 真实流量回放

压测工具用wrk,100并发,持续60秒:

wrk -t8 -c100 -d60s --latency http://localhost:8000/v1/orders/summary?user_id=10001&date=2024-06-01

优化前(基线)

Requests/sec:    210
Latency Distribution
  50%    610ms
  75%    890ms
  90%   1040ms
  95%   1180ms
  99%   1450ms

优化后(DB优化 + Redis缓存)

Requests/sec:   2400
Latency Distribution
  50%     45ms
  75%     68ms
  90%     82ms
  95%     90ms
  99%    120ms

数据库监控:
- 优化前:CPU 80%,慢查询日志每条平均200ms
- 优化后:CPU 12%,慢查询日志消失了(>100ms的查询为0)

缓存命中率:85%(监控面板显示hit_rate=0.85

6. 总结与踩坑清单

这次调优下来,核心收益是P95从1180ms降到90ms,QPS提升11倍。整理几个关键点:

  1. Profiling先行:别猜,用cProfile/py-spy看数据。这次如果不做Profile,大概率会去加索引或者换ORM,根本解决不了N+1问题。
  2. ORM的懒加载是隐形杀手:SQLAlchemy的lazy=True在循环里访问关系属性,会产生大量单条SELECT。用joinedload或者selectinload根治。
  3. 连接池一定要调:默认参数只适合开发环境。生产环境需要根据并发量调整pool_sizemax_overflow
  4. 缓存要分级:热点数据TTL长一点,冷数据短一点。用Redis统一缓存,别用本地缓存搞多进程不一致。
  5. 压测要固定请求分布:这次压测我用了真实用户的user_id分布,而不是随机数,否则缓存命中率会虚高。

最后说一句:性能调优不是玄学,是科学。每一步都要有数据支撑,改完一定要回归压测。如果你也在调类似服务,欢迎留言交流细节。

(完)