一、问题背景:一个“看起来没问题”的接口

上个月接手了一个订单查询服务,技术栈是Flask 2.3 + SQLAlchemy 1.4 + PostgreSQL 14。接口逻辑很简单:根据用户ID查最近的20条订单,每条订单关联商品和店铺信息。压测时发现QPS到300后,P99延迟从180ms直接飙到2.1s,但CPU使用率只有40%——典型的I/O瓶颈。

一开始怀疑是数据库慢查询,但看pg_stat_statements发现单条查询都在50ms以内。用cProfile跑了一下本地压测,发现sqlalchemy__init__占了65%的耗时——问题出在ORM的懒加载上,20条订单触发了20次商品查询+20次店铺查询,加上连接池默认大小只有5,直接被打满。

二、环境与版本:先摆出可复现的底牌

Python 3.11.4
Flask 2.3.2 / FastAPI 0.100.0
SQLAlchemy 1.4.48 (asyncpg 0.27.0)
PostgreSQL 14.5 (shared_buffers=4GB, work_mem=64MB)
Redis 7.0 (maxmemory-policy=allkeys-lru)
gunicorn 21.2.0 (worker_class=gevent / uvicorn)
wrk 4.2.0 (压测工具)

测试机:8核16G的云主机,PostgreSQL和Redis都在同机部署,网络延迟`,看实际运行中的调用栈。

压测发现cProfile显示sqlalchemy.orm.loading._emit_load占了62%时间,但py-spy显示大量线程卡在psycopg2.connect上——连接池等待。这说明有两个独立问题:ORM懒加载和连接池不足。

方案设计如下:

  • 数据库层:把N+1查询改为selectinloadjoinedload,一次JOIN拿全数据。
  • 缓存层:热点用户(近1小时有访问)的订单列表缓存到Redis,TTL 60秒,key为order:list:{user_id}
  • 框架层:对比Flask+gevent和FastAPI+asyncpg,看异步是否真的能压榨出更高吞吐。
  • 连接池:SQLAlchemy连接池pool_size从5调到20,max_overflow设为10。

四、核心实现:Flask版优化(同步+连接池+缓存)

先写Flask版优化后的代码,改动点全在查询和缓存上。

# app_flask_optimized.py
from flask import Flask, jsonify, request
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, selectinload
import redis, json, time

app = Flask(__name__)
engine = create_engine(
    'postgresql+psycopg2://user:pass@localhost/orders',
    pool_size=20,          # 默认5,改到20
    max_overflow=10,       # 最多额外10个连接
    pool_pre_ping=True,    # 防止连接失效
    pool_recycle=300       # 5分钟回收
)
Session = sessionmaker(bind=engine)
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

@app.route('/orders')
def get_orders():
    user_id = request.args.get('user_id', type=int)
    if not user_id:
        return jsonify({'error': 'missing user_id'}), 400

    # 缓存策略:先查Redis,命中直接返回
    cache_key = f'order:list:{user_id}'
    cached = r.get(cache_key)
    if cached:
        return jsonify(json.loads(cached))

    # 数据库查询:一次性JOIN,避免N+1
    with Session() as s:
        stmt = text("""
            SELECT o.id, o.amount, p.name AS product_name, s.name AS shop_name
            FROM orders o
            JOIN products p ON o.product_id = p.id
            JOIN shops s ON o.shop_id = s.id
            WHERE o.user_id = :uid
            ORDER BY o.created_at DESC
            LIMIT 20
        """)
        result = s.execute(stmt, {'uid': user_id}).mappings().all()
        data = [dict(row) for row in result]

    # 回填缓存,60秒过期
    r.setex(cache_key, 60, json.dumps(data))
    return jsonify(data)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

这里有几个坑要提:

  • pool_pre_ping=True:不加的话,PostgreSQL重启后连接池里的旧连接直接报错,压测时会出现偶发500。
  • selectinload试过但没用:ORM的JOIN在复杂查询下生成的SQL不如手写JOIN可控。这个场景手写SQL性能提升约15%,因为能控制索引选择。
  • Redis序列化用json而不是pickle:pickle有安全风险且体积大3倍,网络开销在压测中会放大。

五、核心实现:FastAPI版(异步+asyncpg)

FastAPI版用纯异步,数据库驱动换asyncpg。注意SQLAlchemy在异步下不能用Session的同步方式,得用AsyncSession

# app_fastapi.py
from fastapi import FastAPI, Query
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import redis.asyncio as aioredis
import json

app = FastAPI()
# asyncpg驱动,注意URL前缀是postgresql+asyncpg
engine = create_async_engine(
    'postgresql+asyncpg://user:pass@localhost/orders',
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True
)
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
r = aioredis.from_url('redis://localhost:6379/0', decode_responses=True)

@app.get('/orders')
async def get_orders(user_id: int = Query(...)):
    cache_key = f'order:list:{user_id}'
    cached = await r.get(cache_key)
    if cached:
        return json.loads(cached)

    async with SessionLocal() as s:
        result = await s.execute(text("""
            SELECT o.id, o.amount, p.name AS product_name, s.name AS shop_name
            FROM orders o
            JOIN products p ON o.product_id = p.id
            JOIN shops s ON o.shop_id = s.id
            WHERE o.user_id = :uid
            ORDER BY o.created_at DESC
            LIMIT 20
        """), {'uid': user_id})
        rows = result.mappings().all()
        data = [dict(row) for row in rows]

    await r.setex(cache_key, 60, json.dumps(data))
    return data

启动命令差异:Flask用gunicorn -k gevent -w 8 app_flask_optimized:app,FastAPI用uvicorn app_fastapi:app --workers 8。注意FastAPI如果跑在gunicorn下,worker_class得是uvicorn.workers.UvicornWorker,否则异步事件循环跑不起来。

六、压测数据:从2s到120ms的完整对比

用wrk跑60秒,每个配置跑3次取中位数,结果如下:

配置 QPS P50 (ms) P99 (ms) 错误率
Flask原始 (懒加载+pool=5) 305 180 2100 2.3%
Flask+手写JOIN+pool=20 820 88 420 0%
Flask+JOIN+Redis缓存 1450 32 210 0%
FastAPI+asyncpg+JOIN 1100 65 350 0%
FastAPI+asyncpg+Redis 2600 18 120 0%

关键结论

  1. 连接池从5调到20的收益最大——QPS从305提到820,接近3倍。懒加载带来的N+1查询在连接池不足时会被放大成雪崩。
  2. 异步在无缓存场景下不如预期:FastAPI+asyncpg的QPS 1100只比Flask优化后的820高34%。因为瓶颈在数据库I/O,异步只能减少线程切换开销,不能减少查询次数。
  3. Redis缓存才是杀手锏:一旦命中缓存,FastAPI能跑到2600 QPS,P99只有120ms。原因是所有数据库查询都被省掉了,纯粹是Redis的GET和JSON反序列化。
  4. CPU使用率:优化后FastAPI版CPU从40%升到75%,说明瓶颈从I/O转移到了CPU(JSON序列化+Redis协议),这是健康的信号。

七、踩坑与优化:那些文档里没写的细节

坑1:asyncpg连接池默认不校验连接
如果PostgreSQL重启,asyncpg连接池里的旧连接不会自动断开。必须在create_async_engine里加pool_pre_ping=True,否则会报ConnectionResetError,而且错误是随机出现的,压测时特别难排查。

坑2:Redis连接数
FastAPI版如果每个请求都新建Redis连接,压测时会出现ConnectionRefusedError。用aioredis.from_url创建的连接池,默认max_connections=50,在256并发下不够用,需要显式设置max_connections=100

坑3:gunicorn+gevent的猴子补丁
Flask版必须要在import gevent.monkey; gevent.monkey.patch_all()之后才能用-k gevent,而且必须在导入psycopg2之前。这个顺序问题会导致数据库连接卡死,表现是压测时QPS到500就上不去了。

优化:缓存穿透
压测时发现热点用户(比如测试账号)的缓存TTL到期后,瞬间有大量请求打到数据库。解决方式是加mutex锁:缓存未命中时,先尝试SETNX设置一个锁key,获取到锁的请求才查数据库,其他请求等待100ms后重试。但简单场景下用空值缓存(查不到数据也缓存[])就够了,TTL设30秒。

优化:SQL语句级优化
原始查询里LIMIT 20配合ORDER BY created_at DESC是没问题的,但orders表有50万行后,没有索引会导致排序全表扫描。加了个(user_id, created_at DESC)复合索引,查询时间从50ms降到8ms。这在压测数据里没体现,因为本地数据量小,但生产环境必须加。

八、总结:调优的本质是消除浪费

这次调优让我深刻体会到:

  1. 先profile再改代码。一开始以为是SQL问题,结果cProfile显示是懒加载和连接池。没有profile,我会在SQL上浪费时间。
  2. 同步+连接池优化已经能解决90%的问题。异步的收益在低并发下可以忽略,只有在连接数超过1万时才明显。FastAPI的异步优势体现在IO密集型场景,但前提是你已经把查询次数减到最少。
  3. 缓存是终极手段,但不是银弹。Redis缓存把P99从420ms降到120ms,但缓存穿透、雪崩、一致性都是新问题。对于订单这类最终一致性的数据,60秒TTL完全可以接受。
  4. 运维侧优化同样重要:gunicorn worker数量从4调到8,QPS提升35%;但调到16反而下降,因为上下文切换开销超过了并行收益。8核机器用8个worker是经验法则。

最终线上版本用的是FastAPI+asyncpg+Redis,配置为uvicorn --workers 8 --limit-max-requests 10000。如果你正在用Flask,也别急着迁移——先检查自己的连接池配置和ORM查询,那两处优化到位的收益比换框架大得多。