在開發多款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格式圖片的話,可以使用<picture>元素綁定多個源。/<picture>
如果您對上述內容感到困惑,可以參考這篇指南,裡面對涉及的術語和示例給了很好的解釋。
無用的CSS:刪除導入
如果您正在使用Bootstrap之類的CSS框架,不要盲目地引入所有組件。實際上,一開始我會註釋掉所有不必要的組件,並在需要時再添加。下面是bootstrap.scss中的一小段內容:
我不會使用諸如badges和jumbotron之類的功能,所以將其註釋掉。
無用的CSS:使用PurgeCSS移除
一個更復雜的方法是使用類似PurgeCSS的庫,它可以分析您的文件,檢測CSS中無用的內容並刪除。PurgeCSS是一個NPM軟件包,因此如果您在Heroku上託管Django應用,需要安裝Node.js buildpack。
結論
我希望您至少已經找到一個可以優化Django應用程序的方案了。如果您有任何疑問、建議或反饋,請隨時在Twitter上給我留言。
附錄
用於QuerySet性能分析的裝飾器,如下是django_query_analyze裝飾器的代碼:
閱讀更多 芝麻觀 的文章