Повышение производительности сериализации в Django Rest Framework

Когда разработчик выбирает Python, Django или Django Rest Framework, обычно это происходит не из-за его невероятно высокой производительности. Python всегда был «удобным» выбором, языком, который вы выбираете, когда больше заботитесь об эргономике, чем о нескольких микросекундах какого-либо процесса.

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

Все это не означает, что производительность не важна. Как нас научила эта история, значительного повышения производительности можно добиться, лишь приложив немного внимания и небольшие изменения.

Производительность сериализатора моделей

Некоторое время назад мы заметили очень низкую производительность одной из наших основных конечных точек API. Конечная точка получила данные из очень большой таблицы, поэтому мы, естественно, предположили, что проблема должна быть в базе данных.

Когда мы заметили, что даже небольшие наборы данных плохо работают, мы начали изучать другие части приложения. Это путешествие в конечном итоге привело нас к сериализаторам Django Rest Framework (DRF).

Версии

В тесте мы используем Python 3.7, Django 2.1.1 и Django Rest Framework 3.9.4.

Простая функция

Сериализаторы используются для преобразования данных в объекты и объектов в данные. Это простая функция, поэтому давайте напишем такую, которая принимает экземпляр User и возвращает dict:

from typing import Dict, Any

from django.contrib.auth.models import User


def serialize_user(user: User) -> Dict[str, Any]:
    return {
        'id': user.id,
        'last_login': user.last_login.isoformat() if user.last_login is not None else None,
        'is_superuser': user.is_superuser,
        'username': user.username,
        'first_name': user.first_name,
        'last_name': user.last_name,
        'email': user.email,
        'is_staff': user.is_staff,
        'is_active': user.is_active,
        'date_joined': user.date_joined.isoformat(),
    }

Создайте пользователя для использования в тесте:

>>> from django.contrib.auth.models import User
>>> u = User.objects.create_user(
>>>     username='hakib',
>>>     first_name='haki',
>>>     last_name='benita',
>>>     email='me@hakibenita.com',
>>> )

Для нашего теста мы используем cProfile. Чтобы исключить внешние воздействия, такие как база данных, мы заранее выбираем пользователя и сериализуем его 5000 раз:

>>> import cProfile
>>> cProfile.run('for i in range(5000): serialize_user(u)', sort='tottime')
15003 function calls in 0.034 seconds

Ordered by: internal time
ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  5000    0.020    0.000    0.021    0.000 {method 'isoformat' of 'datetime.datetime' objects}
  5000    0.010    0.000    0.030    0.000 drf_test.py:150(serialize_user)
     1    0.003    0.003    0.034    0.034 <string>:1(<module>)
  5000    0.001    0.000    0.001    0.000 __init__.py:208(utcoffset)
     1    0.000    0.000    0.034    0.034 {built-in method builtins.exec}
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Простая функция заняла 0,034 секунды, чтобы сериализовать объект User 5000 раз.

ModelSerializer

Django Rest Framework (DRF) поставляется с несколькими служебными классами, а именно ModelSerializer.

ModelSerializer для встроенной модели User может выглядеть так:

from rest_framework import serializers

class UserModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = [
            'id',
            'last_login',
            'is_superuser',
            'username',
            'first_name',
            'last_name',
            'email',
            'is_staff',
            'is_active',
            'date_joined',
        ]

Выполнение того же теста, что и раньше:

>>> cProfile.run('for i in range(5000): UserModelSerializer(u).data', sort='tottime')
18845053 function calls (18735053 primitive calls) in 12.818 seconds

Ordered by: internal time
  ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   85000    2.162    0.000    4.706    0.000 functional.py:82(__prepare_class__)
 7955000    1.565    0.000    1.565    0.000 {built-in method builtins.hasattr}
 1080000    0.701    0.000    0.701    0.000 functional.py:102(__promise__)
   50000    0.594    0.000    4.886    0.000 field_mapping.py:66(get_field_kwargs)
 1140000    0.563    0.000    0.581    0.000 {built-in method builtins.getattr}
   55000    0.489    0.000    0.634    0.000 fields.py:319(__init__)
 1240000    0.389    0.000    0.389    0.000 {built-in method builtins.setattr}
    5000    0.342    0.000   11.773    0.002 serializers.py:992(get_fields)
   20000    0.338    0.000    0.446    0.000 {built-in method builtins.__build_class__}
  210000    0.333    0.000    0.792    0.000 trans_real.py:275(gettext)
   75000    0.312    0.000    2.285    0.000 functional.py:191(wrapper)
   20000    0.248    0.000    4.817    0.000 fields.py:762(__init__)
 1300000    0.230    0.000    0.264    0.000 {built-in method builtins.isinstance}
   50000    0.224    0.000    5.311    0.000 serializers.py:1197(build_standard_field)

DRF потребовалось 12,8 секунды на сериализацию пользователя 5000 раз или 390 мсек на сериализацию только одного пользователя. Это в 377 раз медленнее, чем обычная функция.

Мы видим, что значительное количество времени тратится на functional.py. ModelSerializer использует ленивую функцию из django.utils.functional для оценки валидаций. Он также используется подробными именами Django и т.п., которые также оцениваются DRF. Эта функция, кажется, отягощает сериализатор.

ModelSerializer только для чтения

Проверки полей добавляются ModelSerializer только для полей с возможностью записи. Чтобы измерить эффект от проверки, мы создаем ModelSerializer и помечаем все поля как доступные только для чтения:

from rest_framework import serializers

class UserReadOnlyModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = [
            'id',
            'last_login',
            'is_superuser',
            'username',
            'first_name',
            'last_name',
            'email',
            'is_staff',
            'is_active',
            'date_joined',
        ]
        read_only_fields = fields

Когда все поля доступны только для чтения, сериализатор нельзя использовать для создания новых экземпляров.

Давайте запустим наш тест с сериализатором только для чтения:

>>> cProfile.run('for i in range(5000): UserReadOnlyModelSerializer(u).data', sort='tottime')
14540060 function calls (14450060 primitive calls) in 7.407 seconds

 Ordered by: internal time
 ncalls  tottime  percall  cumtime  percall filename:lineno(function)
6090000    0.809    0.000    0.809    0.000 {built-in method builtins.hasattr}
  65000    0.725    0.000    1.516    0.000 functional.py:82(__prepare_class__)
  50000    0.561    0.000    4.182    0.000 field_mapping.py:66(get_field_kwargs)
  55000    0.435    0.000    0.558    0.000 fields.py:319(__init__)
 840000    0.330    0.000    0.346    0.000 {built-in method builtins.getattr}
 210000    0.294    0.000    0.688    0.000 trans_real.py:275(gettext)
   5000    0.282    0.000    6.510    0.001 serializers.py:992(get_fields)
  75000    0.220    0.000    1.989    0.000 functional.py:191(wrapper)
1305000    0.200    0.000    0.228    0.000 {built-in method builtins.isinstance}
  50000    0.182    0.000    4.531    0.000 serializers.py:1197(build_standard_field)
  50000    0.145    0.000    0.259    0.000 serializers.py:1310(include_extra_kwargs)
  55000    0.133    0.000    0.696    0.000 text.py:14(capfirst)
  50000    0.127    0.000    2.377    0.000 field_mapping.py:46(needs_label)
 210000    0.119    0.000    0.145    0.000 gettext.py:451(gettext)

Всего 7,4 секунды. Улучшение на 40% по сравнению с доступным для записи ModelSerializer.

В результатах теста мы видим, что много времени тратится на field_mapping.py и fields.py. Они связаны с внутренней работой ModelSerializer. В процессе сериализации и инициализации ModelSerializer использует множество метаданных для создания и проверки полей сериализатора, и за это приходится платить.

"Обычный" Serializer

В следующем тесте мы хотели точно измерить, сколько нам «стоит» ModelSerializer. Создадим «обычный» сериализатор для модели User:

from rest_framework import serializers

class UserSerializer(serializers.Serializer):
    id = serializers.IntegerField()
    last_login = serializers.DateTimeField()
    is_superuser = serializers.BooleanField()
    username = serializers.CharField()
    first_name = serializers.CharField()
    last_name = serializers.CharField()
    email = serializers.EmailField()
    is_staff = serializers.BooleanField()
    is_active = serializers.BooleanField()
    date_joined = serializers.DateTimeField()

Выполнение того же теста с использованием «обычного» сериализатора:

>>> cProfile.run('for i in range(5000): UserSerializer(u).data', sort='tottime')
3110007 function calls (3010007 primitive calls) in 2.101 seconds

Ordered by: internal time
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    55000    0.329    0.000    0.430    0.000 fields.py:319(__init__)
105000/5000    0.188    0.000    1.247    0.000 copy.py:132(deepcopy)
    50000    0.145    0.000    0.863    0.000 fields.py:626(__deepcopy__)
    20000    0.093    0.000    0.320    0.000 fields.py:762(__init__)
   310000    0.092    0.000    0.092    0.000 {built-in method builtins.getattr}
    50000    0.087    0.000    0.125    0.000 fields.py:365(bind)
     5000    0.072    0.000    1.934    0.000 serializers.py:508(to_representation)
    55000    0.055    0.000    0.066    0.000 fields.py:616(__new__)
     5000    0.053    0.000    1.204    0.000 copy.py:268(_reconstruct)
   235000    0.052    0.000    0.052    0.000 {method 'update' of 'dict' objects}
    50000    0.048    0.000    0.097    0.000 fields.py:55(is_simple_callable)
   260000    0.048    0.000    0.075    0.000 {built-in method builtins.isinstance}
    25000    0.047    0.000    0.051    0.000 deconstruct.py:14(__new__)
    55000    0.042    0.000    0.057    0.000 copy.py:252(_keep_alive)
    50000    0.041    0.000    0.197    0.000 fields.py:89(get_attribute)
     5000    0.037    0.000    1.459    0.000 serializers.py:353(fields)

Вот прыжок, которого мы ждали!

«Обычный» сериализатор занял всего 2,1 секунды. Это на 60% быстрее, чем ModelSerializer только для чтения, и на 85% быстрее, чем ModelSerializer с возможностью записи.

На этом этапе становится очевидным, что ModelSerializer стоит недешево!

Только для чтения "обычный" Serializer

В доступном для записи ModelSerializer много времени было потрачено на проверки. Мы смогли сделать это быстрее, отметив все поля как доступные только для чтения. «Обычный» сериализатор не определяет никакой проверки, поэтому не ожидается, что пометка полей как только для чтения будет быстрее. Убедимся:

from rest_framework import serializers

class UserReadOnlySerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    last_login = serializers.DateTimeField(read_only=True)
    is_superuser = serializers.BooleanField(read_only=True)
    username = serializers.CharField(read_only=True)
    first_name = serializers.CharField(read_only=True)
    last_name = serializers.CharField(read_only=True)
    email = serializers.EmailField(read_only=True)
    is_staff = serializers.BooleanField(read_only=True)
    is_active = serializers.BooleanField(read_only=True)
    date_joined = serializers.DateTimeField(read_only=True)

И запускаем тест для пользовательского экземпляра:

>>> cProfile.run('for i in range(5000): UserReadOnlySerializer(u).data', sort='tottime')
3360009 function calls (3210009 primitive calls) in 2.254 seconds

Ordered by: internal time
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    55000    0.329    0.000    0.433    0.000 fields.py:319(__init__)
155000/5000    0.241    0.000    1.385    0.000 copy.py:132(deepcopy)
    50000    0.161    0.000    1.000    0.000 fields.py:626(__deepcopy__)
   310000    0.095    0.000    0.095    0.000 {built-in method builtins.getattr}
    20000    0.088    0.000    0.319    0.000 fields.py:762(__init__)
    50000    0.087    0.000    0.129    0.000 fields.py:365(bind)
     5000    0.073    0.000    2.086    0.000 serializers.py:508(to_representation)
    55000    0.055    0.000    0.067    0.000 fields.py:616(__new__)
     5000    0.054    0.000    1.342    0.000 copy.py:268(_reconstruct)
   235000    0.053    0.000    0.053    0.000 {method 'update' of 'dict' objects}
    25000    0.052    0.000    0.057    0.000 deconstruct.py:14(__new__)
   260000    0.049    0.000    0.076    0.000 {built-in method builtins.isinstance}

Как и ожидалось, отметка полей как «только для чтения» не имела существенного значения по сравнению с «обычным» сериализатором. Это еще раз подтверждает, что время было потрачено на проверки, полученные из определений полей модели.

Сводка результатов

Вот краткое изложение результатов:

serializer seconds
UserModelSerializer 12.818
UserReadOnlyModelSerializer 7.407
UserSerializer 2.101
UserReadOnlySerializer 2.254
serialize_user 0.034

Предыдущие работы

О производительности сериализации в Python написано много статей. Как и ожидалось, большинство статей сосредоточено на улучшении доступа к БД с использованием таких методов, как select_related и prefetch_related. Хотя оба являются допустимыми способами улучшить общее время ответа на запрос API, они не касаются самой сериализации. Я подозреваю, что это потому, что никто не ожидает, что сериализация будет медленной.

Другие статьи, которые фокусируются исключительно на сериализации, обычно избегают исправления DRF и вместо этого мотивируют новые структуры сериализации, такие как marshmallow и serpy. Есть даже сайт, посвященный сравнению форматов сериализации в Python. Чтобы сэкономить вам время, DRF всегда идет последним.

В конце 2013 года Том Кристи, создатель Django Rest Framework, написал статью, в которой обсуждались некоторые недостатки DRF. В его тестах на сериализацию приходилось 12% общего времени, затрачиваемого на обработку одного запроса. Подводя итог, Том рекомендует не всегда прибегать к сериализации:

4. Вам не всегда нужно использовать сериализаторы.

Для представлений, критичных к производительности, вы можете полностью отказаться от сериализаторов и просто использовать .values() в запросах к базе данных.

Как мы чуть позже увидим, это надежный совет.


Почему это происходит?

В первом тесте с использованием ModelSerializer мы увидели, что значительное количество времени было потрачено на function.py, а точнее, на функцию lazy.

Исправление Django lazy

Функция lazy используется внутри Django для многих вещей, таких как подробные имена, шаблоны и т.д. Источник описывает lazy следующим образом:

Инкапсулируйте вызов функции и действуйте как прокси для методов, которые вызываются в результате этой функции. Функция не оценивается, пока не будет вызван один из методов для результата.

Ленивая функция творит чудеса, создавая прокси класса результата. Чтобы создать прокси, lazy выполняет итерацию по всем атрибутам и функциям класса результата (и его суперклассов) и создает класс-оболочку, который оценивает функцию только тогда, когда ее результат фактически используется.

Для больших классов результатов создание прокси может занять некоторое время. Итак, чтобы ускорить процесс, lazy кеширует прокси. Но, как оказалось, небольшая оплошность в коде полностью сломала механизм кеширования, в результате чего ленивая функция стала очень медленной.

Чтобы понять, насколько медленным является lazy без надлежащего кэширования, давайте воспользуемся простой функцией, которая возвращает str (класс результата), например upper. Мы выбрали str, потому что у него много методов, поэтому потребуется время, чтобы настроить для него прокси.

Чтобы установить базовый уровень, мы проводим сравнительный анализ, используя str.upper напрямую, без lazy:

>>> import cProfile
>>> from django.utils.functional import lazy
>>> upper = str.upper
>>> cProfile.run('''for i in range(50000): upper('hello') + ""''', sort='cumtime')

 50003 function calls in 0.034 seconds

 Ordered by: cumulative time

 ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.000    0.000    0.034    0.034 {built-in method builtins.exec}
     1    0.024    0.024    0.034    0.034 <string>:1(<module>)
 50000    0.011    0.000    0.011    0.000 {method 'upper' of 'str' objects}
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

А теперь самое страшное, та же самая функция, но на этот раз с lazy:

>>> lazy_upper = lazy(upper, str)
>>> cProfile.run('''for i in range(50000): lazy_upper('hello') + ""''', sort='cumtime')

 4900111 function calls in 1.139 seconds

 Ordered by: cumulative time

 ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      1    0.000    0.000    1.139    1.139 {built-in method builtins.exec}
      1    0.037    0.037    1.139    1.139 <string>:1(<module>)
  50000    0.018    0.000    1.071    0.000 functional.py:160(__wrapper__)
  50000    0.028    0.000    1.053    0.000 functional.py:66(__init__)
  50000    0.500    0.000    1.025    0.000 functional.py:83(__prepare_class__)
4600000    0.519    0.000    0.519    0.000 {built-in method builtins.hasattr}
  50000    0.024    0.000    0.031    0.000 functional.py:106(__wrapper__)
  50000    0.006    0.000    0.006    0.000 {method 'mro' of 'type' objects}
  50000    0.006    0.000    0.006    0.000 {built-in method builtins.getattr}
     54    0.000    0.000    0.000    0.000 {built-in method builtins.setattr}
     54    0.000    0.000    0.000    0.000 functional.py:103(__promise__)
      1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Без ошибок! При использовании lazy преобразование 5000 строк в верхний регистр заняло 1,139 секунды. Та же самая функция, использованная напрямую, заняла всего 0,034 секунды. Это на 33,5 быстрее.

Очевидно, это была оплошность. Разработчики четко осознавали важность кеширования прокси. Был выпущен PR, который вскоре после этого был объединен. После выпуска этот патч должен немного улучшить общую производительность Django.

Исправление Django Rest Framework

DRF использует lazy для валидации и подробных имен полей. Когда все эти ленивые оценки объединяются, вы получаете заметное замедление.

Исправление ленивости в Django решило бы эту проблему и для DRF после небольшого исправления, но, тем не менее, было сделано отдельное исправление для DRF, чтобы заменить lazy на что-то более эффективное.

Чтобы увидеть эффект от изменений, установите последнюю версию Django и DRF:

(venv) $ pip install git+https://github.com/encode/django-rest-framework
(venv) $ pip install git+https://github.com/django/django

После применения обоих патчей мы снова запустили тот же тест. Вот результаты рядом:

serializer before after % change
UserModelSerializer 12.818 5.674 -55%
UserReadOnlyModelSerializer 7.407 5.323 -28%
UserSerializer 2.101 2.146 +2%
UserReadOnlySerializer 2.254 2.125 -5%
serialize_user 0.034 0.034 0%

Подведем итоги изменений как в Django, так и в DRF:

  • Время сериализации для доступного для записи ModelSerializer было сокращено вдвое.
  • Время сериализации для ModelSerializer только для чтения сократилось почти на треть.
  • Как и ожидалось, в других методах сериализации заметной разницы нет.

Выводы

Наши выводы из этого эксперимента:

Обновите DRF и Django, как только эти исправления станут официальной версией.

Оба PR были объединены, но еще не выпущены.

В конечных точках, критичных к производительности, используйте «обычный» сериализатор или вообще не используйте его.

У нас было несколько мест, где клиенты получали большие объемы данных или данные с помощью API. API использовался только для чтения данных с сервера, поэтому мы решили вообще не использовать сериализатор, а вместо этого встроили сериализацию.

Поля сериализатора, которые не используются для записи или проверки, должны быть доступны только для чтения.

Как мы видели в тестах, способ реализации валидации делает их дорогостоящими. Пометка полей как только для чтения устраняет ненужные дополнительные расходы.

Бонус: принуждение к хорошим привычкам

Чтобы разработчики не забыли установить поля только для чтения, мы добавили проверку Django, чтобы убедиться, что все ModelSerializers устанавливают read_only_fields:

# common/checks.py

import django.core.checks

@django.core.checks.register('rest_framework.serializers')
def check_serializers(app_configs, **kwargs):
    import inspect
    from rest_framework.serializers import ModelSerializer
    import conf.urls  # noqa, force import of all serializers.

    for serializer in ModelSerializer.__subclasses__():

        # Skip third-party apps.
        path = inspect.getfile(serializer)
        if path.find('site-packages') > -1:
            continue

        if hasattr(serializer.Meta, 'read_only_fields'):
            continue

        yield django.core.checks.Warning(
            'ModelSerializer must define read_only_fields.',
            hint='Set read_only_fields in ModelSerializer.Meta',
            obj=serializer,
            id='H300',
        )

С этой проверкой, когда разработчик добавляет сериализатор, он также должен установить read_only_fields. Если сериализатор доступен для записи, read_only_fields может быть установлен в пустой кортеж. Если разработчик забывает установить read_only_fields, он получает следующую ошибку:

$ python manage.py check
System check identified some issues:

WARNINGS:
<class 'serializers.UserSerializer'>: (H300) ModelSerializer must define read_only_fields.
    HINT: Set read_only_fields in ModelSerializer.Meta

System check identified 1 issue (4 silenced).

 

https://hakibenita.com/django-rest-framework-slow

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