Улучшение производительности сериализации в Django Rest Framework

Spread the love

История о том как можно сократить время сериализации на 99%!

Перевод статьи Haki Benita: Improve Serialization Performance in Django Rest Framework

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

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

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

Производительность ModelSerializer

Некоторое время назад мы заметили очень низкую производительность на одной из наших основных конечных точек 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(),
    }

Создадим пользователя User для использования в бенчмарке:

>>> 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 для встроенной пользовательской модели может выглядеть следующим образом:

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 раз медленнее, чем обычная функция.

Видно, что значительное количество времени уходит в functions.py. ModelSerializer использует отложенную функцию (lazy) из django.utils.functional для проведение валидации. Она также используется в подробных именах (verbose names) Django и т. д., которые также оцениваются DRF. Кажется что эта функция, значительно отягощает сериализатор.

ModelSerializer в режиме только для чтения (Read Only)

Проверка полей (валидация) добавляется в 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}

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

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

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

SERIALIZERSECONDS
UserModelSerializer12.818
UserReadOnlyModelSerializer7.407
UserSerializer2.101
UserReadOnlySerializer2.254
serialize_user0.034

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

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

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

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

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

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

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

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

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

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

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

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

lazy функция делает свое волшебство, создавая прокси класс результата. Чтобы создать прокси, 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, потребовалось 1,139 секунды, чтобы перевернуть 5 000 строк в верхний регистр. Точно такая же функция, которая использовалась напрямую, заняла всего 0,034 секунды. Это на 33,5 быстрее.

Это явная проблема. Разработчики четко осознавали важность кеширования прокси. PR был выпущен, и вскоре после этого слился (см. Здесь). Предполагается, что после выхода этот патч улучшит общую производительность Django

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

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

Исправление для 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

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

SERIALIZERBEFOREAFTER% CHANGE
UserModelSerializer12.8185.674-55%
UserReadOnlyModelSerializer7.4075.323-28%
UserSerializer2.1012.146+2%
UserReadOnlySerializer2.2542.125-5%
serialize_user0.0340.0340%

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

Выводы

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

  • Обновите DRF и Django, как только эти патчи войдут в официальный релиз. Оба PR были объединены, но еще не выпущены.
  • В конечных точках, критичных к производительности, используйте «обычный» сериализатор или не используйте его вообще. У нас было несколько мест, где клиенты выбирали большие объемы данных с помощью API. API использовался только для чтения данных с сервера, поэтому мы решили вообще не использовать Serializer.
  • Поля сериализатора, которые не используются для записи или проверки, должны быть доступны только для чтения. Как мы видели в тестах, способ реализации проверок делает их дорогостоящими. Пометка полей только для чтения исключает ненужные дополнительные расходы.

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

Чтобы разработчики не забыли установить поля только для чтения, мы добавили проверку Django, чтобы все классы ModelSerializer устанавливали 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).

В этой статье вы найдете много других полезных проверок о том, как мы используем систему проверки системы Django.


Spread the love

Добавить комментарий

Ваш e-mail не будет опубликован.