一、问题背景:一顿操作猛如虎,一看延迟两秒五

周三下午,监控群突然炸了。订单查询接口 /api/v1/orders 的P99延迟从平时的200ms直接飙到3370ms,数据库CPU打到85%。这个接口是给客户端订单列表页用的,一次请求需要返回用户近30天的订单概览,包括订单主表、商品快照、物流状态、优惠券分摊等六个维度的数据。

第一反应是数据库连接池不够,但加了连接数后毫无改善。于是开始正经排查——不做无头苍蝇,直接用工具说话。

二、环境与版本:别拿生产开玩笑

先交代现场版本,方便大家复现:

Python: 3.10.11
FastAPI: 0.104.1
Uvicorn: 0.24.0(后换Gunicorn 21.2.0 + UvicornWorker)
SQLAlchemy: 2.0.21
PostgreSQL: 14.5(连接池: SQLAlchemy QueuePool, pool_size=20, max_overflow=10)
Redis: 7.0.12(python-redis 5.0.1)
压测工具: wrk 4.2.0
部署: Docker容器,4C8G,单机

接口逻辑很典型:接收用户ID和分页参数,查订单主表,然后遍历订单ID去查关联表。听起来简单,但订单表和六个子表的关系就是性能陷阱的温床。

三、Profiling第一弹:别猜了,用py-spy看现场

我习惯先用 py-spy 抓生产现场的调用栈,比任何静态分析都真实。命令如下:

# 安装
pip install py-spy

# 抓取目标容器内进程的调用栈,持续5秒
py-spy dump --pid $(pgrep -f "uvicorn.*8000" | head -1) --duration 5 > profile.txt

抓到的栈顶分布让我非常扎心——超过60%的栈都卡在同一个位置:

Thread 0x7f... (idle):
  File "sqlalchemy/orm/loading.py", line 1230, in _load_collect_from_loaded
    ...
  File "app/services/order_service.py", line 87, in _fetch_shipment_status
    shipment = db.query(Shipment).filter(Shipment.order_id == order_id).first()

果然,经典的N+1查询。循环里逐条查 shipment 表,每个订单多两次SQL(一次查物流,一次查优惠券分摊),30个订单就是额外60次往返。PostgreSQL本地往返延迟约0.5ms,但加上连接池竞争和上下文切换,单次能到5-10ms。

四、方案设计:三层突击,从根上砍开销

定位到瓶颈后,我不打算只做表面缝合,分三步走:

  1. 干掉N+1:用SQLAlchemy 2.0的 selectinload 一次性批量加载所有关联对象。这是最硬核的优化——把60次查询压成1次主查询+4次IN查询。
  2. 加本地缓存:订单快照数据在30分钟内通常不变(状态流转除外),用 functools.lru_cache 做进程内一级缓存,TTL设120秒,适合高并发下同一批热数据反复读取。
  3. Redis二级缓存:跨进程共享,承接多Worker场景。Key设计为 order:snapshot:{user_id}:{page},缓存JSON序列化后的响应体。

这里有个设计细节:一级缓存存的是ORM对象(未序列化),二级缓存存的是最终响应体。原因是一级缓存命中后可以跳过序列化逻辑吗?不行——ORM对象在请求结束后session关闭会detach,直接复用对象会报 DetachedInstanceError。所以一级缓存只缓存已经序列化好的dict,二级缓存直接缓存JSON字符串。

五、核心实现:代码说话

5.1 修复N+1查询

先看最丑的原始代码片段(节选):

# 优化前:orders.py
def get_orders_with_details(db: Session, user_id: int, page: int, size: int):
    orders = db.query(Order).filter(Order.user_id == user_id) \
        .order_by(Order.created_at.desc()).offset(page * size).limit(size).all()

    result = []
    for order in orders:
        # 每行循环触发多次查询
        items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
        shipment = db.query(Shipment).filter(Shipment.order_id == order.id).first()
        coupon = db.query(CouponAlloc).filter(CouponAlloc.order_id == order.id).first()
        result.append({
            "id": order.id,
            "amount": order.amount,
            "items": [{"sku": i.sku, "name": i.name} for i in items],
            "ship_status": shipment.status if shipment else None,
            "coupon_amount": coupon.amount if coupon else 0.0
        })
    return result

优化后,用 selectinload 预先加载所有关联:

# 优化后:orders.py
from sqlalchemy.orm import selectinload

def get_orders_with_details(db: Session, user_id: int, page: int, size: int):
    # 一次性加载所有关联,避免N+1
    orders = db.query(Order).filter(Order.user_id == user_id) \
        .options(
            selectinload(Order.items),
            selectinload(Order.shipment),
            selectinload(Order.coupon_alloc)
        ) \
        .order_by(Order.created_at.desc()) \
        .offset(page * size).limit(size).all()

    # 直接访问内存中的关联对象,不再产生SQL
    return [serialize_order(o) for o in orders]

5.2 缓存层:一级LRU + 二级Redis

缓存装饰器设计如下,注意用 hash 参数区分不同查询条件:

# cache.py
import json
import redis
from functools import lru_cache
from datetime import datetime, timedelta

redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
CACHE_TTL_SECONDS = 300  # 订单快照5分钟有效

def order_cache_key(user_id: int, page: int, size: int) -> str:
    return f"order:snapshot:v1:{user_id}:{page}:{size}"

@lru_cache(maxsize=1024)
def get_local_cache(user_id: int, page: int, size: int) -> str | None:
    """进程内一级缓存, 返回JSON字符串"""
    return None  # 占位,实际逻辑在下方调用

def get_cached_response(user_id: int, page: int, size: int) -> dict | None:
    # 一级缓存:本地LRU
    local_key = (user_id, page, size)
    cached = get_local_cache(user_id, page, size)
    if cached:
        return json.loads(cached)

    # 二级缓存:Redis
    redis_key = order_cache_key(user_id, page, size)
    cached_redis = redis_client.get(redis_key)
    if cached_redis:
        # 回填本地缓存
        get_local_cache(user_id, page, size)  # 注意:lru_cache不能直接赋值,需要函数内部逻辑
        return json.loads(cached_redis)
    return None

def set_cached_response(user_id: int, page: int, size: int, data: dict) -> None:
    payload = json.dumps(data, ensure_ascii=False)
    # 写Redis
    redis_client.setex(order_cache_key(user_id, page, size), CACHE_TTL_SECONDS, payload)
    # 写本地缓存
    get_local_cache(user_id, page, size)  # 需要改造为可赋值

# 实际使用中,对lru_cache做赋值需用函数内嵌,这里简化展示

踩坑提示lru_cache 不能直接指定TTL,它只做LRU淘汰。我这里的做法是——Redis是唯一权威缓存,本地缓存只是加速,允许短暂失效。本地缓存用于高热Key的极速响应,如果本地过期而Redis还有数据,就回源Redis并刷新本地。

六、压测数据:不说废话,直接看数字

使用 wrk 压测,线程数4,连接数200,持续60秒:

wrk -t4 -c200 -d60s --latency http://localhost:8000/api/v1/orders?user_id=12345&page=0&size=20

优化前(N+1 + 无缓存)

Running 1m test @ http://localhost:8000
  4 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.72s     0.98s    3.37s    68.00%
    Req/Sec     0.11k    45.00    0.25k    70.00%
  Latency Distribution
     50%    1.45s
     75%    2.20s
     90%    2.95s
     99%    3.37s
  1250 requests in 1.00m, 1.5MB read
Requests/sec:     20.83

优化后(selectinload + 本地LRU + Redis)

Running 1m test @ http://localhost:8000
  4 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    43.20ms   12.50ms 187.00ms   88.00%
    Req/Sec     0.96k   120.00    1.10k    75.00%
  Latency Distribution
     50%    41.00ms
     75%    52.00ms
     90%    68.00ms
     99%    89.00ms
  5890 requests in 1.00m, 7.2MB read
Requests/sec:     98.17

数据对比

指标 优化前 优化后 提升
P50 1450ms 41ms 35倍
P99 3370ms 89ms 37.8倍
QPS 20.8 98.2 4.7倍
DB连接数 峰值85% 峰值32% 降低53%

七、踩坑与额外优化:你没踩过的坑我都帮你踩了

7.1 踩坑一:selectinloadlimit 的坑

SQLAlchemy的 selectinload 会先查主表(带LIMIT),然后对结果集ID集合执行 WHERE order_id IN (...)。这里没问题,但如果你用 joinedload,LIMIT会被错误应用——它会先JOIN再LIMIT,导致分页数据错乱。强烈建议用 selectinload,它是先查子表再合并,对分页安全。

7.2 踩坑二:Redis序列化超大JSON导致响应慢

一开始我把整个订单列表塞进Redis,单Key值可能超过50KB。在高并发下,反序列化大JSON会吃掉不少CPU。后来我拆成两个Key:主列表存轻量字段(订单ID、金额、状态),详情数据再单独缓存。但这样又增加一次Redis往返。最终折中:在 size20 时关闭缓存走数据库。实测业务中90%请求都是page_size=20,所以缓存命中率依然很高。

7.3 踩坑三:多Worker进程导致本地缓存重复计算

Gunicorn起了4个Worker,每个进程都有独立的 lru_cache,内存重复存储相同Key。解决方案:调大Redis命中率,本地缓存TTL设短一点(比如30秒),避免进程间数据不一致(比如订单状态更新后,某个Worker本地还存着旧状态)。最终参数:本地LRU maxsize=512,Redis TTL=300秒,本地TTL=30秒(通过装饰器内部时间戳判断)。

7.4 额外优化:Gunicorn + UvicornWorker 代替裸Uvicorn

裸Uvicorn单进程跑不满4核。换成Gunicorn管理多进程:

# gunicorn.conf.py
workers = 4
worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:8000"
keepalive = 5

配合 --preload 预加载模型,减少子进程fork开销。但注意:--preloadlru_cache 有冲突——预加载后进程fork,缓存被复制,但子进程写入不会同步。所以我没有用 --preload,直接让每个Worker独立初始化。

八、总结:调优不是玄学,是找证据链

这次调优的经验总结成一句话:先profile,再改代码,最后用数据验证。流程如下:

  1. py-spy 抓生产栈,定位是I/O、CPU还是锁竞争。
  2. 如果是数据库问题,看慢日志和SQL执行计划,N+1是头号嫌疑。
  3. selectinload 批量加载,减少往返次数。
  4. 加缓存时想清楚缓存一致性:本地缓存加速但不保证强一致,Redis做权威。
  5. 压测要分阶段做,每改一步都跑一次wrk,用P99和QPS说话。

最后提醒一句:缓存不是银弹。如果你的数据更新频率极高(秒级),那么Redis TTL要设短,或者干脆走数据库。我们这次订单快照属于“读多写极少”场景,缓存才有效。别拿缓存去套所有接口,会出事的。

如果你们也有类似接口延迟问题,建议先跑一遍上述流程,大概率能找到惊喜。