Тяжелое начало

Как всегда после работы, сделал базовые дела и сел доработать эндпоинт на своем проекте. Задача пустяковая: вывести список, у каждой записи подтянуть связанный CVE и источник. Думал минут  30 и я справлюсь. Написать то написал, но подозрительно долго отрабатывались записи хоть их было и немного.

Врубил django-debug-toolbar, чекнул количество SQL-запросов на один HTTP-запрос.

 

201 запрос на одну страницу списка. Один — чтобы вытащить сами записи, и ещё по одному на каждую запись, чтобы подтянуть связанный источник.

Где собака зарыта

def vulnerability_list(request):
    vulnerabilities = Vulnerability.objects.filter(severity="critical")
    return render(request, "vulns/list.html", {"vulnerabilities": vulnerabilities})
{% for v in vulnerabilities %}
  
  • {{ v.cve_id }} — источник: {{ v.source.name }}
  • {% endfor %}

    Каждое обращение к v.source.name - отдельный поход в базу, потому что Django ORM ленивый и подгружает связанные объекты только по факту обращения. Классический N+1.

    Решение

    vulnerabilities = Vulnerability.objects.filter(severity="critical").select_related("source")
    

    select_related делает JOIN и подтягивает связанный объект одним запросом вместо отдельного на каждую строку. Запросов стало 1 вместо 201.

    Проблема ManyToMany

    Отдельно у каждой записи есть список тегов (ManyToManyField), и на них select_related не сработает т.к. он только для ForeignKey/OneToOne. Для этого нужен prefetch_related:

    vulnerabilities = (
        Vulnerability.objects
        .filter(severity="critical")
        .select_related("source")
        .prefetch_related("tags")
    )
    

    Если коротко то  prefetch_related делает отдельный запрос на все теги сразу и сшивает их в Python, а не через JOIN, для M2M это эффективнее.

    Что имеем

    Три секунды сократились до 50 - 60 миллисекунд. Разница на 200 записях вроде минорная, но на проде с тысячами, страшно представить с десятками тысяч записей  это была бы уже не разница в UX, а разница между «работает» и «упало».

    Взял на заметку:

    • N+1 не видно глазами в коде — только через профилировщик запросов
    • select_related — для ForeignKey/OneToOne (JOIN), prefetch_related - для ManyToMany стандартный способ избежать N+1
    • Debug toolbar полезен и не зря его советуют на курсе, хотя знаю что на проде сеньоры-помидоры чекают логи и всякие метрики через middleware

    В общем сегодня не про количество строк, а больше про оптимизацию.