HOME
HOME
文章目录
  1. 1. 出现背景
  2. 2. 探索解决
  3. 3. 总结
  4. 4. 参考文档

记一次django orm的n+1问题的处理

最近在django项目中碰到了N+1问题,就是查询出数据库的结果后,需要遍历处理所有queryset对象的时候,django默认还会去查询一次数据库,这就导致了ORM的N+1问题,其实叫1+N问题更好,就是1个总的查询,加上查询了n个对象,就会出现n次额外不必要的查询。

1. 出现背景

代码中存在assets和systems两个model,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Systems(TimestampModel):

system_name = models.CharField(max_length=100, verbose_name='系统名称', null=False, blank=False)


class Assets(TimestampModel):

system = models.ManyToManyField(Systems, verbose_name='系统名称',)

@property
def get_system_name(self):
if self.system。count() == 1:
return self.system.first().system_name + '【匹配】'
else:
return None

def to_zh_dict(self, fields=None, exclude=None):
"""
model转为中文键值的dict
:return:
"""
data = {}
for f in self._meta.concrete_fields + self._meta.many_to_many:
value = f.value_from_object(self)

if fields and f.name not in fields:
continue

if exclude and f.name in exclude:
continue

if isinstance(f, ManyToManyField):
value = [i.id for i in value] if self.pk else None

if f.name == 'id':
continue

if f.name == 'system':
value = self.get_system_name

else:
key = out_table_map.get(f.name)

data[key] = value

return data

在输出的时候使用了以下代码:

1
2
3
4
all_assets = models.Assets.objects.all()

if all_assets:
all_assets_list = [asset.to_zh_dict() for asset in all_assets]

在这种情况下,all_assets = models.Assets.objects.all()会执行一次查询,将所有的assets查询出来,而在后面的遍历中,django会默认执行n个数量的查询,n等于资产的数据。

同时,我在get_system_name的属性中执行了一次关联查询,会查询到第一个system的名字,这样其实产生的查询是1+n*n,效率更加低。

在数据量较大的时候,会产生很严重的数量问题。我在导出2w左右资产的时候,此处的性能消耗达到了20多分钟的时间。

2. 探索解决

为了便于检查SQL执行的情况,可以在django的settings文件中加入以下配置,开启日志显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'propagate': True,
'level': 'DEBUG',
},
}
}

@cached_property

使用@property属性计算有一个不好的地方,就是每次访问该属性的时候,该属性会重新计算一次,也就是说,在一段代码中访问n次property,就会产生n次全量计算,如果在property中进行了时间比较长的IO操作的话,就会导致比较严重的性能问题,所以python和django均提供了@cached_property方法来缓存属性。python的包在 from functools import cached_property,django的包在from django.utils.functional import cached_property

加上该缓存属性后,将self.system_count的计算独立出来,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Assets(TimestampModel):

system = models.ManyToManyField(Systems, verbose_name='系统名称',)

@cached_property
def system_count(self):
return self.system.count()

@property
def get_system_name(self):
if self.system_count == 1:
return self.system.first().system_name + '【匹配】'
else:
return None

这样会减少一部分查询,但是仍然查询量很大,n的问题仍然没有解决。

prefetch_related

django针对这种情况提供了两个数据库的api,一个是select_related()另一个是prefetch_related(),通过这两个API,数据库可以先进行join查询,一次性将关联的数据查询出来,后续在遍历的时候,在该查询的queryset上进行遍历,不会再次查询。

select_related()主要针对外键查询,prefetch_related()针对多对多关系的查询。

经过优化后,查询的代码如下

1
2
3
4
5
all_assets = models.Assets.objects.all().prefetch_related(Prefetch('system', queryset=models.Systems.objects.all()))


if all_assets:
all_assets_list = [asset.to_zh_dict() for asset in all_assets]

经过优化后,查询次数可以减少到3次,运行速度大幅度提升。prefetch_related()还有其他注意的细节部分,可以参考django文档https://docs.djangoproject.com/zh-hans/4.1/ref/models/querysets/#prefetch-related

3. 总结

经过优化后,查询3万资产的时间从20多分钟,减少到1分钟左右,速度提升明显,但是在[asset.to_zh_dict() for asset in all_assets]循环遍历所有资产的时候,仍然速度较慢,这个跟python语言循环慢很有关系,后续可以考虑异步进行优化。

4. 参考文档

1、django文档:https://docs.djangoproject.com/zh-hans/4.1/ref/models/querysets/#prefetch-related

2、https://blog.labdigital.nl/working-with-huge-data-sets-in-django-169453bca049

3、https://zhuanlan.zhihu.com/p/27323883?utm_medium=social&utm_oi=817506172126003200&utm_psn=1581071419814658048&utm_source=wechat_session

4、https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping

5、https://scoutapm.com/blog/django-and-the-n1-queries-problem

6、https://stackoverflow.com/questions/23489548/how-do-i-invalidate-cached-property-in-django

7、https://medium.com/@fdemmer/django-cached-property-on-models-f4673de33990

8、https://blog.csdn.net/study_in/article/details/95366421

9、https://www.cnblogs.com/garyhtml/p/15965617.html

10、https://www.reddit.com/r/django/comments/6aexjt/using_prefetch_with_drf_viewsets_to_include_a/

11、https://www.cnblogs.com/michael9/p/13797403.html