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的資料
(
a
。
category
)
使用select_related則可以一次性獲取物件以及關聯的物件,只需訪問一次資料庫:
for
a
in
Article
。
objects
。
all
()
。
select_related
(
‘category’
):
# 已經快取了資料,不會再次訪問資料庫
(
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
)
>>>
(
c
)
SELECT
`
blog_category
`
。
`
id
`
,
`
blog_category
`
。
`
name
`
,
`
blog_category
`
。
`
number
`
FROM
`
blog_category
`
;
args
=
()
。。。
>>>
(
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語句,因為沒有快取該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
():
(
a
。
category
,
‘
\n
’
,
a
。
topics
。
all
())
def
articles_retrieve_prefetch
():
for
a
in
Article
。
objects
。
all
()
\
。
select_related
(
‘category’
)
\
。
prefetch_related
(
‘topics’
):
(
a
。
category
,
‘
\n
’
,
a
。
topics
。
all
())
上面兩個函式分別定義了簡單的資料庫查詢,以及使用了預載入的資料庫查詢,並列印其結果。
測試方法為兩個函式分別執行100次,並統計單次執行所耗費的時間。具體的統計函式將放在後面,硬體和軟體配置就不贅述了,直接上結果,其中平均值為函式單次執行所花費的時間,單位為s。
很明顯,預載入的效能更高。
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
)
(
‘對比
\t
no prefetch
\t
prefetch’
)
(
‘平均值
\t
’
,
result1
[
1
],
‘
\t
’
,
result2
[
1
])
(
‘標準差
\t
’
,
result1
[
2
],
‘
\t
’
,
result2
[
2
])
(
‘最大
\t
’
,
result1
[
3
],
‘
\t
’
,
result2
[
3
])
(
‘最小
\t
’
,
result1
[
4
],
‘
\t
’
,
result2
[
4
])