Pull to refresh

Несколько обычных и не очень способов оптимизации производительности serverside-приложений

Level of difficultyMedium
Reading time6 min
Views5.6K

Рекомендую присмотреться к списку, если ваш проект вырос или планирует рост, написанный на любом интерпретируемом языке (php/ruby/python) на нескольких серверах с обычным стеком (веб-сервер/сервер приложений, субд, redis/memcahed, rabbitmq, ...).

В качестве подопытного для оптимизации был взят PHP backend - все нижеперечисленные приёмы были опробованы и применены. Наш проект почему-то задыхался на казалось бы неплохом железе и к тому же не утилизировал выданные ему аппаратные ресурсы.

  1. Чего не будет

  2. Соединение между компонентами приложения

    1. Избегаем сети

    2. Оптимизируем сеть

  3. Настройки хранилищ данных

    1. Репликация

    2. Оценка нагрузки

  4. Кэширование

Чего не будет

Не буду подробно расписывать способы, практически везде повторённые сотни раз в разных местах (как раз детали можно найти в них):

  • оптимизировать настройки БД / кэша (выключение сброса на диск) / сервера приложений (про pm=static);

  • увеличить мощности сервера; заказать ещё серверов; вынести на разные сервера приложение/БД/кэш; обновить версии программного обеспечения (зачастую в мажорных и иногда в минорных версиях привносят оптимизации)

  • не перегружать единые точки отказа - например:

  • не выполнять тяжёлые операции синхронно - перевести всё на очереди;

  • ну и куча других ошибок в разных частях системы;

Предположим что программно у нас все хорошо: везде стоят оптимальные настройки (соответствующие железу и характеру нагрузки), аппаратных ресурсов достаточно, нет единых точек отказа, тяжёлые задачи не выполняются синхронно и вообще нет поводов для волнений. Но приходящая нагрузка (причём далекая от прогнозируемых цифр) показывает что то здесь, то там возникают перегрузки и система не выдерживает нагрузочного тестирования. На что же обратить внимание?

Соединение между компонентами приложения

Избегаем сети

Если несколько блоков системы находятся на одном сервере, их можно связать через Unix domain socket вместо сети. Что это можно быть:

  • nginx -> php-fpm и обратно (например, для reverse-прокси) php-fpm -> nginx

  • php-fpm -> pgbouncer / любая БД, pgbouncer -> БД

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

Оптимизируем сеть

Держим постоянные соединения до хранилищ

Довольно просто - делаем все соединения до внешних хранилищ данных постоянными. Только аккуратнее если у вас целевой адрес динамический (внутри docker/k8s).

Например:

  • до СУБД - с помощью драйвера подключения к БД

  • до redis/memcached/rabbitmq/... - с помощью параметров подключения библиотеки (включить атрибут "постоянное" в открываемом сокете)

  • до внешних http-источников - с помощью реверсивного-прокси

Оптимизируем сеть - переиспользуем сетевые соединения

В статье хорошо описывается принцип reverse-прокси для сетевого межсервисного взаимодействия.

Поднимаем дополнительно контейнер с nginx в качестве reverse-прокси, который будет держать коннекты, и подключаемся к нему из приложения. Нагрузка на сеть уменьшится в разы, да и трафик (при хождении ко всяким https).

Особенным плюсом для умирающей после каждого запроса области памяти при запуске через php-fpm (вместе с curl-handler'ами) является то, что мы можем таким образом держать постоянные коннекты от reverse-прокси до внешних сервисов, а приложение будет ходить по дешёвому http к reverse-прокси (а можно же ещё и по UDS ходить, тогда задержки будут минимальны).

Ставим пулер запросов к хранилищам (postgres/rabbitmq/...)

Устанавливаем пулер запросов pgbouncer перед всеми БД. Некоторые большие бэкэнды используют пулинг как на стороне приложения (т.е сразу после приложения и проксирует к нескольким БД), так и на стороне БД (т.е прямо перед БД и ~ лимитирует запросы к конкретной БД).

Используем по умолчанию режим transaction. Возможно потребует переписывания части кода - особенно в части блокировок в БД. Настраиваем количество коннектов исходя из оценки ресурсов серверов.

Настройки хранилищ данных

Репликация

Используем реплики хранилищ

Заводим ещё БД, настраиваем репликацию - ходим туда за чтением данных.

Используем больше реплик хранилищ

Ставим несколько слейвов в дополнении к мастер БД, настраиваем репликацию. Но чтобы утилизировать все БД (и мастер, и слейвы) по максимуму - мы перенаправим на слейвы только часть запросов на чтение (но уж точно не все). Для этого в приложении настраиваем несколько коннектов и явно задаём коннект, через который пойдут те или иные запросы (зачастую играясь с mt_rand(0, 100) < 70 для указания % запросов на слейвы).

По моей оценке, неплохое соотношение при условии одинаковых суммарных мощностей мастера и слейв(ов): 70% запросов на чтение слейвы, 30% на мастер (оставляем свободу для операций записи). В любом случае частично мастер стоит использовать - лучше для более важных к актуальности данных (например, балансы пользователя).

Шардируем данные

Делаем несколько кэшей/БД - шардируем данные между ними. Например, у нас сессии хранятся в одном редисе, а данные кэша - во втором, а кэш репозитория (см. дальше) - в третьем.

Оценка нагрузки

Анализируем статистику хранилищ

В postgres - pg_stat_statements, в redis/php-fpm - slowlog. Смотрим, находим медленные запросы/функции и либо уменьшаем количество их выполнения (кэшированием), либо упрощаем/дробим.

Можно пойти дальше и добавить в приложение (вручную или с помощью APM-систем) сбор метрик: работы с БД, кэшем, внешними сервисами, чтобы смотреть на производительность и причины тормозов отдельных частей приложения.

Кэширование

Кэшируем простые выбираемые данные из базы

Если у нас так выходит, что после установки пулера запросов у нас кончаются свободные коннекты, но при этом нагрузка базы не стремится к 100%, тогда у нас БД слишком долго выполняет довольно простые запросы. Довольно часто эти простые запрос - это выборка с очень детерминированными фильтрами (id = :id / id IN (:listIds)). Не стоит тратить на это время БД - закэшируйте это, лучше всего прозрачно (с помощью слоя репозитория):

  1. перед тем как вытащить сущность/N сущностей из БД, проверьте в кэш (redis) и выдайте оттуда, если есть;

  2. если данных нет, сходите в бд и отдайте их оттуда;

  3. перед тем как отдать, положите их в кэш перед возвратом, чтобы в следующий раз вытащить быстрее.

Кэшируем сложные (=долго) выбираемые данные из базы

Всё то же самое актуально и для тех выборок, где у нас агрегация/куча фильтров = сложные запросы, которые заставляют базу немного (или много) поработать. Делаем всё то же самое, только перед хождением в БД, синхронизуруйте (как в java) процесс заполнения кэша - ставьте мьютекс (хотя бы в тот же самый кэш) и освобождайте после заполнения кэша - чтобы несколько клиентов не запустили процесс генерации кэша (= несколько сложных запросов к бд).

Кэшируем всё

Если необходимо ещё разгрузить БД, для не слишком критичных к актуальности данных (какие-нибудь подборки новостей на главной, список доступных предложений для пользователя и т.д) можно скомбинировать два приёма кэширования, чтобы убрать большое количество нагрузки:

  1. Выполняем сложный запрос к БД с фильтрами, получаем id каких-то элементов из базы; Кэшируем (можно даже в shm - см. след. раздел); Если полученный кусок общий для всех пользователей, то ещё лучше. Но зачастую такие списки привязаны (= т.к. фильтруются по нему) к пользователю, т.е кэш не переиспользуется между пользователями - ставим небольшое время жизни;

  2. Выбираем сами элементы из базы по id из п.1; Кэшируем; Здесь данные не привязаны к пользователю и кэш будет общий для всех пользователей. Ставим время жизни больше (+ добавляем инвалидацию при редактировании элементов в админ-панели);

  3. В следующих запросах (пока кэш живой) данные будут выбираться уже из кэша, минуя БД.

  4. В качестве задачи со звёздочкой, можно на п.1 сделать сложным с 2 этапами:

    1. Выбираем общий список элементов из БД (например, активных акции сайта); Кэшируем; Причём тут уже будет кэш общий для всех.

    2. Фильтруем его для конкретного пользователя, после сортируем в php.

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

Используйте память сервера как более быстрый кэш

Нередка ситуация когда для API требуется много cpu (условно, 32 ядра и 16 гб озу или больше), поэтому на серверах приложений довольно часто можно найти и какое-то количество свободной памяти (не занимаемой php-fpm, nginx или что там у вас ещё крутится на этих же серверах?). Давайте её используем: выставляем лимит для контейнера shm_size, (напр, 512Мб) и подключаем каталог /dev/shm как каталог для кэша в виде файлов. В хост-машине с этим сложнее - придётся следить самостоятельно за очисткой кэша.

Далее подключаем этот кэш как отдельный компонент кэширования в фреймворке и кладём в него большие куски не особо критичных данных (каких-нибудь списков) на небольшой промежуток времени (исчисляемый минутами). Если протухнет или будет не самый актуальный (если на одном сервере кэш будет лежать до 14:00:00, а на втором до 14:00:02), то ничего страшного не произойдёт.

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

Tags:
Hubs:
Total votes 12: ↑10 and ↓2+12
Comments11

Articles