在开发多款Django应用后,我学到了一些关于性能优化的知识。对于这些内容,无论前后端,都未曾详细记录,于是我决定在本文做个讨论。
如果您从未认真研究过Web应用程序的性能,或许在文中能够找到想要的答案。
为什么速度很重要
对网络而言,100ms的影响很大,1s则被认为是一个生命周期。无数研究表明更快的加载速度与更好的转换率、用户留存以及来自搜索引擎的自然流量息息相关。更重要的是,它提供了更好的用户体验。
不同应用,不同瓶颈
有许多技术和实践可以用于优化Web应用的性能,然而很容易被带到沟里,只有对症下药才能事半功倍。不同的Web应用有着不同的瓶颈,一旦解决,性能将得到质的飞跃。根据您的应用特点,找到适合的方案才是解决瓶颈问题的根本。
尽管本文针对Django开发人员,但这里提到的优化技巧也适用于其他技术栈。在前端方面,它对使用Heroku和无法访问CDN的开发人员尤为有用。
分析和调试性能问题
对于后端,我建议使用try-and-true django-debug-toolbar。它可以用于分析请求/响应周期,找到最耗时的部分。尤其有用的一点是它提供了数据库查询的执行时间,并在浏览器的独立窗口中提供了SQL EXPLAIN。
Google PageSpeed主要提供与前端相关的优化建议,不过有些也可用于后端(如服务器响应时间)。PageSpeed的分数与加载时间并不直接关联,但是可以让您清晰地了解应用的瓶颈所在。对于开发环境,可以使用Google Chrome’s Lighthouse,它提供相同的分析指标且可使用本地网络URI。此外,GTmetrix也是一个细节丰富的分析工具。
免责声明
也许有读者会认为本文的一些建议是错误的或者存在缺陷,这没关系,因为本文并不是终极指南,您可以按需选择合适的方案。
后端:数据库层
通常后端负责大部分繁重的工作,从此处开始优化是个不错的选择。
毫无疑问,这里我想先提到ORM的两个函数:select_related和prefetch_related。两者都专门用于处理检索相关的对象,并且通常通过最小化数据库查询次数来提升速度。
select_related
以音乐web-app为例,其model如下:
每个艺术家只与一个唱片公司关联,而一个唱片公司可以签约多个艺术家:经典的一对多关系。每个艺术家可以发行多张唱片,每张唱片可以属于一个或多个艺术家。
我创建了一些假数据:
20个唱片公司
每个唱片公司有25个艺术家
每个艺术家发行100首音乐作品
总体而言,这个小型数据库中有大约50500条记录。
下面,让我们实现一个标准函数来获取艺术家及他们的唱片公司。
django_query_analyze是我编写的装饰器,用于计算数据库查询次数和运行该函数的时间,其具体实现参见附录。
get_artists_and_labels是Django视图中一个常规函数,它返回一个列表,每个元素都包含艺术家的名字及其所在的唱片公司。我通过获取artist.label.name来强制评估Django QuerySet;您可以将其等同于尝试在Jinja模板中访问此对象。
下面运行此函数:
在0.36s内,获取了500个艺术家及其唱片公司的信息。有趣的是,数据库被访问了501次。一次是查询所有的艺术家记录,另外500次,每一次都访问一个艺术家的唱片公司信息。这被称为“N+1问题”。下面使用select_related让Django在同一查询中检索每个艺术家的唱片公司。
运行此函数:
发现减少了500次查询,且速度提高了96%。
prefetch_related
让我们来看另一个函数,用于获取每个艺术家发行的前100首音乐:
对于100个艺术家,为每个艺术家获取他们发行的100只唱片需要多长时间呢?
现在修改函数中的artists变量,添加select_related,这样也许会减少查询次数,使速度得到提升:
然而修改之后,会报出如下错误:
这是因为select_related只能用于缓存ForeignKey或OneToOneField属性。而Artist和MusicRelease之间是多对多关系,因此这里需要用到prefetch_ related:
select_related只能缓存单方面的“一对多”关系,或者两边都是“一对一”关系。而prefetch_related可以用于其他场景,如多方面的“一对多”、“多对多”关系。如下是改进后的结果:
真棒!
使用select_related和prefetch_related需要注意以下几点:
如果不建立数据库连接池,效果会更明显,因为减少了数据库的往返次数;
如果结果集过大,使用prefetch_related反而会使速度变慢;
对一个数据库查询不一定比两个或多个快。
索引
对数据库列建立索引可能会对查询性能产生很大的影响。既然如此,为何没在开篇就介绍此技巧呢?因为索引远比在模型字段上设置db_index=True复杂。
在常被访问的列上建立索引可以提高与其相关的查询速度。然而建立索引会付出额外的存储空间代价,因此需要权衡成本与收益。通常,创建索引会减慢插入/更新的速度。
只查询需要的内容
如果可以的话,请使用values(),尤其是values_list()来获取所需的数据库对象属性。继续前面的例子,如果只想展示艺术家们的名字,而不需要全部ORM对象的话,通常这样写查询会更好:
Haki Benita,一个真正的数据库专家(不像我),曾写过一些类似本节的内容,需要的话,请阅读Haki’s blog。
后端:请求层
下面来讨论一下请求层。这里包括Django视图、上下文处理器和中间件。此处正确的决策也会带来更好的性能。
分页
在之前的章节,我们用select_related函数返回了500个艺术家及其唱片的信息。许多情况下,一次返回这么多对象既不现实又不推荐。Django文档中关于分页的部分清楚地说明了Paginator对象的使用方法。如果您希望向用户返回的对象不多于N个,或者一次返回很多对象使您的应用变慢,请使用它。
异步执行/后台任务
某些场景有时会不可避免地消耗很多时间。例如,用户请求将大量数据从数据库中导出到XML文件。如果我们在同一进程中执行所有操作,其流程如下所示:
假设处理此文件需要45s,我们不可能真的让用户等这么久。首先从UX角度,这是一种很可怕的用户体验。其次,如果应用在N秒后未进行正确的HTTP响应,某些主机实际上会结束此进程。
多数情况下,明智的做法是将其从请求-响应过程中剔除,放入另一个进程中:
后台任务不在本文的讨论范围,但是如果您需要执行上述操作,可以使用Celery库。
压缩Django的HTTP响应
请勿将其与静态文件的压缩混淆,后者将在本文后续提到。
压缩Django的HTTP/JSON响应也可以减少延迟。具体是多少呢?来看一下未压缩的响应体的字节数:
大概67KB左右。我们能做的更好吗?许多开发者使用Django内置的GZipMiddleware进行gzip压缩。不过现在有一个更有效的工具brotli,它支持多种浏览器(当然,除了IE11)。
重要提示:正如Django文档GZipMiddleware章节所述,压缩可能会导致您的网站出现安全漏洞。
接下来安装django-compression-middleware库。通过检查请求头的Accept-Encoding,它将选择浏览器支持的最快压缩机制:
将其包含到Django应用的中间件中:
再看一下响应体的字节数:
现在它的大小为7.24KB,缩小了89%。您当然可以辩称这种操作应该委托给专用的服务器,例如Ngnix或Apache。我则认为需要在简易性与资源之间做一个平衡。
缓存
缓存是存储特定计算结果以加快未来检索速度的过程。Django拥有出色的缓存框架,支持在各种级别上使用不同的存储后端进行此操作。
在数据驱动型应用中,缓存会变得很棘手:您永远都不想缓存始终显示实时信息的页面。因此,最大的挑战不是设置缓存,而是确定要缓存的内容,持续多长时间以及何时或如何使缓存无效。
在使用缓存之前,请确保已经对数据库或前端做了适当的优化。如果设计和查询得当,数据库可以快速且大规模地查询数据。
前端:处理更加繁琐
缩减静态资源大小可以大大加快Web应用程序的速度。即使已经做好了后端优化,不能高效地提供图片、CSS和JS文件也会降低应用程序的性能。
对于编译、精简、压缩和移除等问题,很容易迷失方向。下面让我们来理清楚这些内容。
提供静态文件
提供静态文件有多种方案。Django文档提供了Ngnix、Apache、Cloud/CDN或使用同一服务器等方案。
这里我采用了一种混合的方案:从CDN获取图片,将大型文件上传到S3,其他静态资源(如CSS、JS等)都是通过WhiteNoise处理的(稍后再详细介绍)。
概念
为了确保我们对某些问题的理解是一致的,我想对以下概念做一个解释:
编译:如果您在样式表中使用了SCSS,则先需要将它们编译为CSS,因为浏览器不理解SCSS。
精简:减少空格并删除CSS和JS文件中的注释可能会对大小产生重大影响。有时此过程会丑化程序:如将长变量名重命名为短变量名等等。
压缩/合并:对于CSS和JS,意味着将多个文件合并为一个。对于图片,则表示删除一些数据以缩减文件大小。
移除:删除无用代码。例如在CSS中删除未使用的选择器。
使用WhiteNoise提供静态文件
WhiteNoise允许Python Web应用程序自己提供静态资源。正如其作者所说,当其他方案如Nginx/Apache不可用时,就可以试试WhiteNoise了。
下面先安装它:
在启动WhiteNoise之前,请确保已在settings.py中配置了STATIC_ROOT:
同时还需要在SecurityMiddleware下面配置WhiteNoise中间件:
在生产环境中,需要运行manage.py collectstatic来启动。
尽管此步骤不是必需的,但强烈建议添加缓存和压缩:
这样只要在模板中遇到{% static %}标签,WhiteNoise会为您压缩和缓存文件,此外它还负责缓存无效化。
还有一步也很重要:为了确保在开发环境和生产环境中的体验一致,请配置runserver_nostatic:
无论DEBUG的状态是否为True,都可添加此配置,因为通常不会在生产环境中通过runserver运行Django。
我发现增加缓存时间也很有用:
这不会导致缓存无效化的问题吗?不会,因为在运行collectstatic时,WhiteNoise会创建版本化的文件:
因此,当再次部署应用时,静态文件将被覆盖且重命名,之前的缓存就无关紧要了。
使用django-compressor压缩
WhiteNoise已经具备压缩静态文件的功能,因此django-compressor是可选的。但是后者提供了额外的功能:合并文件。要想同时使用压缩器和WhiteNoise,需要一些额外的配置。
假设用户加载一个包含三个.css文件的HTML文档:
浏览器将会发出三个不同的请求。多数情况下,在部署时合并这些文件会更加有效,django-compressor通过{% compress css %}模板标签来实现:
这样就合并成如下内容:
下面让django-compressor和WhiteNoise运行起来。安装:
配置静态文件路径:
由于这两个库影响请求-响应周期,与默认配置不兼容,需要通过修改一些配置来克服此问题。
我比较习惯使用.env文件的环境变量,并且只创建一个settings.py,如果您习惯多配置文件,如settings/dev.py和settings/prod.py,您应该知道如何转化:
main_project/settings.py:
COMPRESS_OFFLINE在生产环境中值为True,在开发环境中值为False。COMPRESS_ENABLED在两个环境中都为True。
在离线压缩时,必须在每次部署都运行manage.py compress。在Heroku上,您希望平台禁止自动执行collectstatic(默认是开启的),而在post_compile时才执行,那么在项目的根目录下创建bin文件夹,并创建post_compile文件做如下配置:
压缩器的另一个好处是它可以压缩SCSS/SASS文件:
精简CSS和JS
关于加载时间和带宽使用的另一个重要话题就是精简:通过删除空格和注释来(自动)缩减代码文件大小的过程。
解决此问题的方法有多种,如果您使用了django-compressor,只需在settings.py文件中做如下配置(当然其他支持此功能的压缩器也可):
延迟加载JavaScript
影响性能的另一因素即加载外部脚本。其要点在于浏览器在解析页面其他内容之前,会先去尝试获取并执行
标签中的Javascript文件:我们可以使用async和defer关键字来缓解这种情况:
两者都允许脚本异步获取。不同之处在于:使用前者时,一旦脚本下载完毕,会立刻终止其他HTML解析工作,优先执行脚本;而使用后者则会等所有解析工作完毕再执行。
关于async和defer的使用,我建议参考Flavio Copes的文章。通常可总结为:
提高页面加载速度的最佳方法是在head中引入脚本,并为script标签添加defer属性。
懒加载图片
懒加载图片意味着进入客户端视区才请求它们。这样能节省用户的时间和带宽。目前有许多好用且无外部依赖的库如LazyLoad,确实没有理由不使用它们。此外,Chrome从76版本开始支持lazy属性。
LazyLoad使用起来非常简单,并且可定制化。在我自己的应用中,我希望它仅在具有lazy类的图像上应用,并在进入视区前开始加载300像素的图片:
下面用已有图片尝试一下:
用src替换src属性,并添加lazy到class:
现在,当该图片在视区下为300像素时,客户端将请求此图片。
如果某页面有很多图片,使用懒加载将大大减少加载时间。
优化和动态缩放图片
另一个需要关注的因素是图片优化。除压缩外,还有另外两个技术可以考虑。
首先,文件格式优化。与同质量的JPEG图片相比,新的WebP格式要小25%-30%。从2020年2月开始,一些浏览器开始支持此格式,不过还是需要提供标准格式图片备用。
其次,根据不同的屏幕尺寸提供不同的图片尺寸。如果某些移动设备的最大视区宽度为650px,那么为什么要和13英寸2560px显示器一样提供1050px的图片呢?
这里,您可以根据自己的app进行定制化处理。举个更简单的例子,可以使用srcset属性控制尺寸;并且如果想同时使用WebP、JPEG格式图片的话,可以使用元素绑定多个源。
如果您对上述内容感到困惑,可以参考这篇指南,里面对涉及的术语和示例给了很好的解释。
无用的CSS:删除导入
如果您正在使用Bootstrap之类的CSS框架,不要盲目地引入所有组件。实际上,一开始我会注释掉所有不必要的组件,并在需要时再添加。下面是bootstrap.scss中的一小段内容:
我不会使用诸如badges和jumbotron之类的功能,所以将其注释掉。
无用的CSS:使用PurgeCSS移除
一个更复杂的方法是使用类似PurgeCSS的库,它可以分析您的文件,检测CSS中无用的内容并删除。PurgeCSS是一个NPM软件包,因此如果您在Heroku上托管Django应用,需要安装Node.js buildpack。
结论
我希望您至少已经找到一个可以优化Django应用程序的方案了。如果您有任何疑问、建议或反馈,请随时在Twitter上给我留言。
附录
用于QuerySet性能分析的装饰器,如下是django_query_analyze装饰器的代码: