N+1
原理
N+1 问题是 Web 后端查询数据库时,因关联数据查询逻辑不当,导致执行 1 次主查询后,又额外执行 N 次子查询的性能问题
其中 “1” 代表主查询,“N” 代表为获取关联数据而执行的子查询总数
其核心产生原因是分两次查询关联数据,具体场景如下:
执行 1 次主查询:先查询主表数据,如:
查询所有 10 个用户(主表):此时仅获取用户的基础信息,未包含关联的订单数据
循环执行 N 次子查询:为获取每个主表数据对应的关联数据,通过循环遍历主查询结果,对每个主表记录单独执行 1 次子查询,如:
为每个用户查询其名下的订单(关联表):10 个用户就会额外执行 10 次订单查询,最终总查询次数为 1(主)+10(子)=11 次,即 N+1 次
解决
不同语言 不同框架的解决方式不同,但原理类似
Django:ORM默认懒加载导致,通过优化查询
提前加载关联数据
FastAPI:
SpringBoot:ORM(MyBatis、Hibernate)默认采用 “先查主表、再循环查关联表” 的懒加载逻辑,从而引发 N+1 问题
MyBatis:使用collection
Hibernate:使用fetch join
示例
Django
触发场景
Django ORM 默认不会主动加载模型间的关联数据(如 ForeignKey、ManyToManyField),只有当代码实际访问关联属性时,才会触发新的 SQL 查询,其他常见情况:
ManyToManyField 关联:如 “查所有作者,再遍历获取每个作者的所有图书”,同样会触发 N+1
反向关联查询:如通过author.book_set.all()反向查作者的图书,若循环执行也会引发问题
# 1. 执行1次主查询:获取所有图书(假设返回N本)
books = Book.objects.all()
# 2. 循环访问关联属性,触发N次子查询:每本图书查1次作者
for book in books:
print(book.author.name) # 每次访问book.author,都会执行1次"SELECT * FROM author WHERE id=..."
# 总查询次数:1(主查询图书) + N(子查询作者,N 为图书数量)= N+1 次
解决方案
通过 ORM 的查询优化 API,在主查询时 “主动预加载关联数据”,将 N+1 次查询合并为 1-2 次查询
select_related
预加载 “一对一 / 多对一” 关联(ForeignKey/OneToOneField)
关联关系是单条数据(如一本书只有一个作者),底层通过 SQL 的JOIN实现,主查询时直接关联表并加载数据
在查询时通过select_related('关联字段名')指定预加载的关联
# 1次查询:通过JOIN同时获取图书和关联的作者数据
books = Book.objects.select_related('author').all()
# 循环访问关联属性,无额外查询
for book in books:
print(book.author.name) # 直接使用预加载的数据,不触发新SQL
prefetch_related
预加载 “一对多 / 多对多” 关联(ManyToManyField / 反向 ForeignKey)
关联关系是多条数据(如一个作者有多本书),底层分 2 次查询:
先查主表数据
再查所有关联数据并通过 Python 内存关联,避免循环子查询
在查询时通过prefetch_related('关联字段名')指定预加载的关联
# ============ 优化前 ============
authors = Author.objects.all() # 1次主查询
for author in authors:
print(author.books.all()) # N次查询(每个作者查1次图书)
# ============ 优化后 ============
# 2次查询:1次查所有作者,1次查所有作者关联的图书,内存中匹配
authors = Author.objects.prefetch_related('books').all()
for author in authors:
print(author.books.all()) # 无额外查询,使用预加载数据
# 总查询次数:仅 2 次(与作者数量 N 无关)
结合使用
若存在 “多层关联”(如 “图书→作者→作者的出版社”),可通过__(双下划线)指定深层关联,结合两种方法优化
class Publisher(models.Model):
name = models.CharField(max_length=100)
class Author(models.Model):
name = models.CharField(max_length=100)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE) # 作者关联出版社
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.ForeignKey(Author, on_delete=models.CASCADE) # 图书关联作者
# 查所有图书,同时获取 “作者” 和 “作者的出版社”
# 1次查询:通过JOIN预加载图书→作者→出版社的三层关联
books = Book.objects.select_related('author__publisher').all()
for book in books:
print(book.author.name) # 预加载数据,无查询
print(book.author.publisher.name)# 预加载数据,无查询
辅助验证
通过 Django 的django.db.connection
查看执行的 SQL 语句,验证查询次数
from django.db import connection
# 执行查询逻辑
books = Book.objects.select_related('author').all()
for book in books:
print(book.author.name)
# 打印执行的SQL总数和具体语句
print(f"执行的SQL次数:{len(connection.queries)}")
for query in connection.queries:
print(query['sql']) # 查看每一条SQL
不可重复读
定义
同一事务内,两次读取同一行数据,结果不一致(因其他事务修改并提交了该数据)
核心是 “数据被修改” 导致的读取结果变化
示例
MySQL 默认隔离级别REPEATABLE READ可避免,需用READ COMMITTED复现
事务 A 开启,读取id=1的订单金额为100;
事务 B 开启,修改id=1的订单金额为200并提交;
事务 A 再次读取id=1的订单,金额变为200—— 即不可重复读
复现
Django + PostgreSQL,默认READ COMMITTED级别
# 终端1(事务A:两次读取同一行)
from django.db import transaction
from myapp.models import Order
# 初始化:创建id=1的订单,金额100
Order.objects.create(amount=100.00)
with transaction.atomic():
# 第一次读取
order1 = Order.objects.get(id=1)
print(f"事务A:第一次读取金额 = {order1.amount}") # 100
input("等待事务B修改...") # 暂停
# 第二次读取(事务B已修改并提交)
order2 = Order.objects.get(id=1)
print(f"事务A:第二次读取金额 = {order2.amount}") # 200(不可重复读)
# 终端2(事务B:修改并提交)
from django.db import transaction
from myapp.models import Order
with transaction.atomic():
order = Order.objects.get(id=1)
order.amount = 200.00
order.save()
print("事务B:已修改金额为200并提交")
解决
提升事务隔离级别至REPEATABLE READ或更高,确保同一事务内多次读取同一行数据结果一致
延伸
更极端的并发问题(较少见)
在特定场景下,还可能出现以下问题(本质是隔离级别过低或锁设计缺陷导致):
丢失更新(Lost Update)
两个事务同时修改同一行数据,后提交的事务覆盖了先提交事务的修改,导致前者更新 “丢失”
例:事务 A 将金额从 100→200,事务 B 同时将 100→300,最终结果为 300(A 的更新丢失)
解决方案:用悲观锁(select_for_update())或乐观锁(版本号机制)
读偏斜(Read Skew)
同一事务内读取关联数据时,因部分数据被其他事务修改,导致逻辑不一致
例:事务 A 读取 “用户余额 100” 和 “订单金额 100”(余额 = 订单金额),事务 B 修改余额为 50 并提交,事务 A 再次读取余额为 50(此时余额≠订单金额,逻辑矛盾)
解决方案:用SERIALIZABLE隔离级别或业务层加锁
脏读
在 Django 中,脏读、幻读的核心是事务隔离级别不足导致的并发问题,需结合「代码示例复现问题」+「针对性配置隔离级别」来解决。以下分场景提供示例(以 PostgreSQL 为例,其默认隔离级别READ COMMITTED不防脏读、幻读,更易复现)
定义
数据库脏读(Dirty Read)是指一个事务读取到了另一个未提交事务中修改的数据。如果未提交的事务最终发生回滚,那么读取到的数据就是无效的“脏数据”,会导致业务逻辑出错。
在数据库事务隔离级别中,**读未提交(Read Uncommitted)** 级别会直接导致脏读,而更高的隔离级别(如读已提交、可重复读、串行化)可避免脏读。MySQL默认隔离级别为“可重复读(Repeatable Read)”,但可通过配置或代码手动调整。
示例
假设一个电商场景:用户A的账户余额为100元,同时有两个事务操作该余额:
1. 事务1:尝试扣除50元(余额变为50元),但未提交。
2. 事务2:读取用户A的余额,此时若读到50元(事务1未提交的数据),即为脏读。
3. 若事务1因异常回滚,用户A的实际余额仍为100元,但事务2已基于50元的“脏数据”进行了后续操作(如允许下单),导致业务错误。
复现(Django + MySQL)
环境准备
Django 4.2 + MySQL 8.0
from django.db import models
class UserAccount(models.Model):
username = models.CharField(max_length=50, unique=True)
balance = models.DecimalField(max_digits=10, decimal_places=2)
-- 降低事务隔离级别:MySQL默认隔离级别为“可重复读”,需临时改为“读未提交”以复现脏读:
SET GLOBAL transaction_isolation = 'READ-UNCOMMITTED';
SET SESSION transaction_isolation = 'READ-UNCOMMITTED';
--(注意:修改后需重启Django连接,确保会话生效)
并发事务
使用Djangotransaction.atomic()
控制事务,通过两个独立的Python进程(或Django Shell)模拟并发:
事务1(未提交的修改)
# 进程1:Django Shell 1
from django.db import transaction
from myapp.models import UserAccount
with transaction.atomic():
account = UserAccount.objects.get(username="userA")
account.balance -= 50 # 余额变为50
account.save()
print("事务1已修改余额,未提交")
input("按回车继续(此时事务仍未提交)...") # 暂停,保持事务未提交状态
事务2(读取未提交数据)
# 进程2:Django Shell 2
from django.db import transaction
from myapp.models import UserAccount
with transaction.atomic():
account = UserAccount.objects.get(username="userA")
print(f"事务2读取到的余额:{account.balance}") # 输出50(脏读)
结果
事务2在事务1未提交时读取到了50元,若此时事务1执行回滚(按回车后手动抛出异常),事务2已基于脏数据进行操作
解决
脏读的核心是“读取未提交数据”,解决方式是通过提升事务隔离级别或业务逻辑控制:
使用更高的事务隔离级别
MySQL的“读已提交(Read Committed)”及以上级别可避免脏读。
Django无需额外配置,会继承MySQL的隔离级别
修改隔离级别:
SET GLOBAL transaction_isolation = 'READ-COMMITTED'; # 或更高的REPEATABLE-READ、SERIALIZABLE
SET SESSION transaction_isolation = 'READ-COMMITTED';
Django中显式控制事务
即使隔离级别正确,仍需确保事务边界清晰。例如,使transaction.atomic()
包裹完整的业务逻辑,避免未提交的中间状态被读取:
# 正确示例:修改并提交后才会被其他事务读取
with transaction.atomic():
account = UserAccount.objects.select_for_update().get(username="userA") # 加行锁(可选,增强并发控制)
account.balance -= 50
account.save()
# 事务提交后,其他事务才能读到更新后的数据
避免长事务
减少事务持有时间,降低未提交数据被读取的概率(尤其在高并发场景)
幻读
在Django中,数据库的“幻读”属于事务隔离级别相关的并发问题
本质是多个事务同时操作数据时,因隔离级别不足导致的“同一查询在同一事务内返回不同结果”
定义
幻读是不可重复读的特殊场景,核心表现为:
同一事务内,两次执行完全相同的“范围查询”(WHERE id < 10
),第二次查询会返回第一次查询时不存在的新数据(由其他事务插入),仿佛“出现了幻觉”。 例:
事务A执行
SELECT * FROM book WHERE price < 50
,返回3条数据事务B插入1
price=40
的新图书,提交事务事务A再次执行相同查询,返回4条数据——即幻读
原因
幻读的根源是事务隔离级别过低,Django默认不主动设置隔离级别,而是沿用数据库的默认隔离级别:
MySQL(InnoDB):默认隔离级别
REPEATABLE READ
(可重复读),已通过“间隙锁”机制避免幻读PostgreSQL:默认隔离级别
READ COMMITTED
(读已提交),不防止幻读,是Django中幻读的主要发生场景
简言之:若使用PostgreSQL,且未手动提升事务隔离级别,就可能出现幻读。
解决
核心思路:提升事务隔离级别REPEATABLE READSERIALIZABLE
,结合Django的事务API配置即可
1. Django支持的事务隔离级别
Django通isolation_level
参数指定隔离级别,支持4种标准级别(需数据库支持):
READ UNCOMMITTED
:读未提交(最低,允许脏读、不可重复读、幻读)READ COMMITTED
:读已提交(默认,避免脏读,允许不可重复读、幻读)REPEATABLE READ
:可重复读(避免脏读、不可重复读,MySQL默认,PostgreSQL需手动设置)SERIALIZABLE
:串行化(最高,避免所有并发问题,性能最低)
2. 两种配置方式:全局配置 vs 局部事务配置
全局配置(项目级生效)
settings.py
中设置数据库的默认隔离级别,适用于全项目需要统一隔离级别的场景:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql', # 以PostgreSQL为例
'NAME': 'your_db_name',
'USER': 'your_db_user',
'PASSWORD': 'your_db_password',
'HOST': 'localhost',
'PORT': '5432',
# 关键:设置默认隔离级别为REPEATABLE READ(避免幻读)
'OPTIONS': {
'isolation_level': 'REPEATABLE READ',
},
}
}
局部配置(特定事务生效)
通transaction.atomic()isolation_level
参数,为单个事务指定隔离级别,灵活控制性能与隔离性(推荐优先使用):
from django.db import transaction
# 示例:在需要避免幻读的业务中,使用REPEATABLE READ隔离级别
with transaction.atomic(isolation_level='REPEATABLE READ'):
# 第一次范围查询
books1 = Book.objects.filter(price__lt=50)
count1 = books1.count() # 假设返回3
# 此处即使其他事务插入price<50的图书并提交,也不会影响当前事务
# 第二次相同范围查询
books2 = Book.objects.filter(price__lt=50)
count2 = books2.count() # 仍返回3,避免幻读
若需更严格的隔离(如金融场景),可isolation_level
设'SERIALIZABLE'
,但会导致事务串行执行,性能下降,需谨慎使用。
3. 注意事项
数据库兼容性:不同数据库对隔离级别的支持不同(如SQLite不支
SERIALIZABLE
),需根据实际使用的数据库调整性能权衡:隔离级别越高,并发性能越低
SERIALIZABLE
可能导致事务阻塞),需在“数据一致性”和“性能”间平衡,多数场景REPEATABLE READ
足够Django版本
isolation_level
参数在Django 1.6+支持,低版本需通过原生SQL设置隔离级别(SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
)
评论区