Django ORM에서 발생하는 데이터베이스 N+1 성능 문제 자세히 보기

배경 설명
최근에 Django를 사용할 때api를 호출한 후 데이터베이스와 같은 프로세스에서의 업무에서 대량의 데이터베이스 조회 문장이 나타나는 것을 발견하였다.조사 결과 Django ORM의 메커니즘으로 인해 발생한 것으로 밝혀졌다.
Django Object-Relational Mapper(ORM)는 Django에서 비교적 인기 있는 특성으로 개발에서 대량으로 사용되고 있다.우리는 데이터베이스와 상호작용을 통해 DDL과 DML 작업을 실현할 수 있다.
구체적으로 말하면 QuerySet 대상을 사용하여 데이터를 검색하는 것이고 QuerySet은 본질적으로 미리 정의된 모델Manager과 데이터베이스를 통해 상호작용을 한다.
Manager는 Django 모델이 데이터베이스 조회를 제공하는 인터페이스로 모든 모델에 최소한 하나의 Manager 대상이 존재한다.하지만 오늘 소개할 주인공은 Query Set입니다. 중요한 것은 아닙니다.
문제를 명확하게 설명하기 위해 데이터베이스에 다음과 같은 표가 있다고 가정합니다.
device 테이블, 현재 네트워크에서 관리되는 물리적 장치를 표시합니다.
인터페이스 테이블, 물리 장치가 가지고 있는 인터페이스를 표시합니다.
interface_extension 테이블, 인터페이스 테이블과 일대일 관계입니다. 인터페이스 속성이 너무 많아서 자주 사용하지 않는 인터페이스 속성을 저장하는 데 사용됩니다.

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을 기록해야 한다.
  • djangosettings에서 sqllog 로그 열기
  • 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개의 인터페이스 기록을 취하였으며, 대응하는rawsql는 select * from interface limit 5; 아무런 문제가 없습니다.
    그러나 아래에서 인터페이스가 속한 장치 이름을 찾으면 데이터베이스 호출이 반복됩니다. 인터페이스를 훑어보면 가져온 device_id 데이터베이스 조회 device_name. 대응하는rawsql는 다음과 유사합니다: select name from device where id = {}.
    10만 개의 인터페이스가 있으면 10만 번의 조회를 실행해 성능의 소모를 짐작할 수 있다는 것이다.이전에 모든 인터페이스를 찾았던 조회를 포함하여 N+1회 조회 문제라고 부른다.
    해결 방법도 간단합니다. 원본 SQL을 사용하면 일반적으로 두 가지 해결 방법이 있습니다.
  • 첫 번째 검색 인터페이스에서join을 사용하여 인터페이스와 장치를 연결합니다.이렇게 하면 데이터베이스 호출을 한 번만 실행할 수 있다.
  • 또는 검색 인터페이스 후 코드 논리를 통해 필요한 device_id는 집합 형식으로 수집한 다음에 in 문장을 통해 조회합니다.유사 SELECT name FROM device WHERE id in (....).이렇게 하면 SQL이 두 번만 실행됩니다.
  • 구체적으로 어떤 것을 선택하려면 구체적인 장면, 예를 들어 색인이 있는지, 표의 크기를 구체적으로 분석해야 한다.
    QuerySet으로 돌아가면 어떻게 QuerySet이 이 문제를 해결할 수 있을까요? 마찬가지로 두 가지 해결 방법이 있습니다. QuerySet에서 제공한 select_related() 또는 prefetch_related() 방법을 사용합니다.
    select_relatedselect_related() 메서드를 호출할 때 Queryset은 소속 모델의 키 관계를 함께 조회합니다.rawsql의 join에 해당한다.한 번에 모든 데이터를 동시에 조회해 내다.select_related()의 주요 응용 장면은 어떤 모델에서 외부 키(다중 대 1)와 관련되거나 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() 또한 하나의 모델에 여러 개의 키와 연결된 상황을 지원합니다. 예를 들어 인터페이스 확장, 연결된 장치 이름과 인터페이스 이름 조회:
    
    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
    여기는 빈 키이기 때문에 leftjoin입니다.
    QuerySet의 외부 키 관계를 비우려면: queryset.select_related(None) 을 통해 비울 수 있습니다.
    prefetch_related
    prefetch_related 및 select_related와 마찬가지로 대량의 조회 관계를 피하기 위한 데이터베이스 호출입니다.다만 다중 표join 이후 발생하는 거대한 결과집합과 효율 문제를 피하기 위해select_related는 외부 키(다대일)와 일대일의 관계에 비교적 편향되어 있다.
    그리고 prefetch_related의 실현 방식은 이전rawsql의 두 번째와 유사하다. 조회 간의 관계를 분리한 다음python 코드를 통해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의 실행 논리는 이렇게 변했다.
  • "SELECT * FROM interface "
  • "SELECT * FROM device where device_id in (.....)"
  • 그리고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())
    실행 논리도 다음과 같습니다.
  • "SELECT * FROM device"
  • "SELECT * FROM interface where device_id in (.....)"
  • 그리고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
    실행 논리 변경:
  • "SELECT * FROM device"
  • "SELECT * FROM interface where device_id in (.....)"
  • "SELECT * FROM interface where device_id = xx and collect_status='up";
  • 마지막으로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) 
    실행 논리 변경:
  • "SELECT * FROM device"
  • "SELECT * FROM interface where device_id in (.....) and collect_status = 'up';"
  • 마지막으로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')
    다음과 같은 논리를 수행합니다.
  • SELECT XXX FROM interface LEFT OUTER JOIN interface_extension ON (interface.id=interface_extension .interface_id)
  • SELECT XXX FROM device where id in ()
  • SELECT XXX FROM interface where id in ()
  • 마지막으로python을 통해 조합한다.
  • 첫 번째, 인터페이스와 인터페이스로 인해_extension은 1 대 1의 관계이기 때문에 select_related는 그것을 연결합니다.
    두 번째 단계: interface_extension 및 endpoint_device_id 및 endpoint_interface_id는 키 관계입니다. select_를 계속 사용하면related는 4개의 시계 연속join을 진행하여 select_로 바꿉니다related,interface_extension 키와 관련된 속성은 in쿼리를 사용합니다. 인터페이스_extension표의 속성은 자주 사용하는 것이 아닙니다.
    총결산
    이 글에서 Django N +1 문제가 발생한 원인을 소개했는데, 해결 방법은 QuerySet을 호출하는 select_관련 또는 prefetch_관련 방법.
    select_관련적으로 말하자면 응용 장면은 주로 외부 키와 일대일의 관계에 있다.원래 SQL은 JOIN 작업과 유사합니다.
    prefetch에 대해_관련하여 말하자면 응용 장면은 주로 다대일과 다대다의 관계에 있다.원래 SQL은 IN 작업과 유사합니다.
    Prefetch 객체를 통해 select_ 제어 가능related and prefetch_related는 관계가 있는 대상과 연결됩니다.
    마지막으로 모든 QuerySet에서 select_를 조합할 수 있습니다related and prefetch_관련 방식으로 검색 데이터베이스의 논리를 변경합니다.
    참고
    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
    Django ORM이 일으킨 데이터베이스 N+1의 성능 문제에 대한 상세한 설명은 여기 있습니다. 더 많은 Django ORM 데이터베이스 N+1의 성능 내용은 저희 이전의 글을 검색하거나 아래의 관련 글을 계속 훑어보십시오. 앞으로 많은 응원 부탁드립니다!

    좋은 웹페이지 즐겨찾기