" name="sm-site-verification"/>

目 录CONTENT

文章目录
Web

N+1|脏读|幻读|不可重复读

PySuper
2025-10-07 / 0 评论 / 0 点赞 / 6 阅读 / 0 字
温馨提示:
所有牛逼的人都有一段苦逼的岁月。 但是你只要像SB一样去坚持,终将牛逼!!! ✊✊✊

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 次查询

  • 预加载 “一对一 / 多对一” 关联(ForeignKey/OneToOneField)

  • 关联关系是单条数据(如一本书只有一个作者),底层通过 SQL 的JOIN实现,主查询时直接关联表并加载数据

  • 在查询时通过select_related('关联字段名')指定预加载的关联

# 1次查询:通过JOIN同时获取图书和关联的作者数据
books = Book.objects.select_related('author').all()  

# 循环访问关联属性,无额外查询
for book in books:
    print(book.author.name)  # 直接使用预加载的数据,不触发新SQL
  • 预加载 “一对多 / 多对多” 关联(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复现

  1. 事务 A 开启,读取id=1的订单金额为100;

  2. 事务 B 开启,修改id=1的订单金额为200并提交;

  3. 事务 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或更高,确保同一事务内多次读取同一行数据结果一致

延伸

更极端的并发问题(较少见)

在特定场景下,还可能出现以下问题(本质是隔离级别过低或锁设计缺陷导致):

  1. 丢失更新(Lost Update)

    • 两个事务同时修改同一行数据,后提交的事务覆盖了先提交事务的修改,导致前者更新 “丢失”

    • 例:事务 A 将金额从 100→200,事务 B 同时将 100→300,最终结果为 300(A 的更新丢失)

    • 解决方案:用悲观锁(select_for_update())或乐观锁(版本号机制)

  2. 读偏斜(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),第二次查询会返回第一次查询时不存在的新数据(由其他事务插入),仿佛“出现了幻觉”。 例:

  1. 事务A执行 SELECT * FROM book WHERE price < 50,返回3条数据

  2. 事务B插入1price=40的新图书,提交事务

  3. 事务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

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区