В качестве подопытного для оптимизации был взят PHP API, размещённый на ~10 серверах. Все нижеперечисленные приёмы были опробованы и применены. Поэтому рекомендую присмотреться к списку, если у вас веб-app на любом интерпретируемом языке (php/ruby/python) на нескольких (1-10) серверах с обычным стеком (nginx, fpm, mysql/postgres, redis/memcahed, rabbitmq, ...), которое почему-то задыхается на казалось бы неплохом железе и к тому же не утилизирует весь выделенный CPU.

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

Известные рекомендации

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

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

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

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

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

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

Ставим пулер запросов к базе (для postgres)

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

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

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

В 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.

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

Не особо популярные методы

Но они от этого не менее эффективны.

Использование реплик (слейв-БД) с весами

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

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

Связываем по UDS

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

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

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

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

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

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

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

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

Переиспользуйте сетевые соединения

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

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

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

Переходим на другую модель работы интерпретатора

Стандартный php-fpm очищает память после каждого запроса, а перед обработкой нового заполняет много чего ещё - даже preload не помогает. Стоит ли упоминать в минусах этого подхода необходимость на каждый запрос:

  • Постоянно выбирать из кэша одни и те же редко (раз в день) изменяемые данные?

  • Инициализировать контейнер, компоненты?

  • Устанавливать соединения с БД/пулером/кэшем/внешними сервисами?

Другая парадигма запуска - один раз запустился, и обрабатывает в цикле все запросы внутри приложения. Если очень коротко, то она реализована в: асинхронных (требуют переписать довольно сильно логику; ReactPHP, AMPHP, Workerman, Swoole, и др.) и синхронных (требует не таких кардинальных изменений, но всё таки; Roadrunner) серверах приложений.

Стоит отметить что данный приём - очень трудозатратен. В случае с асинхронными серверами приложений придётся переписать почти всё взаимодействие с БД и др. хранилищами, в случае синхронных - провести ревью и убрать любое использование "глобального" (ну или очень вездесущего) "состояния".

Зато очевидный выигрыш: постоянные соединения до всех внешних источников данных, уменьшение утилизации CPU (можно ожидать как минимум в ~полтора раза), уменьшение среднего времени ответа.

Комментарии (2)


  1. FanatPHP
    29.06.2023 06:11
    +3

    Если честно, я два раза читал в заголовке "большие", и только на третий увидел, что речь про "небольшие" приложения. И как-то картинка резко поменялась.


    Меня все время что-то смущало в этом тексте, но я не мог понять — что. И вот это несоответствие (называть распараллеленную на 10 серверов систему "небольшой" — это скромность, граничащая и идиотизмом троллингом) прямо щёлкнуло. Я понял, что в этом тексте не так: в нем перемешаны советы из, вроде бы, личного опыта, с очевидными байками "из интернета". И начала закрадываться мысль, что всё это написано нашим модным другом, Птицей-говоруном, которая отличается умом и сообразительностью, но находится в сложных отношениях с объективной реальностью.


    • Постоянные соединения для небольших приложений? Последняя, я бы сказал, вещь, которая им требуется. Особенно учитывая, что подавляющее число небольших приложений используют php/mysql, где от пконнекта больше вреда чем пользы.
    • непонятно, как использование редиса для сессий и кэша относится к использованию реплик. Кэш — это не реплика.
    • Кэшируем простые выбираемые данные из базы — возможно, я не работал с "небольшими" приложениями, но выглядит как решение проблему ХУ: неправильную конфигурацию БД исправляем кэшированием. При этом логика выглядит, как всегда в таких случаях, опосредованной: не " БД долго выполняетпростые запросы", а "если кончаются коннекты, то БД долго выполняет запросы". А у таракана уши в ногах.
    • Кэшируем всё. Я думаю, что любой разработчик в свое время приходил к этой идее "ну мы будем инвалидировать после изменения в админке". И сталкивался с суровой реальностью.
    • Использование реплик (слейв-БД) с весами. Тут прямо всё прекрасно. "настраиваем несколько коннектов" — ха, так вот куда делись коннекты из пункта "кэшируем простые запросы!". Причем внятного объяснения так и нет — зачем открывать несколько соединений, если по максимуму нужно два, на чтение и на запись. Как нет и объяснения про сами "веса". И почему слейвы должны иметь ту же мощность, что и мастер, учитывая многочисленные решения по проксированию и кластеризации.
    • Используйте память сервера как более быстрый кэш: какой-то совет из прошлого века, который выглядит больше как рекомендация ИИ, взятая из воздуха, чем реальный юзкейс.
    • Переходим на другую модель работы интерпретатора. Вот тоже, для "небольших" приложений, да.

    И при этом в статье ни слова про опкод кэш и способы его оптимизации, прелоадинг. С одной стороны я понимаю, что статья "общая", но с другой она зачем-то ведь размещена из всех языковых хабов только в РНР?


    1. wapmorgan Автор
      29.06.2023 06:11
      +1

      preload даёт максимум 10% ускорения бутстрепа - в моих тестах подтвердилось. opcache включён по умолчанию. Как вначале и писал, не хочу повторяться с сотней других мануалов по ускорению.

      А теперь по пунктам:

      • Особенно учитывая, что подавляющее число небольших приложений используют php/mysql Не знаю что там с mysql, но с postgres - обязательная настройка, позволяющая под нагрузкой держать стабильное околонулевое время соединения с пулером/бд.

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

      • если кончаются коннекты, то БД долго выполняет запросы. Всё верно. Именно с этим мы и боремся. Открывать по 2к коннектов к postgres - зачем? если можно иметь в четыре раза меньше и выбирать из них только важную связанную информацию, а простые строки выбирать из кэша (что быстрее чем БД и не занимает коннекты).

      • Кэшируем всё. Я думаю, что любой разработчик в свое время приходил к этой идее Сталкивался каждый. И приходилось выбирать: важнее актуальность или быстродействие. Совсем уж важные данные можно вытягивать без кэша либо с очень небольшим временем жизни, а всё остальное - можно кэшировать.

      • Причем внятного объяснения так и нет — зачем открывать несколько соединений, если по максимуму нужно два, на чтение и на запись У нас как раз два и открывается - одно к мастеру на запись и ~30% чтения и один к случайной реплике для чтения остальных данных. Зря я использовал "несколько", видимо, тоже вводит в заблуждение. Причём оба идут через один и тот же пулер, и поэтому 3.6k воркеров php используют всего ~520 коннектов до мастера и 280 коннектов до каждой из реплик (на данный момент их 2). А до кэширования простых записей из БД все коннекты к БД утилизировались и пришлось бы уменьшать work_mem postgres чтобы увеличить кол-во max_connections. А зачем, если можно не тратить время БД на то, чем может заняться кэш (просто выдать данные по ключу)?

      • Используйте память сервера как более быстрый кэш: какой-то совет из прошлого века, который выглядит больше как рекомендация ИИ Зря вы так категорично. Около полугигабайта некритичного к актуальности кэша (которые опять же, выбрали раз в 5 минут из БД, сагрегировали и положили) на каждой из нод приложения очень хорошо хранятся: и не требуют сети для передачи, и очищаются при редеплое, и выбираются очень быстро. Что там "прошловекового" я так и не понял.

      Если вас так сильно зацепило "небольшие", то да - это проблема. Заменю на "средний". Я исхожу из того, что несколько серверов - это вполне себе обычный бэкэнд: парочка для бд, штук 5 для приложения, ещё для кэша и s3.
      Большинство советов применимы к любому backend, но в огород java/go - я не лезу, не любят они этого.