Django ORM 引發的資料庫 N+1 效能問題

以終為始發表於2020-10-11

背景描述

最近在使用 Django 時,發現當呼叫 api 後,在資料庫同一個程式下的事務中,出現了大量的資料庫查詢語句。調查後發現,是由於 Django ORM 的機制所引起。

Django Object-Relational Mapper(ORM)作為 Django 比較受歡迎的特性,在開發中被大量使用。我們可以通過它和資料庫進行互動,實現 DDL 和 DML 操作.

具體來說,就是使用 QuerySet 物件來檢索資料, 而 QuerySet 本質上是通過在預先定義好的 model 中的 Manager 和資料庫進行互動。

Manager 是 Django model 提供資料庫查詢的一個介面,在每個 Model 中都至少存在一個 Manager 物件。但今天要介紹的主角是 QuerySet ,它並不是關鍵。

為了更清晰的表述問題,假設在資料庫有如下的表:

device 表,表示當前網路中納管的物理裝置。

interface 表,表示物理裝置擁有的介面。

interface_extension 表,和 interface 表是一對一關係,由於 interface 屬性過多,用於儲存一些不太常用的介面屬性。

class Device(models.Model):
    name = models.CharField(max_length=100, unique=True)  # 新增裝置時的裝置名
    hostname = models.CharField(max_length=100, null=True)  # 從裝置中獲取的hostname
    ip_address = models.CharField(max_length=100, null=True)  # 裝置管理IP

class Interface(models.Model):
    device = models.ForeignKey(Device, on_delete=models.PROTECT, null=False,related_name='interfaces')) # 屬於哪臺裝置
    name = models.CharField(max_length=100)  # 埠名
    collect_status = models.CharField(max_length=30, default='active')
    class Meta:
        unique_together = ("device", "name")  # 聯合主鍵
        
class InterfaceExtension(models.Model):
    interface = models.OneToOneField(
        Interface, on_delete=models.PROTECT, null=False, related_name='ex_info')
        
    endpoint_device_id = models.ForeignKey( # 繫結了的終端裝置
        Device, db_column='endpoint_device_id',
        on_delete=models.PROTECT, null=True, blank=True)
        
    endpoint_interface_id = models.ForeignKey(
        Interface, db_column='endpoint_interface_id', on_delete=models.PROTECT, # 繫結了的終端裝置的介面
        null=True, blank=True)

簡單說一下之間的關聯關係,一個裝置擁有多個介面,一個介面擁有一個擴充屬性。

在介面的擴充屬性中,可以繫結另一臺裝置上的介面,所以在 interface_extension 還有兩個參考外來鍵。

為了更好的分析 ORM 執行 SQL 的過程,需要將執行的 SQL 記錄下來,可以通過如下的方式:

  • 在 django settings 中開啟 sql log 的日誌
  • 在 MySQL 中開啟記錄 sql log 的日誌

django 中,在 settings.py 中配置如下內容, 就可以在控制檯上看到 SQL 執行過程:

DEBUG = True

import logging
l = logging.getLogger('django.db.backends')
l.setLevel(logging.DEBUG)
l.addHandler(logging.StreamHandler())

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'filters': {
        'require_debug_false': {
            '()': 'django.utils.log.RequireDebugFalse'
        }
    },
    'handlers': {
        'mail_admins': {
            'level': 'ERROR',
            'filters': ['require_debug_false'],
            'class': 'django.utils.log.AdminEmailHandler'
        },'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django.db': {
            'level': 'DEBUG',
            'handlers': ['console'],
        },
    }
}

或者直接在 MySQL 中配置:

# 檢視記錄 SQL 的功能是否開啟,預設是關閉的:
SHOW VARIABLES LIKE "general_log%";

# 將記錄功能開啟,具體的 log 路徑會通過上面的命令顯示出來。
SET GLOBAL general_log = 'ON';

QuerySet

假如要通過 QuerySet 來查詢,所有介面的所屬裝置的名稱:

interfaces = Interface.objects.filter()[:5] # hit once database

for interface in interfaces: 
    print('interface_name: ', interface.name,
          'device_name: ', interface.device.name) # hit database again

上面第一句取前 5 條 interface 記錄,對應的 raw sql 就是 select * from interface limit 5; 沒有任何問題。

但下面取介面所屬的裝置名時,就會出現反覆呼叫資料庫情況:當遍歷到一個介面,就會通過獲取的 device_id 去資料庫查詢 device_name. 對應的 raw sql 類似於:select name from device where id = {}.

也就是說,假如有 10 萬個介面,就會執行 10 萬次查詢,效能的消耗可想而知。算上之前查詢所有介面的一次查詢,合稱為 N + 1 次查詢問題。

解決方式也很簡單,如果使用原生 SQL,通常有兩種解決方式:

  • 在第一次查詢介面時,使用 join,將 interface 和 device 關聯起來。這樣僅會執行一次資料庫呼叫。
  • 或者在查詢介面後,通過程式碼邏輯,將所需要的 device_id 以集合的形式收集起來,然後通過 in 語句來查詢。類似於 SELECT name FROM device WHERE id in (....). 這樣做僅會執行兩次 SQL。

具體選擇哪種,就要結合具體的場景,比如有無索引,表的大小具體分析了。

回到 QuerySet,那麼如何讓 QuerySet 解決這個問題呢,同樣也有兩種解決方法,使用 QuerySet 中提供的 select_related() 或者 prefetch_related() 方法。

在呼叫 select_related() 方法時,Queryset 會將所屬 Model 的外來鍵關係,一起查詢。相當於 raw sql 中的 join . 一次將所有資料同時查詢出來。select_related() 主要的應用場景是:某個 model 中關聯了外來鍵(多對一),或者有 1 對 1 的關聯關係情況。

還拿上面的查詢介面的裝置名稱舉例的話:

interfaces = Interface.objects.select_related('device').filter()[:5] # hit once database

for interface in interfaces:
    print('interface_name: ', interface.name,
         'device_name: ', interface.device.name) # don't need to hit database again 

上面的查詢 SQL 就類似於:SELECT xx FROMinterface INNER JOIN device ON interface.device_id = device.id limit5,注意這裡是 inner join 是因為是非空外來鍵。

select_related() 還支援一個 model 中關聯了多個外來鍵的情況:如擴充介面,查詢繫結的裝置名稱和介面名稱:

ex_interfaces = InterfaceExtension.objects.select_related(
    'endpoint_device_id', 'endpoint_interface_id').filter()[:5] 

# or

ex_interfaces = InterfaceExtension.objects.select_related(
    'endpoint_device_id').select_related('endpoint_interface_id').filter()[:5] 

上面的 SQL 類似於:

SELECT XXX FROM interface_extension LEFT OUTER JOIN device ON (interface_extension.endpoint_device_id=device.id) 
LEFT OUTER JOIN interface ON (interface_extension.endpoint_interface_id=interface.id)
LIMIT 5

這裡由於是可空外來鍵,所以是 left join.

如果想要清空 QuerySet 的外來鍵關係,可以通過:queryset.select_related(None) 來清空。

prefetch_relatedselect_related 一樣都是為了避免大量查詢關係時的資料庫呼叫。只不過為了避免多表 join 後產生的巨大結果集以及效率問題, 所以 select_related 比較偏向於外來鍵(多對一)和一對一的關係。

prefetch_related 的實現方式則類似於之前 raw sql 的第二種,分開查詢之間的關係,然後通過 python 程式碼,將其組合在一起。所以 prefetch_related 可以很好的支援一對多或者多對多的關係。

還是拿查詢所有介面的裝置名稱舉例:

interfaces = Interface.objects.prefetch_related('device').filter()[:5] # hit twice database

for interface in interfaces:
    print('interface_name: ', interface.name,
         'device_name: ', interface.device.name) # don't need to hit database again

換成 prefetch_related 後,sql 的執行邏輯變成這樣:

  1. "SELECT * FROM interface "
  2. "SELECT * FROM device where device_id in (.....)"
  3. 然後通過 python 程式碼將之間的關係組合起來。

如果查詢所有裝置具有哪些介面也是一樣:

devices = Device.objects.prefetch_related('interfaces').filter()[:5] # hit twice database
for device in devices:
    print('device_name: ', device.name,
          'interface_list: ', device.interfaces.all())

執行邏輯也是:

  1. "SELECT * FROM device"
  2. "SELECT * FROM interface where device_id in (.....)"
  3. 然後通過 python 程式碼將之間的關係組合起來。

如果換成多對多的關係,在第二步會變為 join 後在 in,具體可以直接嘗試。

但有一點需要注意,當使用的 QuerySet 有新的邏輯查詢時, prefetch_related 的結果不會生效,還是會去查詢資料庫:

如在查詢所有裝置具有哪些介面上,增加一個條件,介面的狀態是 up 的介面

devices = Device.objects.prefetch_related('interfaces').filter()[:5] # hit twice database
for device in devices:
    print('device_name: ', device.name,
         'interfaces:', device.interfaces.filter(collect_status='active')) # hit dababase repeatly

執行邏輯變成:

  1. "SELECT * FROM device"
  2. "SELECT * FROM interface where device_id in (.....)"
  3. 一直重複 device 的數量次: "SELECT * FROM interface where device_id = xx and collect_status='up';"
  4. 最後通過 python 組合到一起。

原因在於:之前的 prefetch_related 查詢,並不包含判斷 collect_status 的狀態。所以對於 QuerySet 來說,這是一個新的查詢。所以會重新執行。

可以利用 Prefetch 物件 進一步控制並解決上面的問題:

devices = Device.objects.prefetch_related(
    Prefetch('interfaces', queryset=Interface.objects.filter(collect_status='active'))
    ).filter()[:5] # hit twice database
for device in devices:
    print('device_name: ', device.name, 'interfaces:', device.interfaces) 

執行邏輯變成:

  1. "SELECT * FROM device"
  2. "SELECT * FROM interface where device_id in (.....) and collect_status = 'up';"
  3. 最後通過 python 組合到一起。

可以通過 Prefetch 物件的 to_attr,來改變之間關聯關係的名稱:

devices = Device.objects.prefetch_related(
    Prefetch('interfaces', queryset=Interface.objects.filter(collect_status='active'), to_attr='actived_interfaces')
    ).filter()[:5] # hit twice database
for device in devices:
    print('device_name: ', device.name, 'interfaces:', device.actived_interfaces) 

可以看到通過 Prefetch,可以實現控制關聯那些有關係的物件。

最後,對於一些關聯結構較為複雜的情況,可以將 prefetch_related 和 select_related 組合到一起,從而控制查詢資料庫的邏輯。

比如,想要查詢全部介面的資訊,及其裝置名稱,以及擴充介面中繫結了對端裝置和介面的資訊。

queryset = Interface.objects.select_related('ex_info').prefetch_related(
            'ex_info__endpoint_device_id', 'ex_info__endpoint_interface_id')

執行邏輯如下:

  1. SELECT XXX FROM interface LEFT OUTER JOIN interface_extension ON (interface.id=interface_extension .interface_id)
  2. SELECT XXX FROM device where id in ()
  3. SELECT XXX FROM interface where id in ()
  4. 最後通過 python 組合到一起。

第一步, 由於 interface 和 interface_extension 是 1 對 1 的關係,所以使用 select_related 將其關聯起來。

第二三步:雖然 interface_extension 和 endpoint_device_id 和 endpoint_interface_id 是外來鍵關係,如果繼續使用 select_related 則會進行 4 張表連續 join,將其換成 select_related,對於 interface_extension 外來鍵關聯的屬性使用 in 查詢,因為interface_extension 表的屬性並不是經常使用的。

總結

在這篇文章中,介紹了 Django N +1 問題產生的原因,解決的方法就是通過呼叫 QuerySet 的 select_related 或 prefetch_related 方法。

對於 select_related 來說,應用場景主要在外來鍵和一對一的關係中。對應到原生的 SQL 類似於 JOIN 操作。

對於 prefetch_related 來說,應用場景主要在多對一和多對多的關係中。對應到原生的 SQL 類似於 IN 操作。

通過 Prefetch 物件,可以控制 select_related 和 prefetch_related 和那些有關係的物件做關聯。

最後,在每個 QuerySet 可以通過組合 select_related 和 prefetch_related 的方式,更改查詢資料庫的邏輯。

參考

https://docs.djangoproject.com/en/3.1/ref/models/querysets/](https://docs.djangoproject.com/en/3.1/ref/models/querysets/)

https://medium.com/better-programming/django-select-related-and-prefetch-related-f23043fd635d

https://stackoverflow.com/questions/39669553/django-rest-framework-setting-up-prefetching-for-nested-serializers

[https://medium.com/@michael_england/debugging-query-performance-issues-when-using-the-django-orm-f05f83041c5f

相關文章