探索Django ORM的極限
我最近在2019年歐洲Django大會(https://2019.djangocon.eu/ )上發表了一場關於Django ORM的演講。在這次演講中,我展示了使用Django ORM進行複雜查詢時可以使用的各種技術。這篇文章將部分總結這次演講,但我也會擴展和添加我無法在30分鐘內完成的額外的內容。
打開今日頭條,查看更多圖片首先,ORM代表對象關係映射,是一個幫助你處理資料庫的工具。Django ORM提供了一個Python介面,用於處理資料庫中的數據。它對你有兩大好處: 它通過使用模型定義和遷移來幫助你設置和維護資料庫結構,並通過管理器和查詢集幫助你編寫針對資料庫的查詢。
Django ORM沒有開放一個介面來讓你編寫自定義SQL。該介面只關注你定義的模型。這使得使用ORM非常容易,但也使得使用ORM編寫某些查詢更困難——甚至不可能。
示例模型
在本文中,我將在大多數例子中使用下面定義的模型:
自定義管理器和查詢集
我們在Kolonial.no上大量使用的東西是為我們的模型定製的Manager(管理器)和QuerySet(查詢集)。在這裡,你可以保持與你的模型相關的可重用邏輯。例如,我們可以在訂單QuerySet中添加一個方法,該方法給出一個未發貨訂單的列表:
類似地,我們可以創建一個自定義管理器,例如使用一個幫助器方法來簡化新訂單的創建:
為了設置order(訂單)模型的默認管理器,我們設置了objects屬性:
檢查QuerySet
另一個需要知道的有用技巧是如何檢查一個QuerySet。假設你想知道為什麼一個特定的QuerySet不能準確地返回你所期望的結果。那麼,打開一個shell並實際檢查資料庫中正在運行的查詢是非常有用的。這裡我特別想強調兩件事: 如何查看某個QuerySet生成的SQL查詢,以及如何在資料庫中運行一個EXPLAIN查詢。
你還可以訪問Django中的資料庫連接包裝器,並檢查在該連接上運行的最後查詢:
這將給出執行的SQL語句的列表和每個查詢的運行時間。
避免額外的查詢
當使用Django ORM時,很容易出現視圖生成過多查詢的情況。如果你有一個相關的模型,並對QuerySet中的每個實例進行訪問,則默認行為是一次獲取一個相關的模型。
為了避免這種情況,你可以使用select_related和prefetch_related。它們有著非常相似的名字和行為。第一個用於獲取資料庫中的對象,這些對象每一個都和一行相關聯,並生成一個JOIN查詢,其中所有相關對象都在一個SQL查詢中獲取。當你的資料庫中每一行擁有多個相關對象時,你可以使用第二種方法。它將首先獲取原始QuerySet的所有對象,而不是創建一個JOIN查詢。然後,它將運行第二個查詢來獲取所有相關對象,然後在Python中而不是資料庫中來連接它們。因此,通過在我們的Orders QuerySet中添加以下兩行代碼,我們最終會得到兩個查詢:
但是請注意,你不應該盲目地優化。有時運行兩個或多個查詢比運行一個大型查詢更快。所以,在研究性能問題時,請記住這一點。
避免競爭條件
如果你正在處理資料庫中的對象,這些對象可以通過多個請求並發地進行修改,那麼你需要確保這些更改是按照正確的順序應用的。這可能很重要,例如,如果我們在產品模型上保持庫存數量,我們希望確保該數量正確地遞增和遞減。對此的一種解決方案是,當我們從資料庫中獲取對象時,對資料庫中的行進行鎖定。這樣,我們就可以保證不允許其他請求獲取和修改相同的行。
就性能而言,這是一個相當繁重的解決方案,在事務完成之前,不允許任何其他資料庫連接訪問這一行。在對你的數據進行建模時,請記住這一點。也許你可以通過對數據進行不同的建模來提前避免這個問題?
子查詢
當你需要從另一個表(有時甚至是同一個表)獲取一些數據,但在Django模型中又沒有任何直接相關欄位時,子查詢非常有用。在這種情況下,Django目前不允許執行連接。另一個用例是當要獲取或搜索的數據量太大,執行普通連接的速度太慢的時候。
我的第一個示例向你展示了如何將來自一行的單個值注釋到一個QuerySet上。在這個例子中,我用客戶下最後一筆訂單的時間來標註每個客戶對象:
這裡兩個有趣的部分是Subquery和OuterRef類。第一個是一個包裝器,它接受一個普通的QuerySet並將其作為子查詢嵌入到另一個QuerySet中。OuterRef類用於引用嵌入子查詢的QuerySet中的欄位。在本例中,我們使用它進行篩選,以便只獲得屬於當前行客戶的訂單。然後按日期排序,只選擇日期列,然後只返回第一個結果。
除了從另一個QuerySet中選擇一個特定的值,我們還可以使用Exists類檢查另一個對象是否存在:
如果你對選擇結果不感興趣,而只是想過濾,那麼Django目前不支持這種方法。這很不幸,因為這會導致查詢速度變慢。幸運的是,目前有一個開放的推送請求(https://github.com/django/django/pull/8119 )來解決這個問題,所以在Django的未來版本中,很可能會支持這個功能。
上面的兩個例子只是從資料庫中的另一個對象中選擇一個值,但是我們也可以使用聚合的結果運行更複雜的查詢。下面是如何聚合子查詢中匹配行的總和的例子:
這裡我們要做的第一件事是過濾出我們感興趣的一組行。然後,我們使用values_list只選擇年和月的結果。當我們在此之後使用聚合函數進行注釋時,查詢結果將根據所選的行進行分組,這些行對於匹配篩選器的任何行來說都是惟一的。然後,我們只選擇聚合值並將其注釋到外部QuerySet上。
在本例中,我們可以使用默認可用的Django基本類型生成這個查詢,但是如果我們想要聚合一些行,而這些行中沒有惟一的東西可以進行分組,該怎麼辦呢?我們不能在子查詢中使用aggregate,因為它會立即運行資料庫查詢。相反,我們需要做的是在ORM周圍使用一些技巧。假設我們有這張訂單表,並想計算所有周六和周日的總銷售額:
我們沒有任何獨特的東西可以用來分組,但是我們可以嘗試刪除values_list調用。不幸的是,這並沒有帶給我們想要的結果:
這不起作用的原因是,每當我們使用帶注釋的Django向QuerySet添加一個聚合時,都會添加一個group by語句,在本例Order.pk中,默認情況下,這是QuerySet模型的主鍵。結果是我們只得到第一個訂單的和。相反,我們必須使用一個不繼承自Aggregate類的資料庫函數。在Django中,SUM SQL函數只能作為一個聚合子類使用,但是我們可以相對容易地通過直接使用Func類來解決這個問題:
雖然這不是特別漂亮,但它確實給出了我們想要的結果。雖然我並不總是建議使用這個方法,但是知道它是一個可用選項是很有用的。
自定義約束和索引
Django在很長一段時間內都支持唯一性約束,但是只有當你希望欄位的組合在表中的所有行中都是唯一的時候才需要使用它:
我們還可以選擇向表中的某些列添加額外的索引,但同樣只針對整個表。從Django2.2開始,我們可以更好地控制如何創建唯一性約束和自定義索引。我們現在可以指定一個唯一性約束只檢查表中所有行的一個子集:
在本例中,我們將每個用戶限制為只有一個未發貨訂單。雖然這可能是一個相對簡單的例子,但是條件唯一性約束給了我們很大的靈活性。假設我們有一個資料庫表,其中我們希望只允許一行具有NULL值。因為在SQL中NULL是不等於NULL的,所以每一行都被認為是惟一的,我們不能使用一個普通的unique=True或unique_together來限制這一點。但是,我們可以添加一個約束,在欄位為NULL的情況下檢查它是否是唯一的。
我們還可以創建自定義檢查約束。這類似於Django中的欄位驗證器,但是它們是由資料庫執行的。這可以保護我們不受Python代碼中的bug的影響,該檢查甚至可以在bulk_create和類似的平台上運行,如果你從另一個非django項目訪問相同的資料庫時,該檢查也會運行。例如,我們可以創建一個檢查約束來驗證給定的月份數字是否有效:
類似地,我們可以創建自定義的部分索引,它只覆蓋表的一部分。如果只查詢表的一個子集,或者表的很大一部分包含空NULL值時,這將非常有用。在我們的示例模型中,在準備發貨時,我們很可能會頻繁地訪問未發貨的訂單。下面是如何創建只包含未發貨訂單的索引的示例。這將有助於加快我們最常用的資料庫查詢:
窗口函數
在Django2.0中,使用DjangoORM運行窗口函數進行查詢得到了支持。這將在查詢中生成一個OVER語句來查看行分區。這是很有用的,例如,如果我們想查找某個客戶的上一個訂單,查看該客戶上一次下訂單的時間,或者計算來自該客戶的訂單總和。下面是一個示例,演示如何使用來自同一客戶的上一個訂單的id來注釋一個訂單的QuerySet:
我們使用Window類來生成OVER語句,然後生成Lag來從第n行中選擇一個值,在本例中是前一行,因為我們將n指定為1。
不幸的是,由於SQL標準限制,我們不能在窗口函數上進行過濾。但是,這可以通過使用通用表表達式(CTEs)來補救,但是Django目前不支持這種方法。
使用自定義資料庫函數進行擴展
有時候Django ORM不允許你做你想做的事情。在許多情況下,實際上可以通過擴展內置基本類型來添加自己的功能。例如,如果你想使用Django沒有公開的自定義SQL函數,這可以通過子類化Func函數來輕鬆添加:
這就是所需的全部。現在,我們可以像使用任何Django公開的SQL函數一樣使用它。
我們也可以用更複雜的函數來擴展。假設我們用單獨的日期和時間列建立了一個模型,但是需要將其與使用日期時間的另一個表進行比較。我們可以通過實現一個自定義函數來實現這一點。沒有跨資料庫的通用方法可以做到這一點,但是Django允許我們為想要支持的每個資料庫分別實現這一點。下面是用於PostgreSQL和Sqlite的例子:
接著,我們就可以使用這個函數來在QuerySet上注釋一個日期時間:
運行自定義SQL
如果我們想要做的事情使用Django提供給我們的功能不能直接實現,我們有時可以直接編寫自定義SQL。Django提供了多種實現方法。例如,我們可以用一個自定義SQL表達式來對QuerySet進行註解:
我們也可以用QuerySet的extra方法來做同樣的事情:
extra有很多的選項,所以請查閱文檔。
如果我們想自己編寫整個SQL查詢,這也是一個選項。返回的列將按名稱映射到模型欄位。任何與現有欄位名不匹配的列都將作為註解欄位添加。
如果我們不想返回模型對象,我們也可以直接在資料庫游標上運行自定義SQL查詢:
這將返回一個包含列的元組。
自定義遷移
向Django添加額外資料庫功能的另一種方法是編寫自定義遷移。在Django獲得對自定義約束和索引的支持之前,在自定義遷移中使用RunSQL是向資料庫添加這類選項的一種方法。另一種可能性是使用RunSQL向資料庫添加自定義視圖,並將其作為一個模型公開:
自定義遷移的另一個用例是數據遷移。如果你的資料庫中有一組始終可用的初始數據,那你可以使用數據遷移將其添加到自定義遷移中。如果你在運行測試時使用遷移,那麼相同的初始數據集也將在測試中可用。要做到這一點,你可以在你的遷移文件中使用RunPython類:
我將向你展示的與遷移相關的最後一個基本類型是SeparateDatabaseAndState類。Django在運行遷移時,除了資料庫中的實際狀態外,還保留一個內部狀態來表示模型的預期布局等。使用這個類,你可以分別修改這兩個狀態。如果你需要將這兩者分開,例如出於高可用性的原因,這個類將非常有用。這就需要用到列表,一個在內部狀態下執行操作,一個在資料庫中執行操作:
總結
我希望本文為你提供了一些關於在使用Django ORM時如何優化或改進資料庫查詢的想法。
這絕不是一個完整的列表,所以請查看Django文檔(https://docs.djangoproject.com/ )獲取詳細內容。我建議從QuerySet API reference(https://docs.djangoproject.com/en/2.2/ref/models/querysets/ 0部分開始,然後從那裡跟隨鏈接去查看可用的API。
英文原文:https://qiniumedia.freelycode.com/vcdn/1/%E4%BC%98%E8%B4%A8%E6%96%87%E7%AB%A0%E9%95%BF%E5%9B%BE3/pushing-django-orm-to-its-limit.pdf 譯者:憂鬱的紅秋褲
※用wxPython創建GUI應用程序展示NASA圖片(第三部分)
※讀懂Python的Mock對象庫(1)
TAG:Python部落 |