1. 问题背景:一个“看似正常”的慢接口
前阵子接手一个订单服务,线上监控面板显示/api/v1/orders/summary接口的P95延迟在750-800ms之间徘徊,且随着业务增长还在恶化。这个接口的作用是返回用户最近30天的订单统计(总金额、订单数、商品TOP5)。业务方反馈“页面转圈超过1秒”,用户流失率明显上升。
一开始我怀疑是数据库慢查询,但看了pg_stat_statements发现单条SQL都在50ms以内。后来用wrk压测才发现,问题不在单条SQL,而在于请求量上来后,ORM的N+1查询和Python对象构造开销被无限放大。
2. 环境与版本说明
| 组件 | 版本 |
|---|---|
| Python | 3.11.6 |
| FastAPI | 0.104.1 |
| Uvicorn | 0.24.0 (workers=4) |
| SQLAlchemy | 2.0.23 |
| asyncpg | 0.29.0 |
| PostgreSQL | 15.3 |
| Redis | 7.2.1 |
| 压测工具 | wrk 4.2.0 / py-spy 0.3.14 |
压测环境为4C8G的云主机,PostgreSQL和Redis部署在同一VPC内,网络延迟= datetime.now() - timedelta(days=30))
.order_by(Order.created_at.desc())
)
orders = (await session.execute(stmt)).scalars().all()
# 聚合逻辑在Python里做,数据量不大(通常<100条订单)
total_amount = sum(o.total_amount for o in orders)
...
return {"total_amount": total_amount, "order_count": len(orders), "top_products": top_products}
**坑1:** 只加`selectinload`还是不够。查看生成的SQL发现,SQLAlchemy默认会对`OrderItem`做`WHERE order_id IN (...)`,但如果有100个订单,这个IN列表会很长。解决方法是给`OrderItem`表加`(order_id, product_id)`的复合索引,让IN查询走索引。
#### 5.2 Redis缓存层
```python
# app/services/order_service.py
import json
import redis.asyncio as redis
redis_client = redis.Redis(host='redis-cache', port=6379, decode_responses=True, socket_connect_timeout=1)
async def get_order_summary(user_id: int):
cache_key = f"order_summary:v1:{user_id}"
# 尝试读缓存
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 查数据库
async with async_session() as session:
data = await get_summary_data(session, user_id)
# 写缓存,TTL设置为300秒
# 注意:使用setex避免并发时重复查库
await redis_client.setex(cache_key, 300, json.dumps(data))
return data
坑2(缓存雪崩): 最初我设置了全局TTL=300秒,结果所有key同时过期,数据库瞬间被打爆。后来改为TTL加上随机偏移:300 + random.randint(0, 60)秒,让过期时间分散。
5.3 PostgreSQL索引优化
-- 迁移脚本
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);
-- 同时给order_items加复合索引
CREATE INDEX CONCURRENTLY idx_order_items_order_prod
ON order_items (order_id, product_id);
注意:CONCURRENTLY避免锁表,但会消耗更多IO,建议在低峰期执行。
6. 效果数据:调优前后对比
| 指标 | 调优前 | 调优后 | 提升 |
|---|---|---|---|
| P50延迟 | 520ms | 45ms | 91.3% |
| P95延迟 | 780ms | 82ms | 89.5% |
| P99延迟 | 1.2s | 150ms | 87.5% |
| QPS(wrk -c100) | 220 | 1850 | 8.4倍 |
| 数据库QPS | 3400(含N+1) | 480(预加载+缓存) | 85.9% |
| Redis命中率 | 0% | 92.3% | - |
压测输出示例:
# 调优后
Running 30s test @ http://localhost:8000/api/v1/orders/summary?user_id=12345
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 48.2ms 12.5ms 180.3ms 86.2%
Req/Sec 462.3 35.8 512.0 90.0%
Latency Distribution
50% 45.0ms
75% 58.0ms
90% 72.0ms
99% 150.0ms
55452 requests in 30.00s, 12.1MB read
Requests/sec: 1848.4
7. 总结与反思
这次调优花了半天时间,最大的感悟是:Python服务性能瓶颈往往不在语言本身,而在于IO模式和对象构造开销。cProfile + py-spy的组合能快速定位问题,而不是靠猜。
两个建议:
1. 不要过早优化:先用profiling工具确认瓶颈,再动手改。
2. 缓存必须做“防雪崩”设计:TTL随机化、多级缓存(本地+Redis)、降级开关,缺一不可。
另外,SQLAlchemy 2.0的selectinload效率确实比旧版的joinedload好,尤其在多对多场景下。如果追求极致性能,可以直接用asyncpg写SQL,但会牺牲开发效率,看项目取舍。
如果后续接口延迟继续恶化,我会考虑将聚合计算下推到PostgreSQL(用GROUP BY + json_agg),或者引入ClickHouse做OLAP——但那是另一个故事了。