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。

很明显,预加载的性能更高。

1.jpg

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 举报,一经查实,本站将立刻删除。

相关推荐


注:所有源代码均实测运行过。所有源代码均已上传CSDN,请有需要的朋友自行下载。
继承APIView和ViewSetMixin;作用也与APIView基本类似,提供了身份认证、权限校验、流量管理等。ViewSet在开发接口中不经常用。
一、Django介绍Python下有许多款不同的 Web 框架。Django是重量级选手中最有代表性的一位。许多成功的网站和APP都基于Django。Django 是一个开放源代码的 Web 应用框架,由 Python 写成。Django 遵守 BSD 版权,初次发布于 2005 年 7 月, 并于 2008 年 9 月发布了第一个正式版本 1.0 。Django学习线路Django 采用了 MVT 的软件设计模式,即模型(Model),视图(View)和模板(Template)。这个MVT模式并
本文从nginx快速掌握到使用,gunicorn快速掌握到使用,实现小白快速搭建django项目,并对可能出现的报错进行了分析
uniapp微信小程序订阅消息发送服务通知
Django终端打印SQL语句 1 Setting配置: 2 默认python 使用的MysqlDB连接,Python3 支持支持pymysql 所有需要在app里面的__init__加上下面配置:
url: re_path('authors/$', views.AuthorView.as_view()), re_path('book/(?P\d+)/$', vie
前提 关于html寻找路线: template 如果在各个APP中存在, Django 会优先找全局template 文件下的html文件,如果全局下的template文件没有相关的html Djan
// GET请求request.GET // POST请求request.POST // 处理文件上传请求request.FILES // 处理如checkbox等多选 接受列表request.get
from bs4 import BeautifulSoup#kindeditordef kindeditor(request): s = ''' <li><s
view.py 配置 html 配置
from django.http import JsonResponse JsonResponse 里面代码会加这一个响应头 kwargs.setdefault('content_type&#
#下面两种是基于QuerySet查询 也就是说SQL中用的jion连表的方式查询books = models.UserInfo.objects.all() print(type(books)) &gt
return HttpResponse("OK") 返回一个字符串 return redirect("/index/") 返回URL return render
from django.http import JsonResponse JsonResponse 里面代码会加这一个响应头 kwargs.setdefault('content_type&#
浏览器有一个很重要的概念——同源策略(Same-Origin Policy)。所谓同源是指,域名,协议,端口相同。不同源的客户端脚本(javascript、ActionScript)在没明确授权的情况
自动发送 > 依赖jQuery文件 实例-->GET请求: 手动发送 > 依赖浏览器XML对象(也叫原生ajax) Ajax主要就是使用 【XmlHttpRequest】对象来完成请
#下面两种是基于QuerySet查询 也就是说SQL中用的jion连表的方式查询books = models.UserInfo.objects.all() print(type(books)) &gt
// GET请求request.GET // POST请求request.POST // 处理文件上传请求request.FILES // 处理如checkbox等多选 接受列表request.get
return HttpResponse("OK") 返回一个字符串 return redirect("/index/") 返回URL return render