一、从一次接口慢查询说起

很多人在刚接触 Flask-SQLAlchemy 的时候,都会遇到一个挺让人头疼的问题:明明 SQLAlchemy 写起来挺顺手,查询也很快,但接口一上线,数据量稍微大一点,页面加载就慢得像蜗牛一样。你自己调试的时候可能只查了两三条测试数据,没觉得有啥问题,可一上生产,几百条、几千条记录一加载,数据库 CPU 就飙上去了。有一次我做了一个博客系统,文章列表页需要显示每篇文章的作者名字,我本以为很简单:先查出所有文章,再循环获取每篇文章关联的作者。结果就是,列表页一打开,数据库狂刷几十条 SQL,页面等了四五秒才出来。这就是典型的 N+1 查询问题

1.1 一个常见的场景:文章列表展示作者名

假设我们有这样两个模型:一个 Article(文章)和一个 User(用户),文章表里通过外键 user_id 关联用户。页面上想展示最近 10 篇文章,每篇文章后面跟着作者的名字。

# 技术栈: Python 3.10 + Flask 2.3 + Flask-SQLAlchemy 3.0 + SQLite
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
db = SQLAlchemy(app)

# 定义用户模型
class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), nullable=False)
    # 一对多关系,方便通过用户找到文章
    articles = db.relationship('Article', backref='author', lazy='select')

# 定义文章模型
class Article(db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100), nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

注意,我们在 User.articles 关系中用了 lazy='select',这是 SQLAlchemy 默认的加载方式,也就是 惰性加载。这意味着当我们访问 author 属性时,SQLAlchemy 才会去数据库里查对应的用户。

1.2 代码实现及问题出现

写一个简单的视图,查询最近 10 篇文章:

@app.route('/articles')
def article_list():
    # 查询最近10篇文章(按id倒序)
    articles = Article.query.order_by(Article.id.desc()).limit(10).all()
    result = []
    for art in articles:
        # 每次循环访问 art.author,都会触发一次额外的SQL查询
        result.append({
            'title': art.title,
            'author_name': art.author.name  # 这里触发了惰性加载
        })
    return {'articles': result}

这段代码看起来很符合直觉,但仔细观察就会发现:第一行 Article.query...all() 只执行了一条 SQL 查询:SELECT * FROM articles ORDER BY id DESC LIMIT 10。然后循环 10 次,每次访问 art.author 又会执行一条 SELECT * FROM users WHERE id = ?。总共 1 + 10 = 11 条 SQL。如果每篇文章的作者都不一样,那就是 11 条。但如果列表里有很多重复作者呢?虽然缓存机制能节省一些,但默认情况下每篇文章的 author 对象是唯一的,仍然会发出重复的查询。假设列表页有 100 篇文章,那就需要执行 1+100=101 条 SQL。这就是 N+1 问题:1 次主查询 + N 次关联查询。

二、N+1问题到底是怎么发生的

2.1 惰性加载的机制

SQLAlchemy 的关系加载策略有很多种,默认的 lazy='select' 就是惰性加载。它的工作方式是:当你访问一个关系属性(比如 art.author)时,如果这个对象还没有被加载,SQLAlchemy 就会立即发送一条 SELECT 语句去数据库里查询。这条语句是自动生成的,背后相当于执行了 session.query(User).filter(User.id == art.user_id).one()。这在单条记录访问时没什么问题,但在循环里就很容易爆炸。

除了 lazy='select',还有 lazy='joined'(即时加载,使用 JOIN)、lazy='subquery'(即时加载,使用子查询)、lazy='dynamic'(返回查询对象)等。后两种会在下面讲到。

2.2 背后的SQL执行次数

上面的例子中,我们只有 10 条文章,就发出了 11 条 SQL。可以用 Flask 的 get_debug_queries 或者 SQLAlchemy 的查询计数器来验证。这里模拟一下在 shell 中查看:

# 开启查询记录(在开发环境)
app.config['SQLALCHEMY_RECORD_QUERIES'] = True

# 在视图函数里执行后,可以打印出所有查询
from flask_sqlalchemy import get_debug_queries
queries = get_debug_queries()
for query in queries:
    print(query.statement, query.parameters)

你会看到类似下面的输出:

SELECT articles.id AS articles_id, articles.title AS articles_title, articles.user_id AS articles_user_id 
FROM articles ORDER BY articles.id DESC LIMIT 10
()
SELECT users.id AS users_id, users.name AS users_name 
FROM users WHERE users.id = ?
(3,)
SELECT users.id AS users_id, users.name AS users_name 
FROM users WHERE users.id = ?
(5,)
...

每篇文章都重复查一次,耗时加起来就很可观了。尤其是当文章数量增加到几百、几千时,数据库连接池都可能被打满。

三、即时加载:一次性查出来

解决 N+1 问题的核心思路是 提前加载关联数据,让 SQLAlchemy 在查询主表的同时,把关联的数据也一次性查出来。SQLAlchemy 提供了两种常用的即时加载策略:joinedsubquery

3.1 使用joined加载

joined 加载会在主查询中使用 LEFT OUTER JOIN(或 INNER JOIN,取决于外键约束)把关联表一起查出来。配置方式有两种:一种是在定义关系时直接设置 lazy='joined',另一种是在查询时使用 options(joinedload(...))。推荐使用后一种,因为它更灵活,可以针对不同的查询需要选择不同的策略。

修改上面视图的查询:

from sqlalchemy.orm import joinedload

@app.route('/articles')
def article_list():
    # 使用 joinedload 明确告诉 SQLAlchemy:一次性把作者信息也查出来
    articles = Article.query.options(
        joinedload(Article.author)  # 这里可以直接用 Article.author,因为 backref 创建了 author 属性
    ).order_by(Article.id.desc()).limit(10).all()
    result = []
    for art in articles:
        result.append({
            'title': art.title,
            'author_name': art.author.name  # 此时 author 已经被提前加载,不会触发额外查询
        })
    return {'articles': result}

注意:在定义 User 模型时,backref='author' 已经自动在 Article 上创建了一个 author 属性,所以我们可以直接 joinedload(Article.author)。如果关系定义不同,比如 author = db.relationship('User', backref='articles'),那么就用 joinedload(Article.author) 是一样的。

执行上面代码后,SQL 会变成一条:

SELECT articles.id, articles.title, articles.user_id, users_1.id, users_1.name 
FROM articles LEFT OUTER JOIN users AS users_1 ON users_1.id = articles.user_id 
ORDER BY articles.id DESC LIMIT 10

一次 JOIN 就拿到了全部需要的数据,循环里不再有额外查询。

3.2 使用subquery加载

另一种即时加载方式是用子查询:先查出文章,然后构造一个子查询把关联的用户数据一次性地批量查出来,再自动分配到对应的文章对象上。配置方法类似:

from sqlalchemy.orm import subqueryload

@app.route('/articles')
def article_list():
    articles = Article.query.options(
        subqueryload(Article.author)
    ).order_by(Article.id.desc()).limit(10).all()
    # ... 后续循环一样
    result = []
    for art in articles:
        result.append({
            'title': art.title,
            'author_name': art.author.name
        })
    return {'articles': result}

执行后会生成两条 SQL:

SELECT articles.id, articles.title, articles.user_id 
FROM articles ORDER BY articles.id DESC LIMIT 10
SELECT users.id, users.name, users_1.id AS users_1_id 
FROM users WHERE users.id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

虽然两条,但第二条把全部关联用户只用一次 IN 查询就拿到了,相比 N+1 的 N 条,效率提升非常明显。而且当关联表很宽、JOIN 会导致数据膨胀时,子查询往往性能更好(因为 JOIN 可能会把一行文章重复成多行,如果文章下面还有子关联更严重)。所以 subqueryload 在某些场景下比 joinedload 更优。

四、深入对比:两种方案优缺点

4.1 惰性加载的优点和缺点

优点

  • 写代码很直观:就像平时在内存中访问对象属性一样,不用考虑底层怎么查。
  • 节省内存:如果不访问关联数据,SQLAlchemy 根本不会去加载它,减少了数据传输和对象创建的开销。
  • 适合小数据量、偶然访问关联的场景:比如详情页只查一篇文章,顺便访问一次作者,完全没压力。

缺点

  • 容易引发 N+1 查询,尤其是在循环列表里访问关联属性时。
  • 不可控:你很难一眼看出代码里哪些地方会触发懒加载,可能导致线上性能问题。
  • 性能不稳定:数据库压力随循环次数线性增长。

4.2 即时加载的优点和缺点

优点

  • 消除 N+1:一次或两次查询搞定,数据库压力稳定。
  • 性能可预测:从 SQL 执行次数就能估算出开销。
  • 适合列表页、批量展示关联数据的场景。

缺点

  • 如果查询的结果集里很多记录其实并不需要关联数据,就会浪费数据库资源和网络传输(比如 JOIN 了大的文本字段)。
  • JOIN 可能造成数据膨胀:比如一篇文章关联多条评论(一对多),使用 joinedload 会把文章重复多次,导致大量冗余数据被传输到应用层。
  • 增加了 SQL 复杂度,对数据库优化器也有影响(不过大多数情况下收益大于成本)。

另外,subqueryload 也有自己要注意的地方:如果主查询使用了 limitoffset 进行分页,子查询中的 IN 列表能正确拿到主键,但如果你用了 distinct 或复杂的排序,可能导致子查询的结果不正确(SQLAlchemy 内部会处理,但某些边界情况需要留意)。一般推荐:简单的一对一或一对多(关联数量较少)用 joinedload;如果关联表的一侧数量多,或者担心 JOIN 导致行数膨胀,用 subqueryload

五、如何根据场景做选择

5.1 什么时候该用惰性加载

  • 当你只获取单个对象,且基本确定只需要访问一次关联时(比如文章详情页面,展示作者)。
  • 当关联数据量很大但你不确定是否要展示,比如一个用户有很多条评论,你在文章页只展示用户信息,不展示评论,那就不要让评论跟着一起加载。
  • 在 Python shell 中进行调试或临时操作时,懒加载很方便,不需要提前规划。

5.2 什么时候该用即时加载

  • 任何渲染列表的地方:文章列表、商品列表、用户列表等,只要循环里访问了关联属性,就必须用即时加载。
  • 当你确定大部分查询都会用到某个关联时,可以在查询时加上 options(joinedload(...))subqueryload(...)
  • 如果你的模型设计了延迟加载但仍然遇到了性能瓶颈,先检查是不是 N+1,然后改成即时加载。

5.3 注意细节:重复加载、默认选项

  • 不要混用:在同一个查询中,如果同时用了 joinedloadlazy,以后者为准。而且最好在查询语句中显式指定,而不是依赖模型定义。
  • 关系定义中的 lazy 默认值backref 生成的关系默认也是 lazy='select',如果所有场景都需要立即加载,可以在关系定义时就改为 lazy='joined',但这样会影响到所有查询,不够灵活。建议保持默认,在查询时动态通过 options 调整。
  • 小心自关联和循环加载:比如文章关联了评论,评论又关联了用户,你拉取了文章后又加载评论,再加载用户,可能变成 1 + N + M 问题。这时要对每个需要的关联都加上即时加载。
  • 不要过度加载:一条查询里绑定了太多不同表的关系,SQL 会变得臃肿,甚至超过数据库的 JOIN 限制。可以拆成多次查询或使用 subqueryload
  • flask-sqlalchemy 和 SQLAlchemy 的版本差异:新版本(如 2.x/3.x)中,joinedloadsubqueryload 都在 sqlalchemy.orm 下面,注意导入。

六、文章总结

N+1 问题是 ORM 框架几乎无法避开的坑,尤其是在展示列表数据时最明显。Flask-SQLAlchemy 默认的惰性加载虽然方便,但容易让新人写出低效代码。辨别问题和解决之道其实很简单:如果在循环里访问了关联属性,就应该使用即时加载joinedloadsubqueryload 是两种最常见的方案,各有擅长领域。从项目开始就养成显式使用 options 的习惯,能大大降低后期性能排查的难度。记住,SQL 的执行次数是衡量查询效率最简单粗暴的指标,看到几十上百条查询,第一反应就该怀疑 N+1。

最后,写代码时多问自己一句:这里会触发几次数据库查询?答案能帮你避开很多性能陷阱。