1. 问题背景:线上监控告警,接口突然变慢

上周四下午,监控平台连续弹出3条告警:/api/v1/orders/detail 接口P95延迟突破2秒,错误率从0.2%飙到3.8%。查了Grafana,发现是数据库连接池被打满,大量请求在等连接。这个接口是订单详情,前端在用户点击“查看订单”时调用,日均请求量约200万,属于高频核心接口。

我的第一反应是“数据库慢查询”,但看了慢日志只有一条200ms的查询,不至于把连接池打满。于是开始系统排查。

2. 环境与版本

  • Python 3.10.8
  • FastAPI 0.95.1(Pydantic v2)
  • SQLAlchemy 2.0.15(async模式)
  • PostgreSQL 14.5(RDS,4核8G)
  • Redis 6.2(阿里云,256M)
  • 压测工具:wrk 4.2.0,单机8线程
  • 部署:2台4C8G ECS,K8s滚动发布

代码结构大致是:

app/
  api/v1/orders.py    # 路由
  services/order.py   # 业务逻辑
  models/order.py     # ORM模型

3. 方案设计:三步定位法

3.1 第一阶段:排除网络和基础设施问题

先确认不是网络或负载均衡问题。用wrk直接打到Pod IP,延迟依然高。排除。

3.2 第二阶段:应用层profiling

用cProfile跑一遍接口调用:

# 在测试环境执行
python -m cProfile -o profile.out -m uvicorn app.main:app --port 8001
# 用pstat查看热点
python -c "import pstats; pstats.Stats('profile.out').sort_stats('cumulative').print_stats(30)"

输出关键部分:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
    12   0.002    0.000    2.214    0.184  order.py:99(get_order_detail)
    12   0.001    0.000    1.987    0.166  models/order.py:45(load_items)
    86   0.004    0.000    1.521    0.018  sqlalchemy/orm/loading.py:112(_load)

看到了吧,load_items 占了近2秒,里面是循环查item表。典型的N+1查询:一个订单有10个商品,就查10次商品表,每次100ms,累计1秒。还有86次SQLAlchemy的加载调用,明显是懒加载导致的。

3.3 第三阶段:确认数据库侧表现

用pg_stat_statements看数据库侧,发现SELECT * FROM item WHERE id = $1被调用了上千次,单次最快8ms,但次数多。同时发现SELECT * FROM order_item WHERE order_id = $1只执行了一次,但返回了200行——单订单最多20个商品,怎么会返回200行?后来查了代码,发现联表查询没加distinct,笛卡尔积了。

4. 核心实现:三步修复

4.1 修复N+1查询:用joinedload替代懒加载

原代码(问题所在):

# services/order.py
async def get_order_detail(order_id: int, db: AsyncSession):
    order = await db.get(Order, order_id)
    # 懒加载:每次访问order.items都会触发SQL
    items = []
    for item in order.items:  # 这里触发N次查询
        item_detail = await db.get(Item, item.item_id)
        items.append({
            "item_id": item.item_id,
            "name": item_detail.name,
            "price": item_detail.price,
        })
    return {"order_id": order.id, "items": items}

修复后:

# services/order.py
from sqlalchemy.orm import joinedload
from sqlalchemy import select

async def get_order_detail(order_id: int, db: AsyncSession):
    # 一次性join查询,同时加载order和items
    result = await db.execute(
        select(Order)
        .options(joinedload(Order.items).joinedload(OrderItem.item))
        .where(Order.id == order_id)
    )
    order = result.scalar_one_or_none()
    if not order:
        return None
    items = [{
        "item_id": oi.item_id,
        "name": oi.item.name,
        "price": oi.item.price,
    } for oi in order.items]
    return {"order_id": order.id, "items": items}

这里有个细节:joinedload(Order.items).joinedload(OrderItem.item) 是链式加载,一次SQL搞定三层关系。SQLAlchemy 2.0的select语法比旧版query更清晰。

4.2 加Redis缓存:热点数据不再打数据库

订单详情属于读多写少,90%的请求是查看已完成的订单(状态不会变)。所以加了一层缓存:

# services/order.py
import json
from redis import asyncio as aioredis

redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)

CACHE_TTL = 300  # 5分钟

async def get_order_detail_cached(order_id: int, db: AsyncSession):
    cache_key = f"order:detail:{order_id}"
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    data = await get_order_detail(order_id, db)
    if data:
        # 只缓存已完成的订单,避免脏数据
        await redis_client.setex(cache_key, CACHE_TTL, json.dumps(data))
    return data

注意:缓存key要带版本号,防止代码升级后旧缓存结构不兼容。我踩过这个坑,后面详说。

4.3 序列化优化:Pydantic v2的model_dump_json

cProfile里还发现json.dumps占了不少时间。FastAPI默认用Pydantic v2,但如果你手写json.dumps,性能会差很多。改用Pydantic的序列化:

from pydantic import BaseModel

class OrderItemOut(BaseModel):
    item_id: int
    name: str
    price: float

class OrderDetailOut(BaseModel):
    order_id: int
    items: list[OrderItemOut]

# 返回时直接返回模型,FastAPI自动用model_dump_json序列化
return OrderDetailOut(order_id=order.id, items=items)

实测这个改动让序列化时间从120ms降到30ms,因为Pydantic v2是基于Rust的。

5. 踩坑与优化:三个坑,个个要命

坑1:Redis缓存击穿——缓存雪崩

上线后第一个周末,突然又告警了。查日志发现缓存过期时间集中在同一秒(因为下单时间相同)。大量请求同时回源数据库,连接池又满了。修复:在TTL上加随机偏移,CACHE_TTL + random.randint(0, 60)。另外加了互斥锁(详见代码),同一时间只允许一个线程重建缓存。

# 防击穿:用Redis的setnx做互斥
import time
lock_key = f"order:lock:{order_id}"
if await redis_client.setnx(lock_key, "1"):
    await redis_client.expire(lock_key, 5)  # 5秒锁
    try:
        data = await get_order_detail(order_id, db)
        await redis_client.setex(cache_key, CACHE_TTL, json.dumps(data))
    finally:
        await redis_client.delete(lock_key)
else:
    # 稍微等一下再读缓存
    await asyncio.sleep(0.1)
    return await redis_client.get(cache_key)

坑2:SQLAlchemy 2.0的session scope问题

async模式下,如果session在请求结束后才关闭,懒加载会报MissingGreenlet错误。必须确保AsyncSession在依赖注入中正确管理生命周期:

# app/deps.py
async def get_db():
    async with AsyncSession(engine) as session:
        yield session

坑3:wrk压测结果不稳定

第一次压测数据忽高忽低,排查发现是wrk的线程数没设对。单机8核,wrk应该用8线程,但连接数也要同步调大。最终参数:

wrk -t8 -c256 -d30s --latency http://your-api/orders/123

6. 效果数据:压测对比

修完所有问题,重新压测。相同环境(2台ECS,4C8G),30秒压测,结果如下:

指标 优化前 优化后 提升
QPS 45 234 5.2x
P50延迟 420ms 32ms 13x
P95延迟 2100ms 180ms 11.6x
错误率 3.8% 0% -

数据库侧:慢查询数从每分钟1200次降到接近0,连接池使用率从95%降到30%。Redis命中率稳定在87%,缓存占内存约80MB(订单详情平均2KB,缓存5分钟,约4万订单)。

7. 总结:调优checklist

这次调优花了3天,核心收获:

  1. 先profile再优化,别凭感觉。cProfile和py-spy是黄金搭档,py-spy能看生产环境在线进程。
  2. N+1查询是最常见的性能杀手,ORM的懒加载一定要警惕。用joinedloadselectinload
  3. 缓存一定要考虑击穿/雪崩,随机TTL + 互斥锁是标配。
  4. 序列化别用json.dumps,Pydantic v2的model_dump_json性能好很多。
  5. 压测参数要标准,wrk的线程数要和CPU核数对齐,连接数要足够。

最后贴一个通用排查命令,遇到API慢先跑一遍:

# 生产环境用py-spy dump现场
py-spy dump --pid 
# 或者用top看线程
top -H -p 
# 配合slow日志
grep "slow" /var/log/app.log | tail -20

如果以上都做了还慢,那就看数据库慢查询日志和连接池监控。大部分性能问题最后都落在SQL或缓存策略上。希望这篇对你有用,有不同意见欢迎评论区交流。