详解Django ORM引发的数据库N+1性能问题

背景描述

最近在使用 Django 时,发现当调用 api 后,在数据库同一个进程下的事务中,出现了大量的数据库查询语句。调查后发现,是由于 Django ORM 的机制所引起。

Django Object-Relational Mapper(ORM)作为 Django 比较受欢迎的特性,在开发中被大量使用。我们可以通过它和数据库进行交互,实现 DDL 和 DML 操作.

具体来说,就是使用 QuerySet 对象来检索数据, 而 QuerySet 本质上是通过在预先定义好的 model 中的 Manager 和数据库进行交互。

Manager 是 Django model 提供数据库查询的一个接口,在每个 Model 中都至少存在一个 Manager 对象。但今天要介绍的主角是 QuerySet ,它并不是关键。

为了更清晰的表述问题,假设在数据库有如下的表:

device 表,表示当前网络中纳管的物理设备。

interface 表,表示物理设备拥有的接口。

interface_extension 表,和 interface 表是一对一关系,由于 interface 属性过多,用于存储一些不太常用的接口属性。

class Device(models.Model):
  name = models.CharField(max_length=100, unique=True) # 添加设备时的设备名
  hostname = models.CharField(max_length=100, null=True) # 从设备中获取的hostname
  ip_address = models.CharField(max_length=100, null=True) # 设备管理IP

class Interface(models.Model):
  device = models.ForeignKey(Device, on_delete=models.PROTECT, null=False,related_name='interfaces')) # 属于哪台设备
  name = models.CharField(max_length=100) # 端口名
  collect_status = models.CharField(max_length=30, default='active')
  class Meta:
    unique_together = ("device", "name") # 联合主键

class InterfaceExtension(models.Model):
  interface = models.OneToOneField(
    Interface, on_delete=models.PROTECT, null=False, related_name='ex_info')

  endpoint_device_id = models.ForeignKey( # 绑定了的终端设备
    Device, db_column='endpoint_device_id',
    on_delete=models.PROTECT, null=True, blank=True)

  endpoint_interface_id = models.ForeignKey(
    Interface, db_column='endpoint_interface_id', on_delete=models.PROTECT, # 绑定了的终端设备的接口
    null=True, blank=True)

简单说一下之间的关联关系,一个设备拥有多个接口,一个接口拥有一个拓展属性。

在接口的拓展属性中,可以绑定另一台设备上的接口,所以在 interface_extension 还有两个参考外键。

为了更好的分析 ORM 执行 SQL 的过程,需要将执行的 SQL 记录下来,可以通过如下的方式:

  • 在 django settings 中打开 sql log 的日志
  • 在 MySQL 中打开记录 sql log 的日志

django 中,在 settings.py 中配置如下内容, 就可以在控制台上看到 SQL 执行过程:

DEBUG = True

import logging
l = logging.getLogger('django.db.backends')
l.setLevel(logging.DEBUG)
l.addHandler(logging.StreamHandler())

LOGGING = {
  'version': 1,
  'disable_existing_loggers': False,
  'filters': {
    'require_debug_false': {
      '()': 'django.utils.log.RequireDebugFalse'
    }
  },
  'handlers': {
    'mail_admins': {
      'level': 'ERROR',
      'filters': ['require_debug_false'],
      'class': 'django.utils.log.AdminEmailHandler'
    },'console': {
      'level': 'DEBUG',
      'class': 'logging.StreamHandler',
    },
  },
  'loggers': {
    'django.db': {
      'level': 'DEBUG',
      'handlers': ['console'],
    },
  }
}

或者直接在 MySQL 中配置:

# 查看记录 SQL 的功能是否打开,默认是关闭的:
SHOW VARIABLES LIKE "general_log%";

# 将记录功能打开,具体的 log 路径会通过上面的命令显示出来。
SET GLOBAL general_log = 'ON';

QuerySet

假如要通过 QuerySet 来查询,所有接口的所属设备的名称:

interfaces = Interface.objects.filter()[:5] # hit once database

for interface in interfaces:
  print('interface_name: ', interface.name,
     'device_name: ', interface.device.name) # hit database again

上面第一句取前 5 条 interface 记录,对应的 raw sql 就是 select * from interface limit 5; 没有任何问题。

但下面取接口所属的设备名时,就会出现反复调用数据库情况:当遍历到一个接口,就会通过获取的 device_id 去数据库查询 device_name. 对应的 raw sql 类似于:select name from device where id = {}.

也就是说,假如有 10 万个接口,就会执行 10 万次查询,性能的消耗可想而知。算上之前查找所有接口的一次查询,合称为 N + 1 次查询问题。

解决方式也很简单,如果使用原生 SQL,通常有两种解决方式:

  • 在第一次查询接口时,使用 join,将 interface 和 device 关联起来。这样仅会执行一次数据库调用。
  • 或者在查询接口后,通过代码逻辑,将所需要的 device_id 以集合的形式收集起来,然后通过 in 语句来查询。类似于 SELECT name FROM device WHERE id in (....). 这样做仅会执行两次 SQL。

具体选择哪种,就要结合具体的场景,比如有无索引,表的大小具体分析了。

回到 QuerySet,那么如何让 QuerySet 解决这个问题呢,同样也有两种解决方法,使用 QuerySet 中提供的 select_related() 或者 prefetch_related() 方法。

select_related

在调用 select_related() 方法时,Queryset 会将所属 Model 的外键关系,一起查询。相当于 raw sql 中的 join . 一次将所有数据同时查询出来。select_related() 主要的应用场景是:某个 model 中关联了外键(多对一),或者有 1 对 1 的关联关系情况。

还拿上面的查找接口的设备名称举例的话:

interfaces = Interface.objects.select_related('device').filter()[:5] # hit once database

for interface in interfaces:
  print('interface_name: ', interface.name,
     'device_name: ', interface.device.name) # don't need to hit database again 

上面的查询 SQL 就类似于:SELECT xx FROMinterface INNER JOIN device ON interface.device_id = device.id limit5,注意这里是 inner join 是因为是非空外键。

select_related() 还支持一个 model 中关联了多个外键的情况:如拓展接口,查询绑定的设备名称和接口名称:

ex_interfaces = InterfaceExtension.objects.select_related(
  'endpoint_device_id', 'endpoint_interface_id').filter()[:5] 

# or

ex_interfaces = InterfaceExtension.objects.select_related(
  'endpoint_device_id').select_related('endpoint_interface_id').filter()[:5]

上面的 SQL 类似于:

SELECT XXX FROM interface_extension LEFT OUTER JOIN device ON (interface_extension.endpoint_device_id=device.id)
LEFT OUTER JOIN interface ON (interface_extension.endpoint_interface_id=interface.id)
LIMIT 5

这里由于是可空外键,所以是 left join.

如果想要清空 QuerySet 的外键关系,可以通过:queryset.select_related(None) 来清空。

prefetch_related

prefetch_related 和 select_related 一样都是为了避免大量查询关系时的数据库调用。只不过为了避免多表 join 后产生的巨大结果集以及效率问题, 所以 select_related 比较偏向于外键(多对一)和一对一的关系。

而 prefetch_related 的实现方式则类似于之前 raw sql 的第二种,分开查询之间的关系,然后通过 python 代码,将其组合在一起。所以 prefetch_related 可以很好的支持一对多或者多对多的关系。

还是拿查询所有接口的设备名称举例:

interfaces = Interface.objects.prefetch_related('device').filter()[:5] # hit twice database

for interface in interfaces:
  print('interface_name: ', interface.name,
     'device_name: ', interface.device.name) # don't need to hit database again

换成 prefetch_related 后,sql 的执行逻辑变成这样:

  1. "SELECT * FROM interface "
  2. "SELECT * FROM device where device_id in (.....)"
  3. 然后通过 python 代码将之间的关系组合起来。

如果查询所有设备具有哪些接口也是一样:

devices = Device.objects.prefetch_related('interfaces').filter()[:5] # hit twice database
for device in devices:
  print('device_name: ', device.name,
     'interface_list: ', device.interfaces.all())

执行逻辑也是:

  1. "SELECT * FROM device"
  2. "SELECT * FROM interface where device_id in (.....)"
  3. 然后通过 python 代码将之间的关系组合起来。

如果换成多对多的关系,在第二步会变为 join 后在 in,具体可以直接尝试。

但有一点需要注意,当使用的 QuerySet 有新的逻辑查询时, prefetch_related 的结果不会生效,还是会去查询数据库:

如在查询所有设备具有哪些接口上,增加一个条件,接口的状态是 up 的接口

devices = Device.objects.prefetch_related('interfaces').filter()[:5] # hit twice database
for device in devices:
  print('device_name: ', device.name,
     'interfaces:', device.interfaces.filter(collect_status='active')) # hit dababase repeatly

执行逻辑变成:

  • "SELECT * FROM device"
  • "SELECT * FROM interface where device_id in (.....)"
  • 一直重复 device 的数量次: "SELECT * FROM interface where device_id = xx and collect_status='up';"
  • 最后通过 python 组合到一起。

原因在于:之前的 prefetch_related 查询,并不包含判断 collect_status 的状态。所以对于 QuerySet 来说,这是一个新的查询。所以会重新执行。

可以利用 Prefetch 对象 进一步控制并解决上面的问题:

devices = Device.objects.prefetch_related(
  Prefetch('interfaces', queryset=Interface.objects.filter(collect_status='active'))
  ).filter()[:5] # hit twice database
for device in devices:
  print('device_name: ', device.name, 'interfaces:', device.interfaces) 

执行逻辑变成:

  • "SELECT * FROM device"
  • "SELECT * FROM interface where device_id in (.....) and collect_status = 'up';"
  • 最后通过 python 组合到一起。

可以通过 Prefetch 对象的 to_attr,来改变之间关联关系的名称:

devices = Device.objects.prefetch_related(
  Prefetch('interfaces', queryset=Interface.objects.filter(collect_status='active'), to_attr='actived_interfaces')
  ).filter()[:5] # hit twice database
for device in devices:
  print('device_name: ', device.name, 'interfaces:', device.actived_interfaces) 

可以看到通过 Prefetch,可以实现控制关联那些有关系的对象。

最后,对于一些关联结构较为复杂的情况,可以将 prefetch_related 和 select_related 组合到一起,从而控制查询数据库的逻辑。

比如,想要查询全部接口的信息,及其设备名称,以及拓展接口中绑定了对端设备和接口的信息。

queryset = Interface.objects.select_related('ex_info').prefetch_related(
      'ex_info__endpoint_device_id', 'ex_info__endpoint_interface_id')

执行逻辑如下:

  • SELECT XXX FROM interface LEFT OUTER JOIN interface_extension ON (interface.id=interface_extension .interface_id)
  • SELECT XXX FROM device where id in ()
  • SELECT XXX FROM interface where id in ()
  • 最后通过 python 组合到一起。

第一步, 由于 interface 和 interface_extension 是 1 对 1 的关系,所以使用 select_related 将其关联起来。

第二三步:虽然 interface_extension 和 endpoint_device_id 和 endpoint_interface_id 是外键关系,如果继续使用 select_related 则会进行 4 张表连续 join,将其换成 select_related,对于 interface_extension 外键关联的属性使用 in 查询,因为interface_extension 表的属性并不是经常使用的。

总结

在这篇文章中,介绍了 Django N +1 问题产生的原因,解决的方法就是通过调用 QuerySet 的 select_related 或 prefetch_related 方法。

对于 select_related 来说,应用场景主要在外键和一对一的关系中。对应到原生的 SQL 类似于 JOIN 操作。

对于 prefetch_related 来说,应用场景主要在多对一和多对多的关系中。对应到原生的 SQL 类似于 IN 操作。

通过 Prefetch 对象,可以控制 select_related 和 prefetch_related 和那些有关系的对象做关联。

最后,在每个 QuerySet 可以通过组合 select_related 和 prefetch_related 的方式,更改查询数据库的逻辑。

参考

https://docs.djangoproject.com/en/3.1/ref/models/querysets/]

(https://docs.djangoproject.com/en/3.1/ref/models/querysets/)

https://medium.com/better-programming/django-select-related-and-prefetch-related-f23043fd635d

https://stackoverflow.com/questions/39669553/django-rest-framework-setting-up-prefetching-for-nested-serializers

[https://medium.com/@michael_england/debugging-query-performance-issues-when-using-the-django-orm-f05f83041c5f

到此这篇关于详解Django ORM引发的数据库N+1性能问题的文章就介绍到这了,更多相关Django ORM 数据库N+1性能内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Django视图之ORM数据库查询操作API的实例

    查询表记录 查询相关API 操作:models.表名.objects.方法() <BR>all(): 查询所有结果 filter(**kwargs): 它包含了与所给筛选条件相匹配的对象 get(**kwargs): 返回与所给筛选条件相匹配的对象,返回结果有且只有一个, 如果符合筛选条件的对象超过一个或者没有都会抛出错误. exclude(**kwargs): 它包含了与所给筛选条件不匹配的对象 values(*field): 返回一个ValueQuerySet--一个特殊的QuerySet

  • 详解关于Django中ORM数据库迁移的配置

    简介 ORM: 关系对象映射.定义一个类自动生成数据库的表结构. 创建数据库的时候,一般有以下几种常用数据类型:数字.字符串以及时间. ORM分为两种: DB First 数据库里先创建数据库表结构,根据表结构生成类,根据类操作数据库 Code First 先写代码,执行代码创建数据库表结构 主流的orm都是code first.django 的orm也是code first,所以学的时候,本质就分为两块: 根据类自动创建数据库表 根据类对数据库表中的数据进行各种操作 手动创建mysql数据库,

  • Django基于ORM操作数据库的方法详解

    本文实例讲述了Django基于ORM操作数据库的方法.分享给大家供大家参考,具体如下: 1.配置数据库 vim settings #HelloWorld/HelloWorld目录下 DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', #mysql数据库中第一个库test 'NAME': 'test', 'USER': 'root', 'PASSWORD': '123456', 'HOST':'127.0.0.1', '

  • Django Form 实时从数据库中获取数据的操作方法

    Django Form 实时从数据库中获取数据 ,具体内容如下所示: 修改 models.py 添加 class UserType(models.Model): caption = models.CharField(max_length=32) 执行命令,生成数据库 python manage.py makemigrations python manage.py migrate 修改 forms.py 添加 from app01 import models class DBForm(DForms

  • Python Django form 组件动态从数据库取choices数据实例

    app01/models.py: from django.db import models class UserInfo(models.Model): username = models.CharField(max_length=16) password = models.CharField(max_length=16) mobile = models.CharField(max_length=11) class City(models.Model): name = models.CharFie

  • Django框架ORM数据库操作实例详解

    本文实例讲述了Django框架ORM数据库操作.分享给大家供大家参考,具体如下: 测试数据:BookInfo表 PeopleInfo表 一.增加 1.save: 对象 = 模型类( 字段名 = 值, 字段名 = 值, - ) 对象.save() 例: >>> book = BookInfo( ... name='python入门', ... pub_date='2010-1-1' ... ) >>> book.save() >>> book <B

  • 自定义Django Form中choicefield下拉菜单选取数据库内容实例

    工作中遇到的问题,自定义了一个forms.form表单,某项需要作出下拉菜单,下拉菜单中的选项需要从数据库(objectForm models)中提取. form.py为: class objectForm(forms.Form): pre = choicefield(lable = "工作") 最后的解决办法: 1.定义一个函数 def get_object(request): r = [('', '----')] for obj in objectModels.objects.al

  • 详解Django ORM引发的数据库N+1性能问题

    背景描述 最近在使用 Django 时,发现当调用 api 后,在数据库同一个进程下的事务中,出现了大量的数据库查询语句.调查后发现,是由于 Django ORM 的机制所引起. Django Object-Relational Mapper(ORM)作为 Django 比较受欢迎的特性,在开发中被大量使用.我们可以通过它和数据库进行交互,实现 DDL 和 DML 操作. 具体来说,就是使用 QuerySet 对象来检索数据, 而 QuerySet 本质上是通过在预先定义好的 model 中的

  • 详解Django的model查询操作与查询性能优化

    1 如何 在做ORM查询时 查看SQl的执行情况 (1) 最底层的 django.db.connection 在 django shell 中使用  python manage.py shell >>> from django.db import connection >>> Books.objects.all() >>> connection.queries ## 可以查看查询时间 [{'sql': 'SELECT "testsql_boo

  • 详解Django中异步任务之django-celery

    Celery文档参考:http://docs.jinkan.org/docs/celery/ 参考文章:https://www.jb51.net/article/158046.htm Django中异步任务---django-celery Celery简单介绍: celery使用场景: 耗时任务定时任务 请求结果不怎么重要的 耗时任务比如:发送短信验证码我们可以先发送给客户任务状态(请求成功或失败) 请求结果重要的建议使用django实现 比如:支付 首先简单介绍一下,Celery 是一个强大的

  • 详解Django自定义图片和文件上传路径(upload_to)的2种方式

    最近在做一个仿知乎网站的项目了,里面涉及很多图片和文件上传.趁此机会我给大家总结下Django自定义图片和文件上传路径的2种方式吧. 方法1: 在Django模型中定义upload_to选项. Django模型中的ImageField和FileField的upload_to选项是必填项,其存储路径是相对于MEIDA_ROOT而来的. 我们来看一个简单案例(如下所示).如果你的MEDIA_ROOT是/media/文件夹,而你的上传文件夹upload_to="avatar", 那么你上传的

  • 详解Django+Vue+Docker搭建接口测试平台实战

    一. 开头说两句 大家好,我叫林宗霖,是一位测试工程师,也是全栈测开训练营中的一名学员. 在跟着训练营学习完Docker容器技术系列的课程后,理所应当需要通过实操来进行熟悉巩固.正好接口自动化测试平台需要迁移到新的测试服务器上,就想要体验一番Docker的"一次构建,处处运行".这篇文章简单介绍了下这次部署的过程,其中使用了Dockerfile定制镜像和Docker-Compose多容器编排. 二. 项目介绍 项目采用的是前后端分离技术来实现的,前端是Vue+ElementUI,后端是

  • 详解Django的MVT设计模式

    经典的MVC设计模式及其优点 MVC即 Model-View-Controller(模型-视图-控制器) ,是经典的软件开发设计模式. **Model (模型) **: 简而言之即数据模型.模型不是数据本身(比如数据库里的数据),而是抽象的描述数据的构成和逻辑关系.通常模型包括了数据表的各个字段(比如人的年龄和出生日期)和相互关系(单对单,单对多关系等).Web开发框架会根据模型的定义来自动生成数据表. View (视图): 主要用于显示数据,用来展示用户可以看到的内容或提供用户可以输入或操作的

  • 详解Spring Hibernate连接oracle数据库的配置

    详解Spring Hibernate连接oracle数据库的配置 jdbc.properties文件配置如下  driverClassName=oracle.jdbc.driver.OracleDriver url=jdbc\:oracle\:thin\:@localhost\:1521\: database=OA username=oa password=oa initialSize=2 maxActive=10 maxIdle=2 minIdle=2 removeAbandoned=true

  • 详解Django模版中加载静态文件配置方法

    1.settings.INSTALLED_APPS下添加:django.contrib.staticfiles 2.settings.py下添加:STATIC_URL = '/static/' 3. (1)在APP下新建文件夹static,然后在这个static文件夹下创建一个当前APP的名字的文件夹,再把静态文件放到这个文件夹下:(类似于Templates配置) 在模板中这样调用: {% load static %} <img src="{% static 'front/logo.jpg

  • 详解django中Template语言

    Django是一个开放源代码的Web应用框架,由Python写成.采用了MTV的框架模式,即模型M,视图V和模版T.它最初是被开发来用于管理劳伦斯出版集团旗下的一些以新闻内容为主的网站的,即是CMS(内容管理系统)软件.并于2005年7月在BSD许可证下发布.这套框架是以比利时的吉普赛爵士吉他手Django Reinhardt来命名的. 下面看下本文重点给大家介绍的django中Template语言,详情如下: Template本身也有自己的语言和语法,用来处理简单的数据显示 1.常用语法 判断

  • 详解django使用include无法跳转的解决方法

    一般的django项目我都喜欢采用以下的文件结构,使用include的方式,实现从总的url分配给apps里面的url Example: -projtect ---apps -----user -------urls.py -urls.py 但突然发现无法跳转,竟然是总url的这个错误! 以下是错误做法 urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^', views.Index.as_view(), name='index'),

随机推荐