Django中的数据库访问优化——预加载关联数据
Django的模型层提供了一套ORM系统,这使得我们无需学习SQL也能利用数据库来存储相关数据。一次query获取所有需要的数据,往往比多次query分别取得数据要更高效。但由于django模型的数据库检索过程隐藏在后台,不注意的话很容易导致多次检索数据库,浪费不必要的时间。因此充分理解django模型的query机制十分重要。 django官方文档给出了很多数据库访问优化的建议:Database access optimization。这些建议对于提升代码的效率十分有帮助,但其内容较多,本文只介绍其中的一种优化手段,预加载关联数据。 模型中经常会用到外键和多对多关系,但是queryset在获取对象的数据时,如果不指定的话,则不会检索关联对象的数据。当你调用关联对象时,queryset还会再一次访问数据库。因此当你循环多个对象并调用其外键所关联的对象时,django会不停的访问数据库,以获取其所需的数据。 这样说或许有点抽象,下面结合例子详细解释说明,例子使用以下的模型。 from?django.db?import?models from?django.contrib.auth.models?import?User class?Category(models.Model): ????name?=?models.CharField('分类',?max_length=16) class?Topic(models.Model): ????name?=?models.CharField('话题',?max_length=16) class?Article(models.Model): ????'文章' ????title?=?models.CharField('标题',?max_length=100) ????content?=?models.TextField('内容') ????pub_date?=?models.DateTimeField('发布日期') ????category?=?models.ForeignKey(Category) ????topics?=?models.ManyToManyField(Topic) class?ArticleComment(BaseComment): ????'文章评论' ????content?=?models.TextField('评论') ????article?=?models.ForeignKey(Article,?related_name='comments') 先简单看看查询的机制,通过模型的 Manager构建一个QuerySet对象,QuerySet是懒加载,总是等到要用到结果时才去访问数据库。QuerySet在访问数据库时,实际上是使用SQL语句获取结果的。我们可以通过logging查看SQL语句,调整logging等级为DEBUG即可,我在另一篇文章中有介绍:如何查看Django ORM执行的sql语句。或者查看query属性print(QuerySet.query)。 >>>?Article.objects.all() SELECT?`blog_article`.`id`,?`blog_article`.`title`,?`blog_article`.`content`,?`blog_article`.`pub_date`,?`blog_article`.`category_id`?FROM?`blog_article`; 可以看到,一般的QuerySet只取出模型对应的表中的数据,但不会取得关联表中的数据。这意味着只获得外键id,而非外键所指向的数据,至于多对多关系则什么不能获得,因为多对多关系的数据实际都保存在另一个中间表里。 select_related——预加载单个关联对象 Article与Category用外键关联,是多对一关系,一篇文章只能属于一个分类,一个分类可以包含多篇文章。获取一篇文章的分类,即调用Article.category属性。但由于文章中缓存的仅仅只是文章分类的id Article.category.id,而非完整的Category对象,所以当使用文章的category属性时,django会再次访问数据库,以检索其内容。 如下,当用for循环打印文章分类Article.category时,每一次循环都会访问一次数据库。而且,文章的分类往往是重复的,同样的分类可能在for中重复检索了多次,这样的用法显然相当耗时。 #?访问一次数据库,获得Article对象 for?a?in?Article.objects.all(): ????#?访问n次数据库,每次循环都要重新检索Category的数据 ????print(a.category) 使用select_related则可以一次性获取对象以及关联的对象,只需访问一次数据库: for?a?in?Article.objects.all().select_related('category'): ????#?已经缓存了数据,不会再次访问数据库 ????print(a.category) 再用query属性看一下SQL语句,select_related()使用JOIN获取了Category模型的数据。这样就预先加载了外键关联的对象,再次调用关联对象时就不会访问数据库了。 >>>?Article.objects.select_related('category')?#?all()可以省略 SELECT?`blog_article`.`id`,?`blog_article`.`category_id`,?`blog_category`.`id`,?`blog_category`.`name`?FROM?`blog_article`?INNER?JOIN?`blog_category`?ON?(`blog_article`.`category_id`?=?`blog_category`.`id`); 获取外键的外键只需用双下划线隔开就行,以此类推。比如:ArticleComment.objects.select_related('article__category')可以同时预加载该评论归属的文章以及该文章归属的分类。 然而,为了避免由于加入多个关联对象而导致的结果集太大,select_related仅限于获取单值关系——外键和一对一关系。 prefetch_related——预加载多个关联对象 预加载多个关联的对象时,需要使用prefetch_related,它分别查询每一个关系,然后在Python中完成关联对象间的连接。 接下来还是用Article与Category举例,在数据库的article表中,保存了分类的id,但是在category表中,并没有保存下属文章的id。要想获取某分类下的文章,有两种手段: c?=?Category.objects.get(id=1) #?这两种方法是等价的,都要访问一次数据库 c.article_set.all() Article.objects.filter(category=c) 再看看查找多个分类下的文章时的情况: #?访问1次数据库,获得分类 for?c?in?Category.objects.all(): ????#?访问n次数据库,获得文章 ????c.article_set.all() 这种情况下不能使用select_related,因为有多个关联对象时,需要用prefetch_related。这个方法会将所需的关联对象全部加载至内存中,每次调用c.article_set.all()时将直接从缓存中加载对象。 #?访问2次数据库,获得分类与文章 for?c?in?Category.objects.prefetch_related('article_set'): ????#?直接调用缓存,不再访问数据库 ????c.article_set.all() 为什么是两次?第一步检索分类,第二步检索所属的文章,使用SELECT和IN语句查询,相当于: >>>?#?prefetch_related >>>?Category.objects.prefetch_related('article_set') SELECT?`blog_category`.`id`,?`blog_category`.`name`,?`blog_category`.`number`?FROM?`blog_category`; SELECT?`blog_article`.`id`,?FROM?`blog_article`?WHERE?`blog_article`.`category_id`?IN?(1,?2,?3,?...); ... >>>?#?no?prefetch_related >>>?c?=?Category.objects.all() >>>?a?=?Article.objects.filter(category__in=c) >>>?print(c) SELECT?`blog_category`.`id`,?`blog_category`.`number`?FROM?`blog_category`;?args=() ... >>>?print(a) SELECT?`blog_article`.`id`,?`blog_article`.`category_id`?FROM?`blog_article`?WHERE?`blog_article`.`category_id`?IN?(SELECT?`blog_category`.`id`?FROM?`blog_category`) ... 多对多关系也是类似的情况,以Article和Topic为例,使用该方法也能预加载关联对象,Article.objects.prefetch_related('topics')。 Prefetch——进一步控制预加载操作 Prefetch可以用于进一步控制预加载时的操作,例如,下面的代码使用Prefetch将分类下的文章限制为id大于5的文章: >>>?from?django.db.models?import?Prefetch >>>?c=Category.objects.prefetch_related('article_set').get(id=2) SELECT?`blog_article`.`id`,?FROM?`blog_article`?WHERE?`blog_article`.`category_id`?IN?(2); >>>?c.article_set.count()?#?不需访问数据库 11 >>>?qs=Article.objects.filter(id__gt=5) >>>?c=Category.objects.prefetch_related(Prefetch('article_set',queryset=qs)).get(id=2) SELECT?`blog_article`.`id`,?FROM?`blog_article`?WHERE?(`blog_article`.`id`?>?5?AND?`blog_article`.`category_id`?IN?(2)); >>>?c.article_set.count()?#?结果与前一个不一样了 7 除此之外,还可以用to_attr参数指定预加载结果为初始对象的属性,这样就不会覆盖原来的Manager,to_attr指定的属性将预加载的结果保存在列表中。 >>>?c=Category.objects.prefetch_related(Prefetch('article_set',queryset=qs,to_attr='aidgt5')).get(id=2) SELECT?`blog_article`.`id`,?FROM?`blog_article`?WHERE?(`blog_article`.`id`?>?5?AND?`blog_article`.`category_id`?IN?(2)); >>>?c.article_set.count()?#?执行SQL语句,因为没有缓存该queryset SELECT?COUNT(*)?AS?`__count`?FROM?`blog_article`?WHERE?`blog_article`.`category_id`?=?2; 11 >>>?len(c.aidgt5)?#?已缓存,无需访问数据库 7 预加载性能对比 使用select_related和prefetch_related能大大减少访问数据库的次数,但这对性能有多大提升呢?我们依然没有一个直观上的印象。接下来将通过实际运行代码,对比非预加载和预加载在效率上的区别。(其实是因为我不会分析算法复杂度,只能对比实际运行时间了...) def?articles_retrieve_no_prefetch(): ????for?a?in?Article.objects.all(): ????????print(a.category,'n',a.topics.all()) def?articles_retrieve_prefetch(): ????for?a?in?Article.objects.all()? ????.select_related('category')? ????.prefetch_related('topics'): ????????print(a.category,a.topics.all()) 上面两个函数分别定义了简单的数据库查询,以及使用了预加载的数据库查询,并打印其结果。 测试方法为两个函数分别运行100次,并统计单次运行所耗费的时间。具体的统计函数将放在后面,硬件和软件配置就不赘述了,直接上结果,其中平均值为函数单次运行所花费的时间,单位为s。 很明显,预加载的性能更高。 django的模型虽然简单易用,但是不能浅尝辄止,要深入理解其背后的原理,并合理使用查询方法。否则很容易执行许多次不必要的数据库访问,造成严重的性能浪费。 因此,数据库访问优化至关重要,本文仅仅只是介绍了一种优化方法,更多的优化方法请参考django官方文档:Database access optimization。 下面给出详细的统计函数,如果有兴趣可以在自己的项目中跑一下, '''run?this?uder?Django?project''' import?time,?statistics from?django.utils?import?timezone from?blog.models?import?Article def?count_run_time(func): ????start=time.perf_counter() ????func() ????end=time.perf_counter() ????return?end-start def?statistic_run_time(func,?n): ????data?=?[?count_run_time(func)?for?i?in?range(n)] ????mean?=?statistics.mean(data) ????sd?=?statistics.stdev(data,?xbar=mean) ????return?[data,?mean,?sd,?max(data),?min(data)] def?compare_articles_retrieve_time(n): ????result1?=?statistic_run_time(articles_retrieve_no_prefetch,?n) ????result2?=?statistic_run_time(articles_retrieve_prefetch,?n) ????print('对比t?no?prefetcht?prefetch') ????print('平均值t',result1[1],'t',result2[1]) ????print('标准差t',result1[2],result2[2]) ????print('最大t',result1[3],result2[3]) ????print('最小t',result1[4],result2[4]) (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |