Django筆記二十八之資料庫查詢優化彙總

2023-04-22 21:01:05

本文首發於公眾號:Hunter後端
原文連結:Django筆記二十八之資料庫查詢優化彙總

這一篇筆記將從以下幾個方面來介紹 Django 在查詢過程中的一些優化操作,有一些是介紹如何獲取 Django 查詢轉化的 sql 語句,有一些是理解 QuerySet 是如何獲取資料的。

以下是本篇筆記目錄:

  1. 效能方面
  2. 使用標準的資料庫優化技術
  3. 理解 QuerySet
  4. 操作儘量在資料庫中完成而不是在記憶體中
  5. 使用唯一索引來查詢單個物件
  6. 如果知道需要什麼資料,那麼就立刻查出來
  7. 不要查詢你不需要的資料
  8. 使用批次的方法

1、效能方面

1. connection.queries

前面我們介紹過 connection.queries 的用法,比如我們執行了一條查詢之後,可以通過下面的方式查到我們剛剛的語句和耗時

>>> from django.db import connection
>>> connection.queries
[{'sql': 'SELECT polls_polls.id, polls_polls.question, polls_polls.pub_date FROM polls_polls',
'time': '0.002'}]

僅僅當系統的 DEBUG 引數設為 True,上述命令才可生效,而且是按照查詢的順序排列的一個陣列

陣列的每一個元素都是一個字典,包含兩個 Key:sql 和 time

sql 為查詢轉化的查詢語句
time 為查詢過程中的耗時

因為這個記錄是按照時間順序排列的,所以 connection.queries[-1] 總能查詢到最新的一條記錄。

多資料庫操作

如果系統用的是多個資料庫,那麼可以通過 connections['db_alias'].queries 來操作,比如我們使用的資料庫的 alias 為 user:

>>> from django.db import connections
>>> connections['user'].queries

如果想清空之前的記錄,可以呼叫 reset_queries() 函數:

from django.db import reset_queries
reset_queries()

2. explain

我們也可以使用 explain() 函數來檢視一條 QuerySet 的執行計劃,包括索引以及聯表查詢的的一些資訊

這個操作就和 MySQL 的 explain 是一樣的。

>>> print(Blog.objects.filter(title='My Blog').explain())
Seq Scan on blog  (cost=0.00..35.50 rows=10 width=12)
  Filter: (title = 'My Blog'::bpchar)

也可以加一些引數來檢視更詳細的資訊:

>>> print(Blog.objects.filter(title='My Blog').explain(verbose=True, analyze=True))
Seq Scan on public.blog  (cost=0.00..35.50 rows=10 width=12) (actual time=0.004..0.004 rows=10 loops=1)
  Output: id, title
  Filter: (blog.title = 'My Blog'::bpchar)
Planning time: 0.064 ms
Execution time: 0.058 ms

之前在使用 Django 的過程中還使用到一個叫 silk 的工具,它可以用來分析一個介面各個步驟的耗時,有興趣的可以瞭解一下。

2、使用標準的資料庫優化技術

資料庫優化技術指的是在查詢操作中 SQL 底層本身的優化,不涉及 Django 的查詢操作

比如使用 索引 index,可以使用 Meta.indexes 或者欄位裡的 Field.db_index 來新增索引

如果頻繁的使用到 filter()、exclude()、order_by() 等操作,建議為其中查詢的欄位新增索引,因為索引能幫助加快查詢

3、理解 QuerySet

1. 理解 QuerySet 獲取資料的過程

1) QuerySet 的懶載入

一個查詢的建立並不會存取資料庫,直到獲取這條查詢語句的具體資料的時候,系統才會去存取資料庫:

>>> q = Entry.objects.filter(headline__startswith="What")  # 不存取資料庫
>>> q = q.filter(pub_date__lte=datetime.date.today())  # 不存取資料庫
>>> q = q.exclude(body_text__icontains="food")  # 不存取資料庫
>>> print(q)  # 存取資料庫

比如上面四條語句,只有最後一步,系統才會去查詢資料庫。

2) 資料什麼時候被載入

迭代、使用步長分片、使用len()函數獲取長度以及使用list()將QuerySet 轉化成列表的時候資料才會被載入

這幾點情況在我們的第九篇筆記中都有詳細的描述。

3) 資料是怎麼被儲存在記憶體中的

每一個 QuerySet 都會有一個快取來減少對資料庫的存取操作,理解其中的執行原理能幫助我們寫出最有效的程式碼。

當我們建立一個 QuerySet 的之後,並且資料第一次被載入,對資料庫的查詢操作就發生了。

然後 Django 會儲存 QuerySet 查詢的結果,並且在之後對這個 QuerySet 的操作中會重複使用,不會再去查詢資料庫。

當然,如果理解了這個原理之後,用得好就OK,否則會對資料庫進行多次查詢,造成效能的浪費,比如下面的操作:

>>> print([e.headline for e in Entry.objects.all()])
>>> print([e.pub_date for e in Entry.objects.all()])

上面的程式碼,同樣一個查詢操作,系統會查詢兩遍資料庫,而且對於資料來說,兩次的間隔期之間,Entry 表可能的某些資料庫可能會增加或者被刪除造成資料的不一致。

為了避免此類問題,我們可以這樣複用這個 QuerySet :

>>> queryset = Entry.objects.all()
>>> print([p.headline for p in queryset]) # 查詢資料庫
>>> print([p.pub_date for p in queryset]) # 從快取中直接使用,不會再次查詢資料庫

這樣的作業系統就只執行了一遍查詢操作。

使用陣列的切片或者根據索引(即下標)不會快取資料

QuerySet 也並不總是快取所查詢的結果,如果只是獲取一個 QuerySet 部分資料,會查詢有是否這個 QuerySet 的快取
有的話,則直接從快取中獲取資料,沒有的話,後續也不會將這部分資料快取到系統中。

舉個例子,比如下面的操作,在快取整個 QuerySet 資料前,查詢一個 QuerySet 的部分資料時,系統會重複查詢資料庫:

>>> queryset = Entry.objects.all()
>>> print(queryset[5]) # 查詢資料庫
>>> print(queryset[5]) # 再次查詢資料庫

而在下面的操作中,整個 QuerySet 都被提前獲取了,那麼根據索引的下標獲取資料,則能夠從快取中直接獲取資料:

>>> queryset = Entry.objects.all()
>>> [entry for entry in queryset] # 查詢資料庫
>>> print(queryset[5]) # 使用快取
>>> print(queryset[5]) # 使用快取

如果一個 QuerySet 已經快取到記憶體中,那麼下面的操作將不會再次查詢資料庫:

>>> [entry for entry in queryset]
>>> bool(queryset)
>>> entry in queryset
>>> list(queryset)

2. 理解 QuerySet 的快取

除了 QuerySet 的快取,單個 model 的 object 也有快取的操作。

我們這裡簡單理解為外來鍵和多對多的關係。

比如下面外來鍵欄位的獲取,blog 是 Entry 的一個外來鍵欄位:

>>> entry = Entry.objects.get(id=1)
>>> entry.blog   # Blog 的範例被查詢資料庫獲得
>>> entry.blog   # 第二次獲取,使用快取資訊,不會查詢資料庫

而多對多關係的獲取每次都會被重新去資料庫獲取資料:

>>> entry = Entry.objects.get(id=1)
>>> entry.authors.all()   # 查詢資料庫
>>> entry.authors.all()   # 再次查詢資料庫

當然,以上的操作,我們都可以通過 select_related() 和 prefetch_related() 的方式來減少資料庫的存取,這個的用法在前面的筆記中有介紹。

4、操作儘量在資料庫中完成而不是在記憶體中

舉幾個例子:

  1. 在大多數查詢中,使用 filter() 和 exclude() 在資料庫中做過濾,而不是在獲取所有資料之後在 Python 裡的 for 迴圈裡篩選資料
  2. 在同一個 model 的操作中,如果有涉及到其他欄位的操作,可以用到 F 表示式
  3. 使用 annotate 函數在資料庫中做聚合(aggregate)的操作

如果某些查詢比較複雜,可以使用原生的 SQL 語句,這個操作也在前面有過一篇完整的筆記介紹過

5、使用唯一索引來查詢單個物件

在使用 get() 來查詢單條資料的時候,有兩個理由使用唯一索引(unique)或 普通索引(db_index)

一個是基於資料庫索引,查詢會更快,

另一個是如果多條資料都滿足查詢條件,查詢會慢得多,而在唯一索引的約束下則保證這種情況不會發生

所以使用下面的 id 進行匹配 會比 headline 欄位匹配快得多,因為 id 欄位在資料庫中有索引且是唯一的:

entry = Entry.objects.get(id=10)

entry = Entry.objects.get(headline="News Item Title")

而下面的操作可能會更慢:

entry = Entry.objects.get(headline__startswith="News")

首先, headline 欄位上沒有索引,會導致資料庫獲取速度慢

其次,查詢並不能保證只返回一個物件,如果匹配上來多個物件,且從資料庫中檢索並返回數百數千條記錄,後果會很嚴重,其實就會報錯,get() 能接受的返回只能是一個範例資料。

6、如果知道需要什麼資料,那麼就立刻查出來

能一次性查詢所有需要的相關的資料的話,就一次性查詢出來,不要在迴圈中做多次查詢,因為那樣會多次存取資料庫

所以這就需要理解並且用到 select_related() 和 prefetch_related() 函數

7、不要查詢你不需要的資料

1. 使用 values() 和 values_list() 函數

如果需求僅僅是需要某幾個欄位的資料,可以用到的資料結構為 dict 或者 list,可以直接使用這兩個函數來獲取資料

2. 使用 defer() 和 only()

如果明確知道只需要,或者不需要什麼欄位資料,可以使用這兩個方法,一般常用在 textfield 上,避免載入巨量資料量的 text 欄位

3. 使用 count()

如果想要獲取總數,使用 count() 方法,而不是使用 len() 來操作,如果資料有一萬條,len() 操作會導致這一萬條資料都載入到記憶體裡,然後計數。

4. 使用 exists()

如果僅僅是想查詢資料是否至少存在一條可以使用 if QuerySet.exists() 而不是 if queryset 的形式

5. 使用 update() 和 delete()

能夠批次更新和刪除的操作就使用批次的方法,挨個去載入資料,更新資料,然後儲存是不推薦的

6. 直接使用外來鍵的值

如果需要外來鍵的值,直接呼叫早就在這個 object 中的欄位,而不是載入整個關聯的 object 然後取其主鍵id

比如推薦:

entry.blog_id

而不是:

entry.blog.id

7. 如果不需要排序的結果,就不要order_by()

每一個欄位的排序都是資料庫的操作需要額外消耗效能的,所以如果不需要的話,儘量不要排序

如果在 Meta.ordering 中有一個預設的排序,而你不需要,可以通過 order_by() 不新增任何引數的方法來取消排序

為資料庫新增索引,可以幫助提高排序的效能

8、使用批次的方法

1. 批次建立

對於多條 model 資料的建立,儘可能的使用 bulk_create() 方法,這是要優於挨個去 create() 的

2. 批次更新

bulk_update 方法也優於挨個資料在 for 迴圈中去 save()

3. 批次 insert

對於 ManyToMany 方法,使用 add() 方法的時候新增多個引數一次性操作比多次 add 要好

my_band.members.add(me, my_friend)

要優於:

my_band.members.add(me)
my_band.members.add(my_friend)

4. 批次 remove

當去除 ManyToMany 中的資料的時候,也是能一次性操作就一次性操作:

my_band.members.remove(me, my_friend)

要好於:

my_band.members.remove(me)
my_band.members.remove(my_friend)

如果想獲取更多後端相關文章,可掃碼關注閱讀: