下载工作台
Flask Web 开发

ORM 深水区:连接池与 N+1 排查

试读上半部分 · 解锁后可读全文

第 17 章 · ORM 深水区:连接池、读写分离与 N+1 排查

本章目标:深入 SQLAlchemy Engine 连接池配置(pool_sizepool_pre_pingpool_recycle);理解读写分离(主库写、从库读)及 SQLALCHEMY_BINDS 示例;用 joinedload / selectinload 解决 N+1;掌握子查询、exists、聚合与窗口函数简介;配置 SQL 日志(对标 Django Debug Toolbar);建立慢查询排查 checklist

学时建议:6~7 小时(含 2 小时性能实验)

前置:本模块 ch05 ORM、ch09 日志、ch11 生产部署;ch16 RBAC 列表查询可作为优化对象。


17.1 性能问题从哪来

api-demo 商品量上涨后的典型症状:

现象可能原因
首请求慢、后续变快连接池冷启动
凌晨后 API 报错连接被 wait_timeout 断开
列表 10 条打 11 次 SQLN+1 懒加载
报表接口超时缺索引、全表扫描
写后立刻读不到主从复制延迟
 Flask Worker              PostgreSQL / MySQL
┌────────────┐            ┌────────────────────┐
│ SQLAlchemy │── pool ──►│ db-primary.example │ 写
└────────────┘            └─────────┬──────────┘
      │ 读查询(选修)                │ 复制
      └──────────────────────────►┌────────────────────┐
                                  │ db-replica.example │ 读
                                  └────────────────────┘
数据库主机均为虚构域名;本地可用 SQLite,生产示例用 PostgreSQL 语法。

17.2 Engine 与连接池核心参数

Flask-SQLAlchemy 在 db.init_app 时根据 URI 创建 Engine。

api_demo/config.py(ProductionConfig):

class ProductionConfig(Config):
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        "DATABASE_URL",
        "postgresql+psycopg2://api_demo:***@db-primary.example.com:5432/api_demo",
    )
    SQLALCHEMY_ENGINE_OPTIONS = {
        "pool_size": 10,
        "max_overflow": 20,
        "pool_timeout": 30,
        "pool_recycle": 1800,
        "pool_pre_ping": True,
    }
参数含义建议
pool_size常驻连接数每 Worker 5~20
max_overflow峰值额外连接突发缓冲,不宜过大
pool_pre_ping取出前 SELECT 1生产建议 True
pool_recycle连接存活秒数MySQL 设 < wait_timeout
pool_timeout等待连接秒数防线程无限阻塞

连接数估算:总连接 ≈ Gunicorn workers × (pool_size + overflow) + Celery workers × 池大小。示例 4×15+20=80,数据库 max_connections 须留余量。开发环境 SQLite 无网络池,调优在 staging / 生产验证。


17.3 读写分离:主库写、从库读

操作目标库说明
INSERT/UPDATE/DELETEPrimary唯一写入点
SELECT(可接受延迟)Replica分担读压
事务内读 / 写后立刻读Primary「读己之写」

Binds 配置:

class ProductionConfig(Config):
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        "DATABASE_PRIMARY_URL",
        "postgresql+psycopg2://api_demo:***@db-primary.example.com:5432/api_demo",
    )
    SQLALCHEMY_BINDS = {
        "replica": os.environ.get(
            "DATABASE_REPLICA_URL",
            "postgresql+psycopg2://api_demo:***@db-replica.example.com:5432/api_demo",
        ),
    }

显式路由(优于给写模型设 __bind_key__ = "replica"):

from flask import g

def get_read_session():
    if "read_session" not in g:
        g.read_session = db.create_session(bind=db.engines["replica"])()
    return g.read_session

@app.teardown_appcontext
def shutdown_read_session(exception=None):
    sess = g.pop("read_session", None)
    if sess:
        sess.close()

读列表走从库,写仍用 db.session(主库):

@api_bp.route("/products")
def product_list():
    return get_read_session().query(Product).filter_by(is_published=True).paginate(...)

# 写
db.session.add(Product(name="..."))
db.session.commit()

复制延迟 50~500ms:写后详情读主库,或前端乐观更新。


17.4 N+1 问题:识别与修复

products = Product.query.limit(10).all()   # 1 次
for p in products:
    print(p.category.name)                 # 每条 +1 → 共 11 次

joinedload(多对一/一对一,一次 JOIN):

from sqlalchemy.orm import joinedload

products = (
    Product.query
    .options(joinedload(Product.category))
    .filter_by(is_published=True).limit(10).all()
)

selectinload(一对多/多对多,两次 IN 查询):

from sqlalchemy.orm import selectinload

categories = Category.query.options(selectinload(Category.products)).all()
场景策略
商品列表 + 分类joinedload(Product.category)
分类 + 其下商品selectinload(Category.products)
商品 + 创建者(ch16)joinedload(Product.created_by)
仅要少数字段load_only(Product.id, Product.name)

一对多慎用 joinedload,易产生行膨胀;组合示例:

Product.query.options(
    joinedload(Product.category),
    joinedload(Product.created_by),
).order_by(Product.id.desc()).paginate(page=page, per_page=20)

17.5 复杂查询进阶

子查询——各分类商品数:

from sqlalchemy import func

subq = (
    db.session.query(Product.category_id, func.count(Product.id).label("cnt"))
    .group_by(Product.category_id).subquery()
)
rows = db.session.query(Category.name, subq.c.cnt).outerjoin(
    subq, Category.id == subq.c.category_id
).all()

exists——比 COUNT > 0 更高效:

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。