一、问题背景:一个“看起来没问题”的接口
上个月接手一个物流订单查询服务,基于FastAPI + SQLAlchemy 2.0 + PostgreSQL 13。其中一个/api/v1/orders/summary接口,用于大屏展示当日订单统计。功能很简单:查订单表、关联用户表、聚合状态。
但压测结果让人崩溃:单线程循环调用100次,平均耗时4700ms,P95直接飙到5.2秒,而下游业务方要求P95 = date)
)
result = []
for order in orders.scalars().all():
# 每条订单都查询一次用户表!
user = await session.get(User, order.user_id)
result.append({...})
return result
3000条订单 = 3000次用户表查询。异步环境下虽然不阻塞,但每次查询都有网络RTT、事务开销、解析开销,累加起来就是灾难。
## 四、方案设计:ORM降级为原生SQL + 批量预加载
我的优化思路分三步:
1. **消除N+1**:用一次JOIN替代3000次单查
2. **去掉ORM对象映射开销**:SQLAlchemy ORM实例化对象有大量元数据操作,这里改用`text()`原生SQL直接返回字典
3. **后加Redis缓存**:统计口径是T+1的,完全适合缓存
### 核心实现:改写后的查询层
```python
# order_repository.py - 优化版
from sqlalchemy import text
ORDER_SUMMARY_SQL = """
SELECT
o.status,
COUNT(*) AS cnt,
COALESCE(SUM(o.amount), 0) AS total_amount,
COUNT(DISTINCT u.vip_level) AS vip_levels
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.created_at >= :start_date
AND o.created_at dict:
"""原生SQL聚合,单次查询返回所有统计"""
result = await session.execute(
text(ORDER_SUMMARY_SQL),
{"start_date": start_date, "end_date": end_date}
)
rows = result.fetchall()
# 直接映射为dict,避免ORM开销
summary = {
"total": 0,
"amount": 0,
"status_breakdown": {},
"vip_levels": 0
}
for row in rows:
summary["status_breakdown"][row.status] = row.cnt
summary["total"] += row.cnt
summary["amount"] += row.total_amount
summary["vip_levels"] = max(summary["vip_levels"], row.vip_levels)
return summary
同时加上Redis缓存层,TTL设为60秒:
# cache_service.py
import json
import redis.asyncio as aioredis
redis_client = aioredis.from_url(
"redis://localhost:6379/0",
max_connections=50,
decode_responses=True
)
CACHE_KEY_PREFIX = "order_summary:"
async def get_cached_summary(date: str):
key = f"{CACHE_KEY_PREFIX}{date}"
cached = await redis_client.get(key)
if cached:
return json.loads(cached)
return None
async def set_cached_summary(date: str, data: dict, ttl: int = 60):
key = f"{CACHE_KEY_PREFIX}{date}"
await redis_client.setex(key, ttl, json.dumps(data))
踩坑:PostgreSQL索引失效
第一次改写后测试,发现只快了1倍,还是1.9秒。用EXPLAIN ANALYZE查看执行计划:
Seq Scan on orders o (cost=0.00..15432.10 rows=2950 width=18)
Filter: (created_at >= '2024-01-15'::date)
全表扫描! created_at上虽然有索引,但因为是date类型比较,PostgreSQL没有走索引。解决方案:
CREATE INDEX idx_orders_created_at_date
ON orders ((created_at::date));
注意这里用表达式索引,并修改SQL为:
WHERE o.created_at::date = :target_date -- 改为精确匹配
改完后看执行计划:
Bitmap Index Scan on idx_orders_created_at_date (cost=0.00..6.43 rows=2960 width=0)
查询时间从1.9秒降至120ms。
五、缓存策略与压测对比
压测使用wrk,脚本如下:
wrk -t4 -c64 -d60s --latency \
"http://localhost:8000/api/v1/orders/summary?date=2024-01-15"
优化前(无缓存、N+1查询)
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.72s 1.21s 6.89s 63.20%
Req/Sec 12.34 4.56 21.00 70.00%
Latency Distribution
50% 4.55s
75% 5.67s
90% 6.12s
99% 6.83s
Requests/sec: 12.18
优化后(原生SQL + 表达式索引,无缓存)
Thread Stats Avg Stdev Max +/- Stdev
Latency 185.42ms 56.21ms 412.00ms 76.50%
Req/Sec 318.55 41.20 402.00 71.00%
Latency Distribution
50% 172.00ms
75% 215.00ms
90% 256.00ms
99% 388.00ms
Requests/sec: 318.27
加Redis缓存后(TTL=60s)
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.45ms 3.21ms 28.00ms 82.00%
Req/Sec 3260.11 512.30 4200.00 68.00%
Latency Distribution
50% 11.00ms
75% 14.00ms
90% 17.00ms
99% 24.00ms
Requests/sec: 3260.45
结论:
- 数据库查询优化(去N+1 + 索引)让P95从5.2s → 388ms,提升13倍
- Redis缓存让P95从388ms → 24ms,再提升16倍
- 整体QPS从12提升到3260,翻了271倍
六、避坑指南与总结
这次调优踩的几个坑,值得记录:
-
不要相信ORM的“智能”:SQLAlchemy ORM在查询单条记录时确实方便,但批量场景下性能极差。复杂统计直接写原生SQL,但不代表抛弃ORM,业务CRUD还是用ORM。
-
索引不是万能的:
created_at >= ?和created_at::date = ?在PostgreSQL中执行计划完全不同。调试时一定要用EXPLAIN ANALYZE看实际执行路径。 -
缓存一致性没那么可怕:对于T+1统计报表,60秒TTL完全够用。但要注意缓存穿透——如果有人频繁查询未来日期,需要加空值缓存或布隆过滤器。
-
压测工具选择:wrk适合测RPS,但要看Latency Distribution,不能只看平均值。P95才是用户真实体感。
-
Py-spy的妙用:cProfile只能测同步阻塞,但FastAPI是异步的,用Py-spy attach到运行进程能看到真正的CPU热点。本次优化中Py-spy确认了GIL竞争不存在,从而放心使用asyncpg。
最终这个接口稳定运行在生产环境,监控显示P99稳定在30ms以内。性能调优没有魔法,就是找到瓶颈 → 量化验证 → 针对性优化 → 再验证的循环。
如果大家遇到类似的慢查询,建议先花10分钟做profiling,而不是盲目加索引或上缓存。方向对了,效率自然就上来了。