Django中的資料庫訪問優化——預載入關聯數據

Django中的資料庫訪問優化——預載入關聯數據

來自專欄 Fossen的編程筆記

Django的模型層提供了一套ORM系統,這使得我們無需學習SQL也能利用資料庫來存儲相關數據。一次query獲取所有需要的數據,往往比多次query分別取得數據要更高效。但由於django模型的資料庫檢索過程隱藏在後台,不注意的話很容易導致多次檢索資料庫,浪費不必要的時間。因此充分理解django模型的query機制十分重要。

django官方文檔給出了很多資料庫訪問優化的建議:Database access optimization。這些建議對於提升代碼的效率十分有幫助,但其內容較多,本文只介紹其中的一種優化手段,預載入關聯數據。


模型中經常會用到外鍵和多對多關係,但是queryset在獲取對象的數據時,如果不指定的話,則不會檢索關聯對象的數據。當你調用關聯對象時,queryset還會再一次訪問資料庫。因此當你循環多個對象並調用其外鍵所關聯的對象時,django會不停的訪問資料庫,以獲取其所需的數據。

這樣說或許有點抽象,下面結合例子詳細解釋說明,例子使用以下的模型。

from django.db import modelsfrom django.contrib.auth.models import Userclass 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屬性。但由於文章中緩存的僅僅只是文章分類的idArticle.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`.`title`, `blog_article`.`content`, `blog_article`.`pub_date`, `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`, `blog_article`.`title`, `blog_article`.`content`, `blog_article`.`pub_date`, `blog_article`.`category_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`.`name`, `blog_category`.`number` FROM `blog_category`; args=()...>>> print(a)SELECT `blog_article`.`id`, `blog_article`.`title`, `blog_article`.`content`, `blog_article`.`pub_date`, `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`, `blog_article`.`title`, `blog_article`.`content`, `blog_article`.`pub_date`, `blog_article`.`category_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`, `blog_article`.`title`, `blog_article`.`content`, `blog_article`.`pub_date`, `blog_article`.`category_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`, `blog_article`.`title`, `blog_article`.`content`, `blog_article`.`pub_date`, `blog_article`.`category_id`, FROM `blog_article` WHERE (`blog_article`.`id` > 5 AND `blog_article`.`category_id` IN (2));>>> c.article_set.count() # 執行SQL語句,因為沒有緩存該querysetSELECT 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,
,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 projectimport time, statisticsfrom django.utils import timezonefrom blog.models import Articledef count_run_time(func): start=time.perf_counter() func() end=time.perf_counter() return end-startdef 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(對比 no prefetch prefetch) print(平均值 ,result1[1], ,result2[1]) print(標準差 ,result1[2], ,result2[2]) print(最大 ,result1[3], ,result2[3]) print(最小 ,result1[4], ,result2[4])

推薦閱讀:

索引列只要參與了計算, 查詢就會不走索引, 為什麼 MySQL 不對這種情況進行優化?
學習SQL【10】-SQL高級處理
SQL 查詢按照家庭住址進行分組時,組內平均年齡小於50歲的組中成員的姓名和年齡?
從編程語言設計的角度,如何評價SQL語言?
學習SQL【7】-函數

TAG:Django框架 | DjangoORM | SQL |