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 prefetch\t 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])
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。