详解Django ORM引发的数据库N+1性能问题
本文深入解析Django ORM中常见的N+1查询性能瓶颈,通过具体代码示例展示问题成因。重点讲解如何利用select_related和prefetch_related方法优化数据库查询,结合Prefetch对象处理复杂关联,并提供SQL日志记录方案,帮助开发者提升Django应用性能。
Django ORM N+1查询问题背景
背景描述
近期在使用Django框架开发时,观察到调用API后,在同一数据库进程的事务中产生了大量SQL查询语句。经排查,该现象由Django ORM的默认行为机制导致。
Django Object-Relational Mapper(ORM)作为其核心特性之一,在开发中应用广泛。它允许开发者通过Python代码与数据库交互,执行DDL(数据定义语言)和DML(数据操作语言)操作。
具体而言,数据检索通过QuerySet对象实现,而QuerySet本质上是基于预先定义的Model中的Manager与数据库进行交互的工具。
Manager是Django Model提供的数据库查询接口,每个Model至少包含一个Manager对象。但本文重点探讨的对象并非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日志记录。
- 在MySQL数据库中开启SQL日志记录功能。
在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与N+1问题成因
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记录,对应的原始SQL为select * from interface limit 5; ,此过程无异常。
但在后续获取接口所属设备名时,会出现反复调用数据库的情况:每遍历一个接口,便通过已获取的device_id去数据库查询device_name。对应的原始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提供的select_related()或prefetch_related()方法。
使用select_related优化一对一及多对一关系
select_related
调用select_related()方法时,Queryset会将所属Model的外键关系一并查询。相当于原始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
prefetch_related与select_related目的相同,均为避免大量查询关系时的数据库调用。但为避免多表join后产生的巨大结果集及效率问题,select_related更偏向于外键(多对一)和一对一关系。
而prefetch_related的实现方式类似于前述原生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执行逻辑变为:
- "SELECT * FROM interface "
- "SELECT * FROM device where device_id in (.....)"
- 随后通过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())
执行逻辑亦为:
- "SELECT * FROM device"
- "SELECT * FROM interface where device_id in (.....)"
- 随后通过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对象控制复杂关联查询
可利用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,可实现控制关联哪些相关对象。
组合使用select_related与prefetch_related
最后,对于关联结构较复杂的情况,可将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性能内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













