1. 问题背景:迁移框架后,性能提升远低于预期

上个月,我把一个核心订单查询接口从Flask 2.2迁移到了FastAPI 0.104,预期性能能翻倍。结果压测数据让人尴尬:QPS从380涨到430,P95延迟只从890ms降到820ms。这12%的性能提升,基本是uvicorn异步worker带来的,跟FastAPI本身关系不大。

真正的问题在哪?我用py-spy dump看了一眼运行中的进程,发现json.dumpsSQLAlchemy ORM的序列化占了60%以上的CPU时间。也就是说,瓶颈根本不在框架,而在业务代码和数据库访问模式。

这篇文章不讲泛泛而谈的“优化三板斧”,只记录我实际做的三件事:Profiling定位热点 → SQL重写与索引优化 → 多级缓存策略。每一步都有压测数据支撑。

2. 环境与版本:先交代清楚,避免“版本玄学”

  • Python 3.10.12(注意:3.11的字典和f-string有优化,但公司线上还是3.10)
  • FastAPI 0.104.1 + Uvicorn 0.24.0(workers=4,loop="uvloop",http="httptools")
  • Flask 2.2.3 + Gunicorn 20.1(gthread,workers=4,threads=8)
  • SQLAlchemy 2.0.23(ORM模式)+ PostgreSQL 14.5
  • Redis 6.2.6(单机,maxmemory-policy allkeys-lru)
  • 压测工具:Locust 2.18,300并发,持续10分钟

测试环境是4核8G的云主机,数据库和Redis在同一内网,网络延迟约0.4ms。

3. Profiling工具对比:Py-Spy vs cProfile vs FastAPI内置

先说结论:cProfile对FastAPI这种异步框架会漏掉协程内的调用,而py-spy是采样式的,对生产环境无侵入,强烈推荐。

我用py-spy record --duration 30 --pid --format flamegraph -o flame.html抓了30秒火焰图。结果非常直观:

  • sqlalchemy.engine.base.Engine._execute_context 占了38%的CPU
  • pydantic.validators 序列化占22%(这个是我用了response_model=OrderModel导致的)
  • 业务逻辑里的for循环拼接SQL条件占了15%

优化动作1:去掉FastAPI的response_model,改用手动dict返回。 别误会,response_model是好东西,但它会对每个字段做校验和序列化,在QPS超过500时这开销不可忽略。我改用return order.to_dict(),只保留pydantic的输入校验。

# 优化前:response_model会导致二次序列化
@app.get("/api/orders/{order_id}", response_model=OrderResponse)
async def get_order(order_id: int):
    order = await fetch_order_from_db(order_id)
    return order

# 优化后:直接返回dict,减少一次pydantic序列化
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int):
    order = await fetch_order_from_db(order_id)
    return order.to_dict()  # to_dict里自己控制字段类型

这一项单独压测,P95从820ms降到710ms,约13%的提升。

4. 数据库查询优化:EXPLAIN ANALYZE揪出“罪魁祸首”

火焰图里_execute_context占比最高,说明SQL本身有问题。我打开慢查询日志(log_min_duration_statement = 200),发现一个查询执行了380ms:

SELECT * FROM orders 
WHERE user_id = $1 
AND status IN ('pending', 'paid') 
ORDER BY created_at DESC 
LIMIT 20;

orders有500万行,user_id上有普通B-tree索引,但status没有索引。用EXPLAIN ANALYZE看执行计划:

Seq Scan on orders (cost=0.00..104218.40 rows=1234 width=248) (actual time=12.384..342.811 rows=987 loops=1)
  Filter: ((status = 'pending'::text) OR (status = 'paid'::text))
  Rows Removed by Filter: 4998761

问题很明确:user_id索引过滤后,回表再过滤status,导致大量随机IO。

优化方案:创建复合索引(user_id, status, created_at DESC)。注意created_at要放在最后,并且用DESC匹配ORDER BY

CREATE INDEX CONCURRENTLY idx_orders_user_status_created 
ON orders (user_id, status, created_at DESC);

同时我把查询从ORM改成了原生SQL(SQLAlchemy Core),因为ORM的select()在复杂查询时会生成多余的子查询:

# 优化后:使用SQLAlchemy Core,避免ORM一层开销
from sqlalchemy import text

async def fetch_orders(user_id: int, statuses: tuple):
    sql = text("""
        SELECT id, order_no, amount, status, created_at
        FROM orders
        WHERE user_id = :uid AND status IN :statuses
        ORDER BY created_at DESC
        LIMIT 20
    """)
    rows = await db.execute(sql, {"uid": user_id, "statuses": statuses})
    return [dict(row._mapping) for row in rows]

效果:这条SQL从380ms降到42ms,查询时间减少88%。 整体接口P95降到480ms。

5. 缓存策略:Redis三级缓存,命中率与一致性

数据库优化后,接口P95是480ms,但QPS只有1050。因为数据库连接池满了,大量请求在等连接。这时候上缓存。

我设计了三层缓存,但这三层不是固定不变的,而是按数据热点动态切换

  1. L1:进程内缓存(Python dict + 过期时间),TinyLRU算法,容量1000条,TTL 5秒。适合超高QPS的同一用户重复查询。
  2. L2:Redis缓存,key设计为order:user:{user_id}:recent,value为JSON数组,TTL 60秒。
  3. L3:数据库,作为最终一致性兜底。

核心实现用cachetools库的TTLCache做L1,Redis做L2:

from cachetools import TTLCache
import json, redis.asyncio as aioredis

l1_cache = TTLCache(maxsize=1000, ttl=5)  # 进程内缓存
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)

async def get_orders_cached(user_id: int):
    # L1: 进程内缓存
    cache_key = f"user_orders:{user_id}"
    if cache_key in l1_cache:
        return l1_cache[cache_key]

    # L2: Redis缓存
    redis_key = f"order:user:{user_id}:recent"
    cached = await redis_client.get(redis_key)
    if cached:
        data = json.loads(cached)
        l1_cache[cache_key] = data  # 回填L1
        return data

    # L3: 数据库查询
    orders = await fetch_orders(user_id, ('pending', 'paid'))
    data = [o for o in orders]  # 假设每个o是dict
    await redis_client.setex(redis_key, 60, json.dumps(data))
    l1_cache[cache_key] = data
    return data

踩坑点: 第一次我只加了Redis,没加进程缓存,压测发现QPS到1500就上不去了,因为每个请求都要走一次Redis网络IO(即使Redis很快,0.4ms延迟在300并发下也会放大)。加了L1后,进程内命中率约40%(压测场景下用户重复查询多),QPS直接拉到1870。

一致性处理: 订单状态变更时,用Redis的delete删除L2缓存,同时用l1_cache.pop(cache_key, None)清除L1。注意L1的TTL只有5秒,极端情况下最多5秒的脏读,可接受。

6. 最终压测数据与总结

在300并发、持续10分钟的压测下,优化前后对比:

指标 Flask基线 FastAPI迁移后 Profiling+SQL优化后 加缓存后
QPS 380 430 1050 1870
P50延迟 620ms 580ms 210ms 88ms
P95延迟 890ms 820ms 480ms 195ms
P99延迟 1.2s 1.05s 680ms 310ms
CPU占用 85% 80% 65% 42%

总体提升:QPS提升391%,P95延迟降低76%。

几个值得说的结论:

  1. 别迷信框架迁移。 FastAPI确实比Flask快,但只占5%-10%的差距,真正的瓶颈在IO和序列化。
  2. Profiling工具用py-spy,别用cProfile。 cProfile在异步代码里会漏数据,而且对生产环境影响大。
  3. ORM不是万能的。 简单CRUD用ORM没问题,但复杂查询建议用SQLAlchemy Core或原生SQL,省掉一层对象映射。
  4. 缓存一定要分两级。 只加Redis的话,网络IO会成为新的瓶颈。进程内LRU缓存是吞吐量的关键。

最后,别过度优化。如果你的接口QPS不到500,把数据库索引加好、加一层Redis就足够了。我这套方案里L1进程缓存带来的复杂度(缓存失效、多worker内存不一致)只有在QPS超过1500时才值得承担。