在开发多个Django应用程序的过程中,我学到了很多有关速度优化的知识。 此过程的某些部分,无论是后端还是前端,都没有详细记录。 我决定收集本文中我所知道的大部分内容。
如果您从未认真研究过网络应用的性能,那么一定会在这里找到不错的东西。
为什么速度很重要
不同的应用程序,不同的瓶颈
分析和调试性能问题
免责声明
后端:数据库层
select_related
prefetch_related
索引编制
只拿你需要的东西
后端:请求层
分页
异步执行/后台任务
压缩Django的HTTP响应
快取
前端:变得更毛茸茸
提供静态文件
词汇
使用WhiteNoise从Django提供静态文件
使用Django压缩器进行压缩和合并
缩小CSS和JS
延迟加载JavaScript
延迟加载图像
优化和动态缩放图像
未使用的CSS:删除导入
未使用的CSS:使用PurgeCSS清除CSS
结论
附录
用于QuerySet性能分析的装饰器
为什么速度很重要
在网络上,100毫秒可以带来很大的不同,而1秒钟是一生。无数的研究表明,更快的加载时间与更好的转化率,用户保留率以及来自搜索引擎的自然流量相关。最重要的是,它们提供了更好的用户体验。
不同的应用程序,不同的瓶颈
有许多技术和实践可以优化您的网络应用的性能。很容易被带走。寻找最高的工作量回报率。不同的Web应用程序具有不同的瓶颈,因此,当这些瓶颈得到解决时,它们将获得最大收益。根据您的应用程序,一些技巧将比其他技巧有用。
尽管本文是针对Django开发人员的,但此处的速度优化技巧几乎可以调整为任何堆栈。在前端方面,它对于使用Heroku托管的用户以及无法访问CDN服务的用户特别有用。
分析和调试性能问题
在后端,我推荐使用久经考验的django-debug-toolbar。它将帮助您分析请求/响应周期,并了解大部分时间花在了哪里。尤其有用,因为它提供了数据库查询的执行时间,并在浏览器中显示的单独窗格中提供了不错的SQL EXPLAIN。
Google PageSpeed将主要显示与前端有关的建议,但是有些建议也可以应用于后端(例如服务器响应时间)。 PageSpeed分数与加载时间并不直接相关,但应该可以让您清楚了解应用程序的低挂水果在哪里。在开发环境中,您可以使用Google Chrome浏览器的Lighthouse(灯塔),该灯塔提供相同的指标,但可以使用本地网络URI。 GTmetrix是另一个细节丰富的分析工具。
免责声明
有人会告诉您,这里的某些建议是错误的或缺乏的。没关系;这并不意味着要成为圣经或终极指南。将这些技术和技巧视作您可能会使用,不应该或必须使用的技巧。不同的需求要求不同的设置。
后端:数据库层
从后端开始是一个好主意,因为通常是在后台进行大部分繁重工作的层。
在我的脑海中,毫无疑问,我首先要提到的两个ORM功能是:select_related和prefetch_related。它们都专门处理检索相关对象,并且通常通过最小化数据库查询的数量来提高速度。
select_related
让我们以一个音乐网络应用为例,它可能具有以下模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# music/models.py, some fields & code omitted for brevity class RecordLabel(models.Model): name = models.CharField(max_length=560) class MusicRelease(models.Model): title = models.CharField(max_length=560) release_date = models.DateField() class Artist(models.Model): name = models.CharField(max_length=560) label = models.ForeignKey( RecordLabel, related_name="artists", on_delete=models.SET_NULL ) music_releases = models.ManyToManyField( MusicRelease, related_name="artists" ) |
因此,每个艺术家与一个唱片公司相关,并且每个唱片公司可以与多位艺术家签约:经典的一对多关系。 艺术家有许多音乐发行,每个发行可以属于一个或多个艺术家。
我创建了一些虚拟数据:
20条记录标签
每个唱片公司有25位艺术家
每个艺术家有100个音乐作品
总体而言,我们的小型数据库中有约50,500个这些对象。
现在,我们建立一个相当标准的功能,以吸引我们的艺术家和他们的唱片公司。 django_query_analyze是我编写的装饰器,用于计算数据库查询的数量和运行该函数的时间。 它的实现可以在附录中找到。
1 2 3 4 5 6 7 8 |
# music/selectors.py @django_query_analyze def get_artists_and_labels(): result = [] artists = Artist.objects.all() for artist in artists: result.append({"name": artist.name, "label": artist.label.name}) return result |
get_artists_and_labels是您可以在Django视图中使用的常规函数。 它会返回一个词典列表,每个词典都包含艺术家的姓名及其标签。 我正在访问artist.label.name来强制评估Django QuerySet; 您可以将其等同于尝试在Jinja模板中访问这些对象:
1 2 3 |
{% for artist in artists_and_labels %} <p>Name: {{ artist.name }}, Label: {{ artist.label.name }}</p> {% endfor %} |
运行:
1 2 3 4 |
ran function get_artists_and_labels -------------------- number of queries: 501 Time of execution: 0.3585s |
因此,我们在0.36秒内拉了500位艺术家及其标签,但更有趣的是,我们已经打了501次数据库。 对于所有艺术家,一次,再五百次:对每个艺术家的标签一次。 这称为“ N + 1问题”。 让我们告诉Django使用select_related在同一查询中检索每个艺术家的标签:
1 2 3 4 5 6 7 8 9 |
@django_query_analyze def get_artists_and_labels_select_related(): result = [] artists = Artist.objects.select_related("label") # select_related for artist in artists: result.append( {"name": artist.name, "label": artist.label.name if artist.label else "N/A"} ) return result |
运行:
1 2 3 4 |
ran function get_artists_and_labels_select_related -------------------- number of queries: 1 Time of execution: 0.01481s |
减少500个查询,速度提高96%。
prefetch_related
让我们看一下另一个功能,用于获取每位艺术家的前100首音乐作品:
1 2 3 4 5 6 7 8 9 10 11 12 |
@django_query_analyze def get_artists_and_releases(): result = [] artists = Artist.objects.all()[:100] for artist in artists: result.append( { "name": artist.name, "releases": [release.title for release in artist.music_releases.all()], } ) return result |
为每个艺术家获取100位艺术家和100个发行版本需要多长时间?
1 2 3 4 |
ran function get_artists_and_releases -------------------- number of queries: 101 Time of execution: 0.18245s |
让我们在此函数中更改artists变量并添加select_related,这样我们可以减少查询数量并有望提高速度:
1 |
artists = Artist.objects.select_related("music_releases") |
如果您确实这样做,则会收到错误消息:
1 |
django.core.exceptions.FieldError: Invalid field name(s) given in select_related: 'music_releases'. Choices are: label |
这是因为select_related仅可用于缓存ForeignKey或OneToOneField属性。 歌手与MusicRelease之间的关系是多对多的,这就是prefetch_ related出现的地方:
1 2 3 4 5 6 7 8 9 10 11 12 |
@django_query_analyze def get_artists_and_releases_prefetch_related(): result = [] artists = Artist.objects.all()[:100].prefetch_related("music_releases") # prefetch_related for artist in artists: result.append( { "name": artist.name, "releases": [rel.title for rel in artist.music_releases.all()], } ) return result |
select_related只能缓存“一对多”关系的“一侧”,或“一对一”关系的任一侧。 您可以将prefetch_related用于所有其他缓存,包括一对多关系中的多面关系和多对多关系。 这是我们示例中的改进:
1 2 3 4 |
ran function get_artists_and_releases_prefetch_related -------------------- number of queries: 2 Time of execution: 0.13239s |
ok,
有关select_related和prefetch_related的注意事项:
如果您不建立数据库连接池,则由于往返数据库的次数减少,收益将更大。
对于非常大的结果集,运行prefetch_related实际上会使事情变慢。
一个数据库查询不一定比两个或多个查询更快。
索引编制
索引数据库列可能会对查询性能产生很大影响。那为什么不是本节的第一条款呢?因为索引比简单地在模型字段上分散db_index = True更为复杂。
在经常访问的列上创建索引可以提高与它们相关的查找速度。索引虽然要付出额外的写入和存储空间的代价,所以您应该始终衡量自己的收益:成本比率。通常,在表上创建索引会减慢插入/更新的速度。
只拿你需要的东西
如果可能,请使用values()尤其是values_list()来仅提取数据库对象的所需属性。继续我们的示例,如果我们只想显示一个艺术家名称列表并且不需要完整的ORM对象,通常最好这样编写查询:
1 2 3 4 5 6 7 8 |
artist_names = Artist.objects.values('name') # <QuerySet [{'name': 'Chet Faker'}, {'name': 'Billie Eilish'}]> artist_names = Artist.objects.values_list('name') # <QuerySet [('Chet Faker',), ('Billie Eilish',)]> artist_names = Artist.objects.values_list('name', flat=True) # <QuerySet ['Chet Faker', 'Billie Eilish']> |
后端:请求层
我们要看的下一层是请求层。 这些是您的Django视图,上下文处理器和中间件。 此处的正确决策也将带来更好的性能。
分页
在与select_related有关的部分中,我们使用该函数返回500个艺术家及其标签。 在许多情况下,返回这么多对象是不现实的或不希望的。 Django文档中有关分页的部分非常清楚如何使用Paginator对象。 如果您不希望向用户返回多于N个对象,或者这样做会使您的网络应用运行太慢,请使用它。
异步执行/后台任务
有时某些动作不可避免地会花费很多时间。 例如,用户请求将大量对象从数据库导出到XML文件。 如果我们在同一过程中进行所有操作,则流程如下所示:
1 |
web: user requests file -> process file -> return response |
假设处理此文件需要45秒。 您并不是真的要让用户一直等待等待响应。 首先,从UX的角度来看,这是一种可怕的体验,其次,因为如果您的应用在N秒后未以正确的HTTP响应进行响应,则某些主机实际上会缩短该过程。
在大多数情况下,明智的做法是从请求-响应循环中删除此功能,并将其中继到其他进程:
1 2 3 4 |
web: user requests file -> delegate to another process -> return response | v background process: receive job -> process file -> notify user |
后台任务不在本文的讨论范围之内,但是如果您需要执行上述操作,我相信您已经听说过Celery之类的库。
压缩Django的HTTP响应
请勿将其与静态文件压缩混淆,后者将在本文后面提到。
压缩Django的HTTP / JSON响应还可以节省用户的延迟。 多少钱 让我们检查一下响应正文中没有经过任何压缩的字节数:
1 2 |
Content-Length: 66980 Content-Type: text/html; charset=utf-8 |
因此,我们的HTTP响应约为67KB。 我们可以做得更好吗? 许多人使用Django的内置GZipMiddleware进行gzip压缩,但是如今,更新,更有效的brotli在所有浏览器中都享有相同的支持(当然,除了IE11)。
重要提示:如Django文档的GZipMiddleware部分所述,压缩可能会打开您的网站,使其违反安全性。
让我们安装出色的django-compression-middleware库。 通过检查请求的Accept-Encoding标头,它将选择浏览器支持的最快压缩机制:
1 |
pip install django-compression-middleware |
将其包含在我们的Django应用的中间件中:
1 2 3 4 5 6 7 |
MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "compression_middleware.middleware.CompressionMiddleware", # ... ] |
然后再次检查body的Content-Length:
1 2 3 |
Content-Encoding: br Content-Length: 7239 Content-Type: text/html; charset=utf-8 |
主体大小现在为7.24KB,缩小了89%。您当然可以辩称,这种操作应该委托给专用服务器,例如Ngnix或Apache。我认为一切都是简单性与资源之间的平衡。
CACHE
缓存是存储特定计算结果以加快将来检索速度的过程。 Django具有出色的缓存框架,可让您在各种级别上使用不同的存储后端进行此操作。
在数据驱动型应用中,缓存可能会很棘手:您永远都不想缓存本应始终显示最新实时信息的页面。因此,最大的挑战不是设置缓存,而是确定要缓存的内容,持续多长时间以及了解何时或如何使缓存无效。
在使用缓存之前,请确保您已经在数据库级别和/或前端进行了适当的优化。如果设计和查询得当,数据库可笑地快速大规模地提取相关信息。
前端:变得更毛茸茸
减少静态文件/资产的大小可以大大加快您的Web应用程序的速度。即使您已正确完成了后端的所有操作,但低效地提供图像,CSS和JavaScript文件也会降低应用程序的速度。
在编译,最小化,压缩和清除之间,很容易迷路。让我们尽量不要。
提供静态文件
您可以在何处以及如何提供静态文件方面有多种选择。 Django的文档提到了运行Ngnix和Apache的专用服务器,Cloud / CDN或同一服务器方法。
我已经采取了一种混合的态度:从CDN提供图像,将大型文件上传到S3,但是其他静态资产(CSS,JavaScript等)的所有服务和处理都是使用WhiteNoise(已发现)完成的。稍后再详细介绍)。
词汇
为了确保我们在同一页面上,这就是我说的意思:
编译:如果您在样式表中使用SCSS,则首先必须将其编译为CSS,因为浏览器不了解SCSS。
缩小:减少空格并从CSS和JS文件中删除注释可能会对它们的大小产生重大影响。有时,这个过程涉及丑陋的事情:将长变量名重命名为短变量名,等等。
压缩/合并:对于CSS和JS,将多个文件合并为一个。对于图像,通常意味着从图像中删除一些数据以减小其文件大小。
清除:删除不需要/不需要的代码。例如,在CSS中:删除未使用的选择器。
使用WhiteNoise从Django提供静态文件
WhiteNoise允许您的Python Web应用程序自己提供静态资产。如其作者所述,当其他选项(例如Nginx / Apache)不可用或不受欢迎时,它就会出现。
让我们安装它:
1 |
pip install whitenoise[brotli] |
在启用WhiteNoise之前,请确保已在settings.py中定义了您的STATIC_ROOT:
1 |
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") |
要启用WhiteNoise,请在settings.py中的SecurityMiddleware下方添加其WhiteNoise中间件:
1 2 3 4 5 |
MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', # ... ] |
在生产中,您必须运行manage.py collectstatic才能使WhiteNoise正常工作。
虽然此步骤不是必须的,但强烈建议您添加缓存和压缩:
1 |
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' |
现在,只要在模板中遇到{%static%}标签,WhiteNoise都会为您压缩和缓存文件。 它还负责缓存无效化。
另一个重要步骤:为了确保我们在开发和生产环境之间获得一致的体验,我们添加了runserver_nostatic:
1 2 3 4 5 |
INSTALLED_APPS = [ 'whitenoise.runserver_nostatic', 'django.contrib.staticfiles', # ... ] |
不论DEBUG是否为True,都可以添加此代码,因为您通常不会在生产环境中通过runserver运行Django。
我发现增加缓存时间也很有用:
1 2 |
# Whitenoise cache policy WHITENOISE_MAX_AGE = 31536000 if not DEBUG else 0 # 1 year |
这会不会导致缓存失效问题? 否,因为在运行collectstatic时WhiteNoise会创建版本化文件:
1 |
<link rel="stylesheet" href="/static/CACHE/css/4abd0e4b71df.css" type="text/css" media="all"> |
因此,当您再次部署应用程序时,您的静态文件将被覆盖并具有不同的名称,因此以前的缓存变得无关紧要。
使用Django压缩器进行压缩和合并
WhiteNoise已经压缩了静态文件,因此django-compressor是可选的。 但是后者提供了额外的增强功能:合并文件。 要将压缩器与WhiteNoise一起使用,我们必须采取一些额外的步骤。
假设用户加载了一个HTML文档,该文档链接了三个.css文件:
1 2 3 4 5 |
<head> <link rel="stylesheet" href="base.css" type="text/css" media="all"> <link rel="stylesheet" href="additions.css" type="text/css" media="all"> <link rel="stylesheet" href="new_components.css" type="text/css" media="all"> </head> |
您的浏览器将对这些位置发出三个不同的请求。 在许多情况下,部署时合并这些不同的文件会更有效,而django-compressor可以使用其{%compress css%}模板标记来做到这一点:
这个:
1 2 3 4 5 6 7 8 |
{% load compress %} <head> {% compress css %} <link rel="stylesheet" href="base.css" type="text/css" media="all"> <link rel="stylesheet" href="additions.css" type="text/css" media="all"> <link rel="stylesheet" href="new_components.css" type="text/css" media="all"> {% compress css %} </head> |
变成:
1 2 3 |
<head> <link rel="stylesheet" href="combined.css" type="text/css" media="all"> </head> |
让我们来看一下使django-compressor和WhiteNoise发挥出色的步骤。 安装:
1 |
pip install django_compressor |
告诉压缩机在哪里可以找到静态文件:
1 2 |
COMPRESS_STORAGE = "compressor.storage.GzipCompressorFileStorage" COMPRESS_ROOT = os.path.abspath(STATIC_ROOT) |
由于这两个库截取请求-响应周期的方式,它们与它们的默认配置不兼容。 我们可以通过修改某些设置来克服这一问题。
我更喜欢在.env文件中使用环境变量,并使用一个Django settings.py,但是如果您具有settings / dev.py和settings / prod.py,您将知道如何转换这些值:
main_project / settings.py:
1 2 3 4 5 |
from decouple import config #... COMPRESS_ENABLED = config("COMPRESS_ENABLED", cast=bool) COMPRESS_OFFLINE = config("COMPRESS_OFFLINE", cast=bool) |
COMPRESS_OFFLINE在生产中为True,在开发中为False。 COMPRESS_ENABLED均为True。
使用脱机压缩时,必须在每个部署上运行manage.py compress。 在Heroku上,您将要禁止该平台为您自动运行collectstatic(默认情况下处于启用状态),而是选择在post_compile钩子中执行此操作,该钩子将在您部署时运行。 如果还没有,请在项目的根目录下创建一个名为bin的文件夹,并在其中创建一个名为post_compile的文件,其中包含以下内容:
1 2 3 |
python manage.py collectstatic --noinput python manage.py compress --force python manage.py collectstatic --noinput |
Compressor的另一个好处是它可以压缩SCSS / SASS文件:
1 2 3 4 |
COMPRESS_PRECOMPILERS = ( ("text/x-sass", "django_libsass.SassCompiler"), ("text/x-scss", "django_libsass.SassCompiler"), ) |
缩小CSS和JS
讨论加载时间和带宽使用情况时要应用的另一重要事项是最小化:通过消除空格和删除注释来(自动)减小代码文件大小的过程。
这里有几种方法,但如果您专门使用django-compressor,则也可以免费获得。 您只需将以下内容(或压缩器支持的其他任何过滤器)添加到settings.py文件中:
1 2 3 4 5 6 7 |
COMPRESS_FILTERS = { "css": [ "compressor.filters.css_default.CssAbsoluteFilter", "compressor.filters.cssmin.rCSSMinFilter", ], "js": ["compressor.filters.jsmin.JSMinFilter"], } |
延迟加载JavaScript
导致性能降低的另一件事是加载外部脚本。 其要点是,浏览器将在遇到<head>标记时以及解析页面其余部分之前尝试获取并执行<head>标记中的JavaScript文件:
1 2 3 4 5 6 |
<html> <head> <script src="https://will-block.js"></script> <script src="https://will-also-block.js"></script> </head> </html> |
我们可以使用async和defer关键字来减轻这种情况:
1 2 3 4 5 |
<html> <head> <script async src="somelib.somecdn.js"></script> </head> </html> |
异步和延迟都允许异步获取脚本而不会阻塞。它们之间的主要区别之一是允许执行脚本的时间:使用异步后,一旦下载了脚本,所有解析都将暂停,直到脚本执行完毕,而使用defer则仅在所有HTML完成后才执行脚本。解析。
我建议参考Flavio Copes在defer和aysnc关键字上的文章。其一般结论是:
使用脚本时,加快页面加载速度的最佳方法是将其放在头部,并在脚本标记中添加defer属性。
延迟加载图像
延迟加载图片意味着我们仅在它们进入客户(用户)视口之前或之后才请求它们。它为您的用户节省了时间和带宽(在蜂窝网络上为$)。有了出色的,无依赖的JavaScript库(例如LazyLoad),确实没有任何理由不延迟加载图像。此外,自版本76起,谷歌浏览器就本机支持lazy属性。
使用前面提到的LazyLoad非常简单,并且该库是非常可定制的。在我自己的应用中,我希望它仅适用于具有懒惰类的图像,并在进入视口之前开始加载300像素的图像:
1 2 3 4 5 6 |
$(document).ready(function (e) { new LazyLoad({ elements_selector: ".lazy", // classes to apply to threshold: 300 // pixel threshold }) }) |
现在,使用现有图像尝试一下:
1 |
<img class="album-artwork" alt="{{ album.title }}" src="{{ album.image_url }}"> |
我们用data-src替换src属性,并将lazy添加到class属性:
1 |
<img class="album-artwork lazy" alt="{{ album.title }}" data-src="{{ album.image_url }}"> |
现在,当该图像在视口下为300像素时,客户端将请求此图像。
如果某些页面上有很多图像,则使用延迟加载将大大缩短加载时间。
优化和动态缩放图像
要考虑的另一件事是图像优化。除了压缩以外,这里还有另外两种技术需要考虑。
首先,文件格式优化。像WebP这样的较新格式在相同质量的情况下大概比普通JPEG图像小25-30%。从02/2020开始,WebP具有不错的浏览器支持,但不完整,因此如果要使用它,则必须提供标准格式的备用。
其次,为不同的屏幕尺寸提供不同的图像尺寸:如果某些移动设备的最大视口宽度为650px,那么为什么要为13英寸2560px视网膜显示屏提供与1050px相同的图像?
您也可以在此处选择适合您的应用程序的粒度和自定义级别。对于更简单的情况,可以使用srcset属性控制大小并以此来完成操作,但是例如,如果您还为同一图像提供具有JPEG后备功能的WebP,则可以将<picture>元素与多个来源和来源一起使用集。
如果上述内容对您和我一样使您感到复杂,则本指南应有助于解释术语和用例。
未使用的CSS:删除导入
如果您使用的是Bootstrap之类的CSS框架,则不要盲目地包含其所有组件。实际上,我将从注释掉所有不必要的组件开始,并仅在需要时逐渐添加这些组件。这是我的bootstrap.scss的一小段,其中所有不同部分均已导入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// ... // Components // ... @import "bootstrap/dropdowns"; @import "bootstrap/button-groups"; @import "bootstrap/input-groups"; @import "bootstrap/navbar"; // @import "bootstrap/breadcrumbs"; // @import "bootstrap/badges"; // @import "bootstrap/jumbotron"; // Components w/ JavaScript @import "bootstrap/modals"; @import "bootstrap/tooltip"; @import "bootstrap/popovers"; // @import "bootstrap/carousel"; |
我不使用徽章或巨型机这样的东西,因此可以放心地将其注释掉。
未使用的CSS:使用PurgeCSS清除CSS
一种更积极,更复杂的方法是使用PurgeCSS之类的库,该库可分析文件,检测未使用的CSS内容并将其删除。 PurgeCSS是一个NPM软件包,因此,如果要在Heroku上托管Django,则需要与Python并行安装Node.js buildpack。
结论
希望您已经找到至少一个可以加快Django应用速度的领域。 如果您有任何疑问,建议或反馈,请随时在Twitter上与我联系。
附录
用于QuerySet性能分析的装饰器
以下是django_query_analyze装饰器的代码:
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 |
from timeit import default_timer as timer from django.db import connection, reset_queries def django_query_analyze(func): """decorator to perform analysis on Django queries""" def wrapper(*args, **kwargs): avs = [] query_counts = [] for _ in range(20): reset_queries() start = timer() func(*args, **kwargs) end = timer() avs.append(end - start) query_counts.append(len(connection.queries)) reset_queries() print() print(f"ran function {func.__name__}") print(f"-" * 20) print(f"number of queries: {int(sum(query_counts) / len(query_counts))}") print(f"Time of execution: {float(format(min(avs), '.5f'))}s") print() return func(*args, **kwargs) return wrapper |
原文:https://openfolder.sh/django-faster-speed-tutorial