一、从一次接口慢查询说起
很多人在刚接触 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 提供了两种常用的即时加载策略:joined 和 subquery。
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 也有自己要注意的地方:如果主查询使用了 limit 和 offset 进行分页,子查询中的 IN 列表能正确拿到主键,但如果你用了 distinct 或复杂的排序,可能导致子查询的结果不正确(SQLAlchemy 内部会处理,但某些边界情况需要留意)。一般推荐:简单的一对一或一对多(关联数量较少)用 joinedload;如果关联表的一侧数量多,或者担心 JOIN 导致行数膨胀,用 subqueryload。
五、如何根据场景做选择
5.1 什么时候该用惰性加载
- 当你只获取单个对象,且基本确定只需要访问一次关联时(比如文章详情页面,展示作者)。
- 当关联数据量很大但你不确定是否要展示,比如一个用户有很多条评论,你在文章页只展示用户信息,不展示评论,那就不要让评论跟着一起加载。
- 在 Python shell 中进行调试或临时操作时,懒加载很方便,不需要提前规划。
5.2 什么时候该用即时加载
- 任何渲染列表的地方:文章列表、商品列表、用户列表等,只要循环里访问了关联属性,就必须用即时加载。
- 当你确定大部分查询都会用到某个关联时,可以在查询时加上
options(joinedload(...))或subqueryload(...)。 - 如果你的模型设计了延迟加载但仍然遇到了性能瓶颈,先检查是不是 N+1,然后改成即时加载。
5.3 注意细节:重复加载、默认选项
- 不要混用:在同一个查询中,如果同时用了
joinedload和lazy,以后者为准。而且最好在查询语句中显式指定,而不是依赖模型定义。 - 关系定义中的
lazy默认值:backref生成的关系默认也是lazy='select',如果所有场景都需要立即加载,可以在关系定义时就改为lazy='joined',但这样会影响到所有查询,不够灵活。建议保持默认,在查询时动态通过options调整。 - 小心自关联和循环加载:比如文章关联了评论,评论又关联了用户,你拉取了文章后又加载评论,再加载用户,可能变成 1 + N + M 问题。这时要对每个需要的关联都加上即时加载。
- 不要过度加载:一条查询里绑定了太多不同表的关系,SQL 会变得臃肿,甚至超过数据库的 JOIN 限制。可以拆成多次查询或使用
subqueryload。 - flask-sqlalchemy 和 SQLAlchemy 的版本差异:新版本(如 2.x/3.x)中,
joinedload和subqueryload都在sqlalchemy.orm下面,注意导入。
六、文章总结
N+1 问题是 ORM 框架几乎无法避开的坑,尤其是在展示列表数据时最明显。Flask-SQLAlchemy 默认的惰性加载虽然方便,但容易让新人写出低效代码。辨别问题和解决之道其实很简单:如果在循环里访问了关联属性,就应该使用即时加载。joinedload 和 subqueryload 是两种最常见的方案,各有擅长领域。从项目开始就养成显式使用 options 的习惯,能大大降低后期性能排查的难度。记住,SQL 的执行次数是衡量查询效率最简单粗暴的指标,看到几十上百条查询,第一反应就该怀疑 N+1。
最后,写代码时多问自己一句:这里会触发几次数据库查询?答案能帮你避开很多性能陷阱。
Comments