本文首發於公眾號:Hunter後端 原文鏈接:Django筆記二十八之資料庫查詢優化彙總 這一篇筆記將從以下幾個方面來介紹 Django 在查詢過程中的一些優化操作,有一些是介紹如何獲取 Django 查詢轉化的 sql 語句,有一些是理解 QuerySet 是如何獲取數據的。 以下是本篇筆記目錄: ...
本文首發於公眾號: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)
如果想獲取更多後端相關文章,可掃碼關註閱讀: