Руководство по тестированию производительности и оптимизации с помощью Python и Django
Опубликовано: 2022-03-11Дональд Кнут сказал, что «преждевременная оптимизация — корень всех зол». Но наступает момент, обычно в зрелых проектах с высокими нагрузками, когда возникает неизбежная необходимость оптимизации. В этой статье я хотел бы рассказать о пяти распространенных методах оптимизации кода вашего веб-проекта. Я буду использовать Django, но принципы должны быть одинаковыми для других фреймворков и языков. В этой статье я буду использовать эти методы, чтобы сократить время ответа на запрос с 77 до 3,7 секунд.
Пример кода адаптирован из реального проекта, с которым я работал, и демонстрирует методы оптимизации производительности. Если вы хотите проследить и увидеть результаты самостоятельно, вы можете получить код в его начальном состоянии на GitHub и внести соответствующие изменения, следуя инструкциям в статье. Я буду использовать Python 2, так как некоторые сторонние пакеты пока недоступны для Python 3.
Представляем наше приложение
Наш веб-проект просто отслеживает предложения недвижимости по странам. Поэтому есть только две модели:
# houses/models.py from utils.hash import Hasher class HashableModel(models.Model): """Provide a hash property for models.""" class Meta: abstract = True @property def hash(self): return Hasher.from_model(self) class Country(HashableModel): """Represent a country in which the house is positioned.""" name = models.CharField(max_length=30) def __unicode__(self): return self.name class House(HashableModel): """Represent a house with its characteristics.""" # Relations country = models.ForeignKey(Country, related_name='houses') # Attributes address = models.CharField(max_length=255) sq_meters = models.PositiveIntegerField() kitchen_sq_meters = models.PositiveSmallIntegerField() nr_bedrooms = models.PositiveSmallIntegerField() nr_bathrooms = models.PositiveSmallIntegerField() nr_floors = models.PositiveSmallIntegerField(default=1) year_built = models.PositiveIntegerField(null=True, blank=True) house_color_outside = models.CharField(max_length=20) distance_to_nearest_kindergarten = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_school = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_hospital = models.PositiveIntegerField(null=True, blank=True) has_cellar = models.BooleanField(default=False) has_pool = models.BooleanField(default=False) has_garage = models.BooleanField(default=False) price = models.PositiveIntegerField() def __unicode__(self): return '{} {}'.format(self.country, self.address)
Абстрактная HashableModel
предоставляет любой модели, которая наследует от нее hash
свойство, содержащее первичный ключ экземпляра и тип содержимого модели. Это скрывает конфиденциальные данные, такие как идентификаторы экземпляров, заменяя их хешем. Это также может быть полезно в тех случаях, когда в вашем проекте есть несколько моделей и вам нужно централизованное место, которое расшифровывает и решает, что делать с разными экземплярами моделей разных классов. Обратите внимание, что для нашего небольшого проекта хеширование на самом деле не нужно, так как мы можем обойтись и без него, но оно поможет продемонстрировать некоторые методы оптимизации, поэтому я оставлю его там.
Вот класс Hasher
:
# utils/hash.py import basehash class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
Поскольку мы хотели бы обслуживать эти данные через конечную точку API, мы устанавливаем Django REST Framework и определяем следующие сериализаторы и представление:
# houses/serializers.py class HouseSerializer(serializers.ModelSerializer): """Serialize a `houses.House` instance.""" id = serializers.ReadOnlyField(source="hash") country = serializers.ReadOnlyField(source="country.hash") class Meta: model = House fields = ( 'id', 'address', 'country', 'sq_meters', 'price' )
# houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() serializer = self.serializer_class(queryset, many=True) return Response(serializer.data)
Теперь мы заполняем нашу базу данных некоторыми данными (всего 100 000 экземпляров дома, сгенерированных с помощью factory-boy
: 50 000 для одной страны, 40 000 для другой и 10 000 для третьей страны) и готовы протестировать производительность нашего приложения.
Оптимизация производительности заключается в измерении
Есть несколько вещей, которые мы можем измерить в проекте:
- Время исполнения
- Количество строк кода
- Количество вызовов функций
- Выделенная память
- И т.п.
Но не все из них важны для измерения того, насколько хорошо работает наш проект. Вообще говоря, есть две основные метрики, которые являются наиболее важными: как долго что-то выполняется и сколько памяти для этого требуется.
В веб-проекте время отклика (время, необходимое серверу, чтобы получить запрос, сгенерированный каким-либо действием пользователя, обработать его и отправить обратно результат) обычно является самой важной метрикой, так как оно не дает пользователям скучать в ожидании. для ответа и переключиться на другую вкладку в своем браузере.
В программировании анализ производительности проекта называется профилированием. Чтобы профилировать производительность нашей конечной точки API, мы будем использовать пакет Silk. После его установки и выполнения нашего вызова /api/v1/houses/?country=5T22RI
(хеш, который соответствует стране с 50 000 записей домов) мы получаем это:
200 ПОЛУЧИТЬ
/апи/v1/дома/
77292 мс всего
15854 мс на запросы
50004 запроса
Общее время ответа составляет 77 секунд, из них 16 секунд уходит на запросы в базу данных, где всего было сделано 50 000 запросов. С такими огромными числами есть много возможностей для улучшения, так что давайте начнем.
1. Оптимизация запросов к базе данных
Один из наиболее частых советов по оптимизации производительности — убедиться, что ваши запросы к базе данных оптимизированы. Этот случай не исключение. Кроме того, мы можем сделать несколько вещей с нашими запросами, чтобы оптимизировать время ответа.
1.1 Предоставить все данные сразу
Присмотревшись к этим 50 000 запросов, вы увидите, что все они являются избыточными запросами к таблице houses_country
:
200 ПОЛУЧИТЬ
/апи/v1/дома/
77292 мс всего
15854 мс на запросы
50004 запроса
В | Столы | Присоединяется | Время выполнения (мс) |
---|---|---|---|
+0:01:15.874374 | "дома_страна" | 0 | 0,176 |
+0:01:15.873304 | "дома_страна" | 0 | 0,218 |
+0:01:15.872225 | "дома_страна" | 0 | 0,218 |
+0:01:15.871155 | "дома_страна" | 0 | 0,198 |
+0:01:15.870099 | "дома_страна" | 0 | 0,173 |
+0:01:15.869050 | "дома_страна" | 0 | 0,197 |
+0:01:15.867877 | "дома_страна" | 0 | 0,221 |
+0:01:15.866807 | "дома_страна" | 0 | 0,203 |
+0:01:15.865646 | "дома_страна" | 0 | 0,211 |
+0:01:15.864562 | "дома_страна" | 0 | 0,209 |
+0:01:15.863511 | "дома_страна" | 0 | 0,181 |
+0:01:15.862435 | "дома_страна" | 0 | 0,228 |
+0:01:15.861413 | "дома_страна" | 0 | 0,174 |
Источником этой проблемы является тот факт, что в Django наборы запросов ленивы . Это означает, что набор запросов не оценивается и не попадает в базу данных до тех пор, пока вам действительно не понадобится получить данные. В то же время он получает только те данные, которые вы ему сказали, делая последующие запросы, если нужны какие-либо дополнительные данные.
Именно так и произошло в нашем случае. При получении запроса через House.objects.filter(country=country)
Django получает список всех домов в данной стране. Однако при сериализации экземпляра house
HouseSerializer
требуется экземпляр country
дома для вычисления поля country
сериализатора. Поскольку данные о стране отсутствуют в наборе запросов, django делает дополнительный запрос для получения этих данных. И так для каждого дома в наборе запросов — всего 50 000 раз.
Однако решение очень простое. Чтобы извлечь все необходимые данные для сериализации, вы можете использовать метод select_related()
в наборе запросов. Таким образом, наш get_queryset
будет выглядеть так:
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return queryset
Посмотрим, как это повлияло на производительность:
200 ПОЛУЧИТЬ
/апи/v1/дома/
всего 35979 мс
102 мс на запросы
4 запроса
Общее время ответа сократилось до 36 секунд, а время, проведенное в базе данных, составляет ~ 100 мс при всего 4 запросах! Это отличная новость, но мы можем сделать больше.
1.2 Предоставляйте только релевантные данные
По умолчанию Django извлекает все поля из базы данных. Однако, когда у вас есть огромные таблицы со множеством столбцов и строк, имеет смысл указать Django, какие именно поля извлекать, чтобы он не тратил время на получение информации, которая вообще не будет использоваться. В нашем случае для сериализации нужно всего пять полей, а у нас 17 полей. Имеет смысл указать, какие именно поля извлекать из базы данных, чтобы еще больше сократить время отклика.
Django имеет методы defer()
и the only()
для набора запросов именно для этого. Первый указывает, какие поля не загружать, а второй указывает, какие поля только загружать.
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country)\ .select_related('country')\ .only('id', 'address', 'country', 'sq_meters', 'price') return queryset
Это сократило время, затрачиваемое на запросы, вдвое, что хорошо, но 50 мс — это не так уж и много. Общее время также немного уменьшилось, но места для его сокращения стало больше.

200 ПОЛУЧИТЬ
/апи/v1/дома/
33111 мс всего
52 мс на запросы
4 запроса
2. Оптимизация вашего кода
Вы не можете бесконечно оптимизировать запросы к базе данных, и наш последний результат только что показал это. Даже если мы гипотетически уменьшим время, затрачиваемое на запросы, до 0, мы все равно столкнемся с реальностью полуминутного ожидания ответа. Пришло время перейти на другой уровень оптимизации: бизнес-логику .
2.1 Упростите свой код
Иногда сторонние пакеты требуют больших накладных расходов для выполнения простых задач. Одним из таких примеров является наша задача вернуть сериализованные экземпляры дома.
Django REST Framework великолепен, с множеством полезных функций из коробки. Однако наша главная цель сейчас — сократить время отклика, так что это отличный кандидат на оптимизацию, тем более, что сериализованные объекты довольно просты.
Давайте напишем собственный сериализатор для этой цели. Для простоты у нас будет один статический метод, который выполняет эту работу. На самом деле вам может понадобиться иметь одни и те же сигнатуры классов и методов, чтобы иметь возможность взаимозаменяемо использовать сериализаторы:
# houses/serializers.py class HousePlainSerializer(object): """ Serializes a House queryset consisting of dicts with the following keys: 'id', 'address', 'country', 'sq_meters', 'price'. """ @staticmethod def serialize_data(queryset): """ Return a list of hashed objects from the given queryset. """ return [ { 'id': Hasher.from_pk_and_class(entry['id'], House), 'address': entry['address'], 'country': Hasher.from_pk_and_class(entry['country'], Country), 'sq_meters': entry['sq_meters'], 'price': entry['price'] } for entry in queryset ] # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer plain_serializer_class = HousePlainSerializer # <-- added custom serializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() data = self.plain_serializer_class.serialize_data(queryset) # <-- serialize return Response(data)
200 ПОЛУЧИТЬ
/апи/v1/дома/
17312 мс всего
38 мс на запросы
4 запроса
Теперь это выглядит лучше. Время отклика сократилось почти вдвое из-за того, что мы не использовали код сериализаторов DRF.
Другой измеримый результат — общее количество вызовов функций, выполненных в течение цикла запрос/ответ, — сократилось с 15 859 427 вызовов (по сравнению с запросом, сделанным в разделе 1.2 выше) до 9 257 469 вызовов. Это означает, что около 1/3 всех вызовов функций были сделаны Django REST Framework.
2.2 Обновление/замена сторонних пакетов
Описанные выше методы оптимизации являются наиболее распространенными, с ними можно обойтись без тщательного анализа и размышлений. Тем не менее, 17 секунд все еще кажутся довольно длинными; чтобы уменьшить это число, нам нужно будет глубже погрузиться в наш код и проанализировать, что происходит под капотом. Другими словами, нам нужно будет профилировать наш код.
Вы можете выполнить профилирование самостоятельно, используя встроенный профилировщик Python, или вы можете использовать для этого некоторые сторонние пакеты (которые используют встроенный профилировщик Python). Поскольку мы уже используем silk
, он может профилировать код и генерировать двоичный файл профиля, который мы можем дополнительно визуализировать. Существует несколько пакетов визуализации, которые преобразуют двоичный профиль в некоторые полезные визуализации. Я буду использовать пакет snakeviz
.
Вот визуализация бинарного профиля последнего запроса сверху, привязанного к методу отправки представления:
Сверху вниз расположен стек вызовов, отображающий имя файла, имя метода/функции с номером строки и соответствующее совокупное время, затраченное на этот метод. Теперь легче увидеть, что львиная доля времени уходит на вычисление хеша (прямоугольники __init__.py
и primes.py
фиолетового цвета).
На данный момент это основное узкое место производительности в нашем коде, но в то же время это не совсем наш код — это сторонний пакет.
В таких ситуациях есть ограниченное количество вещей, которые мы можем сделать:
- Проверьте наличие новой версии пакета (надеюсь, она имеет лучшую производительность).
- Найдите другой пакет, который лучше справляется с нужными нам задачами.
- Напишите собственную реализацию, которая превзойдет по производительности пакет, который мы сейчас используем.
К счастью для меня, есть более новая версия пакета basehash
, отвечающего за хеширование. В коде используется версия 2.1.0, но есть и версия 3.0.4. Такие ситуации, когда вы можете обновиться до более новой версии пакета, более вероятны, когда вы работаете над существующим проектом.
При проверке примечаний к выпуску v.3 есть это конкретное предложение, которое звучит очень многообещающе:
В алгоритмах простоты была проведена масштабная переработка. Включая (так в оригинале) поддержку gmpy2, если она доступна (в оригинале) в системе, что намного больше.
Давайте выясним это!
pip install -U basehash gmpy2
200 ПОЛУЧИТЬ
/апи/v1/дома/
7738 мс всего
59 мс на запросы
4 запроса
Мы сократили время отклика с 17 до менее 8 секунд. Отличный результат, но есть еще одна вещь, на которую стоит обратить внимание.
2.3 Рефакторинг собственного кода
До сих пор мы улучшали наши запросы, заменяли сторонний сложный и общий код нашими собственными очень специфическими функциями и обновляли сторонние пакеты, но оставили наш существующий код нетронутым. Но иногда небольшой рефакторинг существующего кода может принести впечатляющие результаты. Но для этого нам нужно снова проанализировать результаты профилирования.
Присмотревшись повнимательнее, вы увидите, что хэширование по-прежнему является проблемой (неудивительно, что это единственное, что мы делаем с нашими данными), хотя мы и улучшились в этом направлении. Однако меня беспокоит зеленоватый прямоугольник, говорящий о том, что __init__.py
занимает 2,14 секунды, а также сероватый __init__.py:54(hash)
, который идет сразу после него. Это означает, что некоторая инициализация занимает много времени.
Давайте посмотрим на исходный код пакета basehash
.
# basehash/__init__.py # Initialization of `base36` class initializes the parent, `base` class. class base36(base): def __init__(self, length=HASH_LENGTH, generator=GENERATOR): super(base36, self).__init__(BASE36, length, generator) class base(object): def __init__(self, alphabet, length=HASH_LENGTH, generator=GENERATOR): if len(set(alphabet)) != len(alphabet): raise ValueError('Supplied alphabet cannot contain duplicates.') self.alphabet = tuple(alphabet) self.base = len(alphabet) self.length = length self.generator = generator self.maximum = self.base ** self.length - 1 self.prime = next_prime(int((self.maximum + 1) * self.generator)) # `next_prime` call on each initialized instance
Как видите, для инициализации base
экземпляра требуется вызов функции next_prime
; это довольно тяжело, как мы можем видеть в нижних левых прямоугольниках визуализации выше.
Давайте еще раз взглянем на мой класс Hash
:
class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() # <-- initializing on each method call content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() # <-- initializing on each method call unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
Как видите, я пометил два метода, которые инициализируют экземпляр base36
при каждом вызове метода, что на самом деле не требуется.
Поскольку хеширование является детерминированной процедурой, а это означает, что для заданного входного значения оно всегда должно генерировать одно и то же значение хеш-функции, мы можем сделать его атрибутом класса, не опасаясь, что это что-то сломает. Давайте проверим, как это будет работать:
class Hasher(object): base36 = basehash.base36() # <-- initialize hasher only once @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return cls.base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): unhashed = '%09d' % cls.base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
200 ПОЛУЧИТЬ
/апи/v1/дома/
всего 3766 мс
38 мс на запросы
4 запроса
Окончательный результат составляет менее четырех секунд, что намного меньше, чем то, с чего мы начали. Дальнейшая оптимизация времени отклика может быть достигнута с помощью кэширования, но в этой статье я не буду его затрагивать.
Заключение
Оптимизация производительности — это процесс анализа и обнаружения. Жестких правил, применимых ко всем случаям, нет, так как у каждого проекта есть свой поток и узкие места. Однако первое, что вы должны сделать, это профилировать свой код. И если в таком коротком примере я смог сократить время отклика с 77 секунд до 3,7 секунд, то огромные проекты имеют еще больший потенциал оптимизации.
Если вам интересно прочитать больше статей, связанных с Django, ознакомьтесь с 10 основными ошибками, которые совершают разработчики Django, написанным другим разработчиком Toptal Django Александром Шурыгиным.