В качестве подопытного для оптимизации был взят PHP API, размещённый на ~10 серверах. Все нижеперечисленные приёмы были опробованы и применены. Поэтому рекомендую присмотреться к списку, если у вас веб-app на любом интерпретируемом языке (php/ruby/python) на нескольких (1-10) серверах с обычным стеком (nginx, fpm, mysql/postgres, redis/memcahed, rabbitmq, ...), которое почему-то задыхается на казалось бы неплохом железе и к тому же не утилизирует весь выделенный CPU.
Не буду подробно расписывать способы, практически везде повторённые сотни раз в разных местах (как раз детали можно найти в них):
увеличить мощности сервера; заказать ещё серверов; вынести на разные сервера приложение/БД/кэш;
оптимизировать настройки БД / кэша (выключение сброса на диск) / php-fpm (про pm=static); обновить версии компонентов; Не выполнять одинаковые запросы, иметь индексы в БД;
не перегружать кэш - напр, не класть в кэш мегабайты данных (или хотя бы иметь кэш для мелких и больших данных с разным требованиям к времени доступа); не выполнять очень много долгих операций в однопоточных сервисах (redis, например).
не выполнять тяжёлые операции синхронно - перевести всё на очереди;
Известные рекомендации
Держим постоянные соединения до хранилищ
Довольно просто - делаем все соединения до БД/кэша persistent. Только аккуратнее если у вас целевой адрес динамический (внутри docker/k8s).
Используем реплики хранилищ
Заводим ещё БД, настраиваем репликацию - ходим туда за чтением данных.
Шардируем данные
Делаем несколько кэшей/БД - шардируем данные между ними. Например, у нас сессии хранятся в одном редисе, а данные кэша - во втором, а кэш репозитория (см. дальше) - в третьем.
Ставим пулер запросов к базе (для postgres)
Устанавливаем пулер запросов pgbouncer перед всеми БД. Некоторые большие бэкэнды используют пулинг как на стороне приложения (т.е сразу после приложения и проксирует к нескольким БД), так и на стороне БД (т.е прямо перед БД и ~ лимитирует запросы к конкретной БД).
Используем по умолчанию режим transaction. Возможно потребует переписывания части кода - особенно в части блокировок в БД. Настраиваем количество коннектов исходя из оценки ресурсов серверов.
Анализируем статистику хранилищ
В postgres - pg_stat_statements, в redis/php-fpm - slowlog. Смотрим, находим медленные запросы/функции и либо уменьшаем количество их выполнения (кэшированием), либо упрощаем/дробим.
Можно пойти дальше и добавить в приложение (вручную или с помощью APM-систем) сбор метрик: работы с БД, кэшем, внешними сервисами, чтобы смотреть на производительность и причины тормозов отдельных частей приложения.
Кэшируем простые выбираемые данные из базы
Если у нас так выходит, что после установки пулера запросов у нас кончаются свободные коннекты, но при этом нагрузка базы не стремится к 100%, тогда у нас БД слишком долго выполняет довольно простые запросы. Довольно часто эти простые запрос - это выборка с очень детерминированными фильтрами (id = :id / id IN (:listIds)
). Не стоит тратить на это время БД - закэшируйте это, лучше всего прозрачно (с помощью слоя репозитория):
перед тем как вытащить сущность/N сущностей из БД, проверьте в кэш (redis) и выдайте оттуда, если есть;
если данных нет, сходите в бд и отдайте их оттуда;
перед тем как отдать, положите их в кэш перед возвратом, чтобы в следующий раз вытащить быстрее.
Кэшируем сложные (=долго) выбираемые данные из базы
Всё то же самое актуально и для тех выборок, где у нас агрегация/куча фильтров = сложные запросы, которые заставляют базу немного (или много) поработать. Делаем всё то же самое, только перед хождением в БД, синхронизуруйте (как в java) процесс заполнения кэша - ставьте мьютекс (хотя бы в тот же самый кэш) и освобождайте после заполнения кэша - чтобы несколько клиентов не запустили процесс генерации кэша (= несколько сложных запросов к бд).
Кэшируем всё
Если необходимо ещё разгрузить БД, для не слишком критичных к актуальности данных (какие-нибудь подборки новостей на главной, список доступных предложений для пользователя и т.д) можно скомбинировать два приёма кэширования, чтобы убрать большое количество нагрузки:
Выполняем сложный запрос к БД с фильтрами, получаем id каких-то элементов из базы; Кэшируем (можно даже в shm - см. след. раздел); Если полученный кусок общий для всех пользователей, то ещё лучше. Но зачастую такие списки привязаны (= т.к. фильтруются по нему) к пользователю, т.е кэш не переиспользуется между пользователями - ставим небольшое время жизни;
Выбираем сами элементы из базы по id из п.1; Кэшируем; Здесь данные не привязаны к пользователю и кэш будет общий для всех пользователей. Ставим время жизни больше (+ добавляем инвалидацию при редактировании элементов в админ-панели);
В следующих запросах (пока кэш живой) данные будут выбираться уже из кэша, минуя БД.
-
В качестве задачи со звёздочкой, можно на п.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 (можно ожидать как минимум в ~полтора раза), уменьшение среднего времени ответа.
FanatPHP
Если честно, я два раза читал в заголовке "большие", и только на третий увидел, что речь про "небольшие" приложения. И как-то картинка резко поменялась.
Меня все время что-то смущало в этом тексте, но я не мог понять — что. И вот это несоответствие (называть распараллеленную на 10 серверов систему "небольшой" — это скромность, граничащая и
идиотизмомтроллингом) прямо щёлкнуло. Я понял, что в этом тексте не так: в нем перемешаны советы из, вроде бы, личного опыта, с очевидными байками "из интернета". И начала закрадываться мысль, что всё это написано нашим модным другом, Птицей-говоруном, которая отличается умом и сообразительностью, но находится в сложных отношениях с объективной реальностью.И при этом в статье ни слова про опкод кэш и способы его оптимизации, прелоадинг. С одной стороны я понимаю, что статья "общая", но с другой она зачем-то ведь размещена из всех языковых хабов только в РНР?
wapmorgan Автор
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 - я не лезу, не любят они этого.