一、问题背景:一个“简单”的订单接口为何拖垮数据库
上周五下午,运营反馈后台订单列表页转圈超过3秒。我看了一下监控,/api/v1/orders 接口P95耗时458ms,高峰期直接飙到912ms,数据库CPU使用率92%。这个接口逻辑看起来很简单:查订单表、关联用户表、关联商品表,然后返回列表。但就是这样一个“简单”接口,成了整个系统的瓶颈。
我的第一反应是看慢查询日志,结果发现并没有特别慢的SQL——单条查询都在50ms以内。那就不是SQL本身的问题,而是查询次数太多了。
二、环境与版本:技术栈全景
先交代一下生产环境:
- Python 3.10.12
- FastAPI 0.104.1 + Uvicorn 0.24.0(workers=4)
- SQLAlchemy 2.0.23(async模式)+ asyncpg 0.29.0
- PostgreSQL 14.5(16核32G)
- Redis 7.0.12(单节点)
- 压测工具:wrk 4.2.0 + 自建脚本
服务部署在K8s,4个Pod副本,每个Pod限制2核4G。数据库是托管实例,连接池上限50。
三、方案设计:先用工具定位,别瞎猜
3.1 用py-spy做生产环境采样分析
慢查询日志没问题,那就得看应用层的调用堆栈。我用py-spy对生产Pod做了60秒采样:
# 找到目标Pod的PID
kubectl exec -it orders-api-7d8f9f6d8c-abcde -- py-spy dump --pid 1
采样结果让我很惊讶:OrderService.get_order_list 方法占了78%的CPU时间,其中大部分花在了 await session.execute() 上。这说明接口在循环里执行了多次查询——典型的N+1问题。
3.2 用cProfile分析单请求调用链
本地复现后,用cProfile跑了一次请求:
import cProfile
import pstats
from app.main import app
from httpx import AsyncClient
async def bench():
async with AsyncClient(app=app, base_url="http://test") as client:
for _ in range(50):
await client.get("/api/v1/orders?page=1&page_size=20")
profiler = cProfile.Profile()
profiler.enable()
import asyncio
asyncio.run(bench())
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(30)
关键输出:
ncalls tottime percall cumtime percall filename:lineno(function)
50 0.002 0.000 0.412 0.008 orders.py:56(get_order_list)
500 0.001 0.000 0.356 0.001 orders.py:87(get_user_by_id)
500 0.001 0.000 0.298 0.001 orders.py:102(get_product_by_id)
看到了吧:50次请求,500次用户查询、500次商品查询。每个订单都单独查一次用户和商品,这就是问题的根源。
四、核心实现:三步优化
4.1 第一步:SQLAlchemy联合查询消除N+1
原来的代码是循环里查关联表,我改成了一次性join:
# 优化前:循环查询
async def get_order_list_before(db: AsyncSession, page: int, page_size: int):
result = await db.execute(
select(Order).order_by(Order.created_at.desc())
.offset((page - 1) * page_size).limit(page_size)
)
orders = result.scalars().all()
items = []
for order in orders: # 每次循环2次查询,20条订单就是40次
user = await db.get(User, order.user_id)
product = await db.get(Product, order.product_id)
items.append({
"order_id": order.id,
"user_name": user.name,
"product_name": product.name,
"amount": order.amount,
})
return items
# 优化后:一次性join
async def get_order_list_after(db: AsyncSession, page: int, page_size: int):
stmt = (
select(Order, User, Product)
.join(User, Order.user_id == User.id)
.join(Product, Order.product_id == Product.id)
.order_by(Order.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
rows = result.all() # 一次查询搞定
return [
{
"order_id": order.id,
"user_name": user.name,
"product_name": product.name,
"amount": order.amount,
}
for order, user, product in rows
]
这一步改动后,数据库查询次数从41次降到1次。本地压测P95从458ms降到了112ms,效果立竿见影。
4.2 第二步:Redis缓存热点数据
查询次数少了,但用户表和商品表的数据其实是相对稳定的。我加了一层Redis缓存,TTL设为10分钟:
import json
import redis.asyncio as aioredis
redis_client = aioredis.from_url(
"redis://redis-service:6379/0",
max_connections=20,
decode_responses=True
)
async def get_user_cached(user_id: int) -> dict | None:
cache_key = f"user:info:{user_id}"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 从数据库查询(这里省略,假设用SQLAlchemy)
user = await fetch_user_from_db(user_id)
if user:
await redis_client.set(cache_key, json.dumps(user), ex=600) # 10分钟过期
return user
注意:缓存只加在用户和商品信息上,订单数据没有缓存,因为订单实时性要求高,而且量大不适合全量缓存。
4.3 第三步:引入lru_cache做进程内缓存
Redis缓存减少了跨网络调用,但每次都走一次Redis协议也有开销。对于用户信息这种几乎不变的场景,我直接用Python内置的functools.lru_cache做了进程内缓存:
from functools import lru_cache
@lru_cache(maxsize=1024)
def get_user_info_sync(user_id: int) -> dict:
# 注意:这是同步函数,实际使用时要结合线程池
# 或者用async_lru库
return {
"id": user_id,
"name": f"user_{user_id}",
}
# 对于异步场景,我用了async_lru库
from async_lru import alru_cache
@alru_cache(maxsize=1024, ttl=300) # 5分钟过期
async def get_user_info_async(user_id: int) -> dict:
# 先查Redis,再查DB
user = await get_user_cached(user_id)
return user
这里踩了一个坑:lru_cache不能直接用于async函数,会报TypeError: the wrapped function is no longer a coroutine。必须用async_lru库或者自己封装。
五、踩坑与优化:版本兼容和缓存一致性
5.1 坑一:asyncpg与SQLAlchemy2.0的兼容问题
升级SQLAlchemy到2.0后,原来用的await session.query()全部废弃了,必须改成select()语法。这个迁移花了我半天时间,主要是各种关联查询的写法变了。
5.2 坑二:缓存雪崩和击穿
加Redis缓存后,遇到一个问题:用户信息缓存在同一时间过期,导致大量请求同时穿透到数据库。解决方案是给TTL加随机抖动:
import random
ttl = 600 + random.randint(0, 120) # 10分钟±2分钟随机
await redis_client.set(cache_key, json.dumps(user), ex=ttl)
5.3 坑三:Uvicorn workers与进程内缓存
lru_cache是进程级别的,4个worker意味着每个进程都有独立的缓存。这会导致缓存不一致,但用户信息这种场景影响不大。如果需要严格一致,可以用cachetools配合Redis做分布式锁。
六、压测数据:优化前后对比
最终用wrk做了压测,命令如下:
wrk -t8 -c100 -d30s --latency http://localhost:8000/api/v1/orders?page=1&page_size=20
结果对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P50延迟 | 312ms | 28ms | 11.1x |
| P95延迟 | 458ms | 39ms | 11.7x |
| P99延迟 | 912ms | 87ms | 10.5x |
| 吞吐量 | 320 req/s | 2100 req/s | 6.6x |
| DB CPU | 92% | 23% | -75% |
| 每请求DB查询次数 | 41 | 1 | 41x |
从数据看,优化效果非常显著。核心收益来自两点:联合查询消除了N+1,缓存消除了重复计算和网络IO。
七、总结:性能调优的黄金法则
这次调优让我深刻体会到几件事:
- 先profile再优化,不要凭感觉猜瓶颈。py-spy和cProfile让我十分钟就定位到了N+1问题。
- 数据库查询优化是第一步,缓存是第二步。如果SQL本身有问题,加再多缓存也白搭。
- 缓存要分层:进程内缓存(LRU) > Redis缓存 > 数据库查询,每一层的延迟差一个数量级。
- 注意缓存过期策略,随机TTL能有效防止雪崩。
最后说一句:这个接口的QPS峰值也就2000左右,如果你遇到的是几万QPS的场景,那可能还需要引入消息队列削峰、读写分离等更复杂的架构。但不管多复杂的系统,调优的思路都是一样的:先定位,再优化,用数据说话。
代码仓库:github.com/yourname/orders-api-tuning(示例代码已脱敏)
延伸阅读:如果你对py-spy不熟悉,强烈建议看看它的官方文档。生产环境调试神器,比加日志定位问题快十倍。