Как стать автором
Поиск
Написать публикацию
Обновить

Оптимизация Django под высокие нагрузки: как мы ускорили ответы сервиса с помощью кэша, SIMD и настройки GC

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров791

За более чем 10 лет в разработке я не раз сталкивался с проблемой недостаточной производительности сервисов. Особенно это заметно на Python – отличном языке для быстрого старта, с множеством библиотек и фреймворков. Однако, когда проект растёт, его производительности начинает не хватать, и проблемы с задержками превращаются в угрозу стабильности и пользовательскому опыту. В этом руководстве я поделюсь практическими решениями, основанными на реальных кейсах, чтобы помочь вам оптимизировать Django-сервис и значительно сократить время ответа на запросы.

Мой первый рабочий день в одной из компаний начался с фразы CEO: "Мы вчера упали, надо разобраться и больше не падать!" Причины деградации были очевидны – в обычное время нагрузка на сервис равномерно распределялась, но в пиковые моменты, такие как крупные общественные мероприятия, происходил резкий всплеск, и десятки тысяч пользователей подключались в короткий промежуток времени. Несмотря на кеширование и масштабирование, с такой нагрузкой Django не справлялся. В этой статье я расскажу, какие шаги мы предприняли, чтобы преодолеть эту проблему и выйти из новогодних праздников с минимальными сбоями.

Поиск проблемы

Если вы ранее занимались вопросом ускорения Django-приложений вы наверняка встречались с советами вроде: избавьтесь от проблемы N+1; добавьте индексы в БД; добавьте кэш. Это хорошие советы, правда, однако для нас это была уже проделанная работа и её не хватило.

Симптомы следующие: в час пик пользователи массово запускают приложения и/или открывают сайт сервиса. После краткой аутентификации, они идут запускать нужный им видеопоток, но для этого его нужно найти, а для этого требуется загрузить расписание передач, а для этого требуется получить данные пользователя, доступных ему подписок, доступность контента и массу других проверок, а для этого надо сходить в базу данных во множество таблиц, а для этого... Вот тут картина начала вырисовываться.

В десятке самых популярных запросов в час пик были:

  • различные данные пользователя

  • телепрограмма

  • метаданные канала/передачи

  • и, наконец, запрос видеопотока

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

Выбор направлений для работы

Наш сервис предоставляет возможность смотреть видео пользователям не только из разных регионов страны, но и из других стран, такой контент зачастую имеет лицензионные ограничения, которые могут, например, ограничивать регионы распространения. Плюсом к этому, часть контента должна быть доступна или недоступна на основании подписок пользователей и, даже, их сетевой привязки к тому или иному интернет-провайдеру. В итоге, для генерации финального ответа от сервера, нужно получить массу данных из БД, часть из которых динамическая для каждого пользователя, что не позволяет положить ответ в кэш и использовать его для всех сразу. Так как же быть?

Мы выбрали два основных пути оптимизации:

  1. Кэширование: часть данных, которые не меняются часто или не меняются в рамках одной сессии - кешируем с коротким сроком.

  2. Ускоренная сериализация: хотя для Python существует множество библиотек для работы с JSON, не все они используют возможности современных процессоров, например такие как SIMD.

Кэширование

Разобрав цепочку запросов пользователя стало ясно, что несколько последовательных запросов требуют получения одних и тех же данных из профиля пользователя: наличие подписок и дополнительных покупок, привязку его к интернет-провайдеру, регион и др. Очевидно, что эти данные не меняются часто в процессе сеанса: подписка может истечь и пользователь может пересечь границу региона или попасть в сеть к другому провайдеру, но проверять эти данные можно в дискретный промежуток времени, скажем, каждые 5 минут. Это даёт нам возможность положить в кэш часть данных.

Нужно учесть, что данные в разных методах API используются по-разному, а значит возникает вопрос "в каком виде хранить данные в кэше". Тут нет единого ответа, форматы данных могут быть разными и способы их использования тоже. Протестировав несколько вариантов мы остановились на Hash в Redis, т. к. он показал наименьший оверхед. Формат примерно такой:

{
  "profile:12345" => {
    "region": "RU-CENTRAL",
    "isp": "some-isp-id",
    "subscriptions": ["basic", "kids"],
    ...
}

Совет: перед тем, как решите использовать кеш, обязательно посчитайте необходимый объём данных и настройте политику вытеснения (Eviction Policy): в зависимости от вашего сценария использования подбирайте подходящую именно вам. Мы остановились на allkeys-lru.

Сериализация

Как сказано выше, для Python существует масса библиотек для работы с JSON, такие как UltraJson, RapidJson, Orjson и др., однако, не все они работают одинаково и вот в чем суть: с начала 2010-х годов в процессорах начал появляться SIMD – возможность параллельного выполнения одной инструкции над несколькими наборами данных. Это стало возможным благодаря появлению в процессорах векторных регистров, об этом можно прочитать подробнее в Вики. Технология развивалась и к 2020-м года появилась новая версия, использующая большие регистры (AVX-512) и именно её используют некоторые библиотеки при парсинге и сериализации, что возвращает нас к Python.

Многие разработчики по привычке используют ultrajson – это проверенный способ широко описанный в различных проектах и документации и, к сожалению для нас, он требовал замены. Поиск и анализ существующих альтернатив приводит нас к двум библиотекам, которые показывают схожую производительность оставляя стандартный json и ujson далеко позади: orjson и msgspec.

https://res.craft.do/user/full/20ed8a2f-6282-7081-91fc-e519cabf5b84/doc/0a0f1e55-f7c9-4de5-9917-b06bceb64b6b/428ccb72-2d7f-4ff1-a784-9115a5747282
Сравнение библиотке (источник)

Для обоих существуют пакеты для интеграции с Django Rest Framework, оба покрывали необходимый нам функционал. Выбор пал на orjson по двум причинам:

  • memory-safety, т. к. orjson написан на rust

  • минимально необходимые изменения в коде

Нам потребовалось изменить буквально несколько строк в settings.py:

REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": (
        "drf_orjson_renderer.renderers.ORJSONRenderer",
        "rest_framework.renderers.BrowsableAPIRenderer",
    ),
}

Итак, что же нам это дало? А дало нам это много и вот почему: кроме сериализации ответов API JSON активно используется при межсервисном взаимодействии. Таким образом заменяя сериализатор/десериализатор мы разом ускоряем несколько частей системы. В цифрах это выразилось в сокращении на сотни миллисекунд на больших объемах данных телепрограммы.

Библиотека

Время (сек)

Ускорение относительно json

json

0.00479

1x (базовый уровень)

orjson

0.001179

4.06x быстрее

msgspec

0.000629

7.62x быстрее

ujson

0.003957

1.21x быстрее

simdjson

0.004636

1.03x быстрее

Пример скорости обработки на реальных данных
Пример скорости обработки на реальных данных

Совет: тестируйте различные варианты, это позволит найти лучший именно для вашей ситуации.

Дополнительные изменения

Конечно, это не единственные правки, которые мы внесли пока работали над ускорением.

Уменьшение объема данных

После анализа передаваемых и используемых данных, стало ясно, что часть данных можно не запрашивать из БД, сокращая время обработки запроса и генерации ответа. Это позволило сократить объем ответа некоторых методов до 20% (профиль пользователя, метаданные контента).

Асинхронные методы

С версии 4.0 Django позволяет использовать асинхронные методы для обработки запросов. И, хотя, само по себе это не ускоряет отдельный запрос, но позволяет эффективнее использовать ресурсы одного рабочего процесса для I/O-bound задач, увеличивая общую пропускную способность сервиса.

Сборка мусора

Мы заметили что Python очень часто запускает сборщик мусора: необходимо обрабатывать большие связанные структура данных, что приводило к аллокации огромного количества объектов в памяти. Дефолтные настройки довольно GC при этом довольно скромные:

  • аллокаций минус деаллокаций = 2000

  • 10 срабатываний GC для первого поколения

  • 10 срабатываний GC для второго поколения

После экспериментов мы установили новые значения.

import gc

# 10_000 allocations
# 100 gen1 GC
# 100 gen2 GC
gc.set_threshold(10_000, 100, 100)

В совокупности это дало порядка 5% ускорения в ответах, при этом не потеряв в стабильности работы приложения в целом и не приводя к увеличению нагрузке на ОЗУ.

Настройка GC требует осторожности и тестирования. Обязательно проверяйте работу и стабильность перед релизом в прод.

Обновление Python

И последний, но не менее важный совет: не пренебрегайте обновлениями не только библиотек и фреймворков, но и самого интерпретатора. Переход на актуальную версию Python 3.8 → 3.11 дал нам прирост в производительности порядка 10%, хотя и потребовал некоторого обновления кодовой базы и зависимых библиотек.

Итоги

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

  • Значительное снижение задержек: 99-й перцентиль времени ответа для ключевых эндпоинтов (телепрограмма, стрим) сократился с 900-1500 мс до стабильных 250-600 мс.

  • Снятие нагрузки с БД: Количество запросов к базам данных в пик уменьшилось в несколько раз за счет грамотного кэширования и сокращения избыточных данных, итоговая загрузка серверов не превышала 50% против 90% до изменений.

  • Рост пропускной способности: Серверы приложений стали обрабатывать до 3-х раз больше запросов в секунду на том же железе благодаря снижению нагрузки на CPU (orjson) и эффективному использованию ресурсов (асинхронность, настройка GC).

Главный вывод этого кейса: не существует «серебряной пули». Высокая производительность – это всегда результат системного подхода и внимания к деталям. Стандартные советы по оптимизации – это необходимый базис, но для борьбы с реальными «штормами» нужен глубокий анализ и готовность к точечным, иногда нетривиальным, изменениям. Начните с профилирования, найдите ваши узкие места, и будьте готовы копать глубже общепринятых практик.

Если ваш Django-сервис на грани – не бойтесь выходить за рамки документации. Начните с метрик, посмотрите в Django Debug Toolbar и flamegraph, попробуйте orjson и поэкспериментируйте с GC – иногда именно такие мелочи спасают миллионы запросов.

Теги:
Хабы:
+8
Комментарии1

Публикации

Ближайшие события