一、问题背景:报警从早上七点开始
先说场景。我们是一个电商中台团队,服务分两块:
- 对外网关层:FastAPI 0.110.0 + Uvicorn 0.29.0,Python 3.11.6,负责鉴权、限流、聚合下游服务。
- 内部订单服务:Flask 3.0.2 + Gunicorn 21.2.0(4 worker × 4 线程),SQLAlchemy 2.0.29,MySQL 8.0.35。
出问题的是订单列表接口 GET /api/v1/orders?user_id=xxx&page=1&size=20。这个接口平时 P99 在 400ms 左右,某天早上七点开始,P99 直接冲到 3.8s,错误率虽然没涨,但超时(>3s)比例到了 12%。
第一反应是流量涨了。看监控:QPS 从 300 涨到 340,涨了 13%,但延迟翻了近 10 倍。这明显不是纯流量问题,是某个环节的耗时被放大了。
我按照老规矩,先看三个东西:
- 机器指标:CPU 从 35% 涨到 78%,但没打满;内存正常;MySQL 的 CPU 到 60%,慢查询数从 0 涨到每分钟 40+。
- 接口链路:网关层耗时 30ms,订单服务耗时 3.5s,瓶颈在订单服务内部。
- MySQL 慢查询日志:
SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET 0,扫描行数 80 万,Using filesort。
到这里基本能猜个八九不离十了:user_id 有索引,但排序字段 created_at 没进联合索引,导致 filesort;加上分页偏移和 N+1 查询,数据量一涨就崩。
但我不想拍脑袋改,还是得走一遍 profiling,确认时间到底花在哪。
二、环境与版本
| 组件 | 版本 |
|---|---|
| Python | 3.11.6 |
| FastAPI | 0.110.0 |
| Uvicorn | 0.29.0 |
| Flask | 3.0.2 |
| Gunicorn | 21.2.0 |
| SQLAlchemy | 2.0.29 |
| MySQL | 8.0.35 |
| Redis | 7.2.4 |
| py-spy | 0.3.14 |
| locust | 2.24.1 |
压测机是一台 8C16G 的阿里云 ECS,被压的服务是 4C8G,MySQL 是独立的 8C16G 实例。压测时保证没有其他业务干扰,这点很重要,不然数据没法看。
三、方案设计:先测量,再优化
我的思路很朴素,就三步:
- 定位:用 py-spy 做采样分析,找出 CPU 热点;用 cProfile 做函数级耗时统计,找出时间黑洞。
- 拆解:把一次请求拆成「DB 查询」「业务逻辑」「序列化」三段,分别计时。
- 优化:按投入产出比排序,先做收益大、风险低的——索引优化 > 批量查询 > 缓存。
这里有个原则:不要一上来就上缓存。缓存是最后手段,因为一旦上了缓存,数据一致性问题会跟着来,维护成本高。先把 SQL 和代码本身的问题解决掉,再考虑缓存。
四、核心实现:profiling 到优化
4.1 用 py-spy 抓 CPU 热点
py-spy 最大的好处是不用改代码、不用重启服务,直接 attach 到进程上:
# 找到 gunicorn worker 的 pid
ps -ef | grep gunicorn | grep -v grep
# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid --duration 30 --rate 100
# 或者直接 top 看实时热点
py-spy top --pid
火焰图出来后,最宽的那一坨就是问题所在。我当时看到的是:
sqlalchemy/orm/loading.py占了 42% 的采样json/encoder.py占了 18%pymysql/connections.py占了 25%
这说明大量时间花在 ORM 加载和 JSON 序列化上,而 ORM 加载又是因为 N+1 查询——每个订单都要去查一次用户信息。
4.2 用 cProfile 定位函数级耗时
py-spy 只能看 CPU,看不了 IO 等待。我用 cProfile 补上这块,在 Flask 里加了个临时的 profiling 装饰器:
import cProfile
import pstats
import io
from functools import wraps
from flask import request
def profile_route(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 只对特定请求做 profiling,避免污染线上
if request.args.get("__profile__") != "1":
return func(*args, **kwargs)
pr = cProfile.Profile()
pr.enable()
result = func(*args, **kwargs)
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(30)
# 这里实际是打到日志里,线上别直接返回
print(s.getvalue())
return result
return wrapper
跑一次请求后,输出里最扎眼的是:
ncalls tottime cumtime function
21 0.002 2.841 get_order_detail # 每个订单查一次详情
1 0.001 3.102 list_orders
21 次 get_order_detail,每次 135ms 左右,加起来 2.8s。这就是 N+1 查询的铁证。
4.3 优化一:SQL 与索引
先说原始 SQL 的问题:
-- 原始查询
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
user_id 上有单列索引,但 ORDER BY created_at 需要 filesort。改成联合索引:
-- 联合索引,注意顺序:等值条件在前,排序字段在后
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at DESC);
-- 如果还要按状态过滤,再加一列
ALTER TABLE orders ADD INDEX idx_user_status_created (user_id, status, created_at DESC);
加完索引后,EXPLAIN 从 type: ref, rows: 800000, Extra: Using filesort 变成 type: ref, rows: 20, Extra: Using index condition。扫描行数从 80 万降到 20,这一步就砍掉了 60% 的耗时。
然后是 N+1 查询。原来的代码:
# 反例:N+1 查询
orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).limit(20).all()
result = []
for order in orders:
# 每个订单查一次用户,21 次查询
user = User.query.get(order.user_id)
result.append({
"order_id": order.id,
"user_name": user.name,
"amount": order.amount,
})
改成批量查询 + join:
# 优化后:一次 join 拿完
from sqlalchemy.orm import joinedload
orders = (
db.session.query(Order)
.options(joinedload(Order.user)) # 预加载关联
.filter(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.limit(20)
.all()
)
# 如果关联表数据量大,用 in_ 批量查更稳
order_ids = [o.id for o in orders]
details = OrderDetail.query.filter(OrderDetail.order_id.in_(order_ids)).all()
detail_map = {d.order_id: d for d in details}
这里有个坑:joinedload 在数据量大时会产生笛卡尔积,如果一对多关系多,结果集膨胀得厉害。我们订单和订单详情是一对一,所以没问题;如果是一对多,建议用 selectinload,它会先查主表再 WHERE id IN (...),更可控。
4.4 优化二:Redis 缓存
索引和批量查询做完,P99 降到 620ms。但还有优化空间——这个接口读多写少,热点用户(比如运营账号)会反复查同样的数据,适合加缓存。
我设计了两级缓存:
- L1:进程内缓存,用
cachetools.TTLCache,容量 10000,TTL 30 秒,扛瞬时热点。 - L2:Redis,TTL 5 分钟,扛跨进程共享。
import json
import hashlib
from cachetools import TTLCache
from redis import Redis
local_cache = TTLCache(maxsize=10000, ttl=30)
redis_client = Redis(host="redis.internal", port=6379, db=0, socket_timeout=0.05)
def get_cache_key(user_id: int, page: int, size: int) -> str:
raw = f"orders:{user_id}:{page}:{size}"
return hashlib.md5(raw.encode()).hexdigest()
def get_orders_cached(user_id: int, page: int, size: int):
key = get_cache_key(user_id, page, size)
# L1
if key in local_cache:
return local_cache[key]
# L2
try:
cached = redis_client.get(key)
if cached:
data = json.loads(cached)
local_cache[key] = data
return data
except Exception:
# Redis 挂了不能影响主流程,降级到 DB
pass
# 回源
data = query_orders_from_db(user_id, page, size)
# 回写,注意异常吞掉
try:
redis_client.setex(key, 300, json.dumps(data, default=str))
local_cache[key] = data
except Exception:
pass
return data
这里有几个细节值得说:
- socket_timeout 设 50ms,Redis 一旦慢,直接降级,不能拖垮接口。
- 缓存穿透:对空结果也缓存,但 TTL 短一点(60 秒),防止恶意刷不存在的 user_id。
- 缓存雪崩:TTL 加了 ±10% 的随机抖动,避免同一时刻大面积失效。
- 序列化用
default=str,因为订单里有Decimal和datetime,不加会报错。
4.5 FastAPI 侧:异步阻塞问题
网关层虽然只占 30ms,但我也顺手查了一下。发现一个典型问题:FastAPI 的异步路由里调了同步的 requests:
# 反例:async 路由里跑同步阻塞代码
@app.get("/api/v1/orders")
async def get_orders(user_id: int):
# requests 是同步的,会阻塞事件循环
resp = requests.get(f"http://order-service/orders?user_id={user_id}", timeout=2)
return resp.json()
改成 httpx 的异步客户端:
import httpx
client = httpx.AsyncClient(timeout=2.0, limits=httpx.Limits(max_connections=200))
@app.get("/api/v1/orders")
async def get_orders(user_id: int):
resp = await client.get(f"http://order-service/orders?user_id={user_id}")
return resp.json()
这个改动看起来小,但在高并发下差别巨大。同步 requests 会把事件循环堵死,Uvicorn 的并发优势完全发挥不出来。改完后网关层 P99 从 30ms 降到 12ms。
五、踩坑与优化
这部分说几个实际踩的坑,比代码本身更有价值。
坑一:索引加错了顺序。 我一开始加的是 (created_at, user_id),结果 EXPLAIN 还是 filesort。原因是联合索引遵循最左前缀,等值条件 user_id 必须在前,排序字段在后,才能既走索引又避免排序。这个顺序错了,索引基本白加。
坑二:joinedload 导致结果集膨胀。 我们有个接口是一对多(订单对商品),用 joinedload 后 SQL 返回了 20×N 行,反而更慢。换成 selectinload 后正常。教训是:一对一用 joinedload,一对多用 selectinload。
坑三:缓存 key 设计太粗。 一开始 key 是 orders:{user_id},没带分页参数,导致不同页返回同一份数据。后来改成带 page:size,并且用 md5 压缩长度,避免 key 过长。
坑四:Gunicorn worker 数配错。 原来配的是 workers=8(CPU 核数 × 2),但服务是 4 核,8 个 worker 反而因为上下文切换导致性能下降。改成 workers=4,每个 worker 4 线程,QPS 提升了 15%。公式是 (2 × CPU) + 1,但这不是铁律,要实测。
坑五:压测时没关慢查询日志。 第一次压测数据很难看,后来发现是 MySQL 慢查询日志开着,每次写入都拖慢。关掉后数据才正常。压测环境要尽量贴近生产,但无关的日志、监控要关。
六、效果数据
压测用 locust,模拟 500 并发用户,持续 10 分钟,逐步加压。
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| QPS | 320 | 2100 | 6.5x |
| P50 | 680ms | 45ms | 15x |
| P95 | 1800ms | 120ms | 15x |
| P99 | 3800ms | 180ms | 21x |
| 超时率 | 12% | 0.02% | - |
| MySQL 慢查询/分钟 | 40+ | 0 | - |
| CPU 使用率 | 78% | 42% | - |
分阶段看,每一步的收益:
- 索引优化:P99 从 3800ms → 1400ms,贡献最大,改动最小。
- 批量查询(消除 N+1):1400ms → 620ms。
- Redis 缓存:620ms → 200ms。
- FastAPI 异步改造:网关层 30ms → 12ms,整体 P99 到 180ms。
这个顺序也印证了那个原则:先优化 SQL,再优化代码,最后上缓存。如果一上来就加缓存,索引问题被掩盖,数据量再涨还是会崩,而且缓存一失效就打回原形。
七、总结
这次调优下来,几点体会:
-
profiling 是第一步,不是可选项。 没有 py-spy 和 cProfile,我可能会去猜是缓存问题或者网络问题,方向就错了。工具能让你看到真相。
-
索引是性价比最高的优化。 一条
ALTER TABLE换来 60% 的性能提升,没有比这更划算的了。但要理解最左前缀和排序字段的位置。 -
N+1 查询是隐形杀手。 单看每次查询都很快,但 20 次叠加就是 2.8 秒。ORM 用起来爽,但要警惕它的隐式查询。
-
缓存是最后手段,不是第一选择。 缓存引入一致性问题、穿透问题、雪崩问题,维护成本高。先把底层问题解决,缓存才能锦上添花。
-
异步框架里别写同步代码。 FastAPI 的 async 路由里跑 requests,等于把异步优势全扔了。这个坑很常见,但很容易被忽略。
-
压测数据要可信。 关掉无关日志、保证环境干净、逐步加压,不然数据会误导你。
最后贴一下优化后的核心代码,方便对照:
```python
order_service.py
from flask import Flask, request, jsonify
from sqlalchemy.orm import joinedload, selectinload
from cachetools import TTLCache
from redis import Redis
import json, hashlib
app = Flask(name)
local_cache = TTLCache(maxsize=10000, ttl=30)
redis_client = Redis(host="redis.internal", port=6379, db=0, socket_timeout=0.05)
def query_orders_from_db(user_id: int, page: int, size: int):
offset = (page - 1) * size
orders =