Как обнаружить причину запроса N+1 в сериализаторе DRF

У меня есть проект на основе Django REST Framework, в котором одна из конечных точек работает очень медленно. Оказалось, что это проблема N+1 запросов, когда каждая возвращенная строка из сотен строк вызывает дополнительный запрос. Конечная точка не должна делать никаких объединений или дополнительных запросов, она просто возвращает содержимое одной таблицы, немного отфильтрованное. Модель для этой таблицы содержит несколько внешних ключей, но ни один из референсов не должен быть доступен, только идентификаторы должны быть отображены как JSON и отправлены клиенту.

Немного проб и ошибок показывают, что именно сериализатор DRF является причиной дополнительных запросов:

class FooSerializer(serializers.ModelSerializer):
    class Meta:
        model = Foo
        fields = ["data_field_a", "data_field_b", "bar_id", "qux_id"]

Когда я закомментирую bar_id в сериализаторе, запрос становится таким же быстрым, как и ожидалось. Удивительно то, чтоqux_id, несмотря на то, что поле практически идентично bar_id, не вызывает такого замедления. Я прочитал https://www.django-rest-framework.org/api-guide/relations/ и, помимо прочего, попробовал установить depth = 0 в классе Meta.

Также я понимаю, что использование select_related или prefetch_related по сути служит быстрым решением проблемы, но строго говоря, они не должны быть необходимы для этого случая, так как никаких объединений или дополнительных запросов на самом деле не требуется, и я пытаюсь понять первопричину проблемы, чтобы углубить свое понимание.

Итак, я хорошо понимаю фактическую причину замедления (дополнительные запросы сериализатора), но не совсем понимаю почему сериализатор решает сделать эти запросы для одного поля, но не для другого. Мой вопрос в том, как я должен продолжать отлаживать это? У меня нет опыта работы с DRF.

Итак, я нашел ответ, и вот как я его отладил. В основном три вещи:

  1. Изучение того, что делает сериализатор. https://www.django-rest-framework.org/api-guide/relations/#inspecting-relationships
poetry run python manage.py shell
...
>>> from app.views import FooSerializer
>>> print(repr(FooSerializer()))
FooSerializer():
    bar_code = SlugRelatedField(queryset=Bar.objects.all(), slug_field='unique_code')

Это ясно показывает, что он рассматривает поле как ссылку, а не как простое значение. Это может быть очевидно для некоторых разработчиков Django, но, конечно, не для меня, поскольку значение, которое мы пытаемся сериализовать, является значением поля на Foo, а не поля Bar. Похоже, что это происходит по логике типа "Получить Bar, используя внешний ключ к уникальному (не-PK) полю Bar. После получения Bar прочитайте это поле". Но это кажется мне контринтуитивным и излишним, поскольку у нас уже есть значение для доступа к Bar для начала!

  1. Выполнение минимального воспроизведения проблемы: https://github.com/golddranks/drf-demo

Это помогло мне понять, что относится к проблеме, а что нет. Играя с воспроизведением, я также заметил, что проблема была не только в Serializer, но и в общем доступе к полю bar_code. Я понял, что не понимаю, как получить доступ к базовому значению этого поля, не вызывая при этом выборку всей модели, на которую ссылаюсь. Играя вокруг, я также обнаружил, что когда bar_code является основным ключом в Bar, он на самом деле достаточно умен, чтобы не загружать Bar, а просто получить доступ к значению внешнего ключа Foo напрямую!

  1. Осведомиться о новообретенных знаниях.

Друг прислал мне запрос pull request, который показывает, как получить доступ к базовому значению поля внешнего ключа: доступ к полю как bar_code_id, с присоединенным суффиксом _id. Что меня удивило, так это то, что в таблице DB нет такого столбца! Из документации (https://docs.djangoproject.com/en/dev/ref/models/fields/#database-representation) я понял, что доступ к базовому полю таким образом возможен в случае, если Django автоматически создаст базовое поле ID, но в данном случае этого не произошло, мы создали его вручную, и у него, конечно, нет суффикса _id! Однако bar_code_id работает и справляется с извлечением базового значения, не вызывая загрузки Bar!

Вернуться на верх