本文首發於公眾號:Hunter後端
原文連結:Django筆記二十八之資料庫查詢最佳化彙總
這一篇筆記將從以下幾個方面來介紹 Django 在查詢過程中的一些最佳化操作,有一些是介紹如何獲取 Django 查詢轉化的 sql 語句,有一些是理解 QuerySet 是如何獲取資料的。
以下是本篇筆記目錄:
- 效能方面
- 使用標準的資料庫最佳化技術
- 理解 QuerySet
- 操作儘量在資料庫中完成而不是在記憶體中
- 使用唯一索引來查詢單個物件
- 如果知道需要什麼資料,那麼就立刻查出來
- 不要查詢你不需要的資料
- 使用批次的方法
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、操作儘量在資料庫中完成而不是在記憶體中
舉幾個例子:
- 在大多數查詢中,使用 filter() 和 exclude() 在資料庫中做過濾,而不是在獲取所有資料之後在 Python 裡的 for 迴圈裡篩選資料
- 在同一個 model 的操作中,如果有涉及到其他欄位的操作,可以用到 F 表示式
- 使用 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)
如果想獲取更多後端相關文章,可掃碼關注閱讀: