Несколько способов оптимизации производительности больших backend PHP

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

  • увеличить мощности сервера; заказать ещё серверов;

  • вынести на разные сервера приложение/БД/кэш;

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

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

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

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

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

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

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

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

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

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

pgbouncer устанавливаем перед всеми БД. Некоторые avito</p>" data-abbr="техногиганты">техногиганты используют пулинг как на стороне приложения (т.е сразу после приложения и проксирует к нескольким БД), так и на стороне БД (т.е прямо перед БД и ~ лимитирует запросы к конкретной БД). Настраиваем количество коннектов исходя из оценки мощностей серверов, после ряда тестов.

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

Если у нас так выходит, что после установки пулера запросов у нас кончаются свободные коннекты, но при этом нагрузка базы не стремится к 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 вместо сети. Что это можно быть:

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

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

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

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

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

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

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

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

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

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

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

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

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

Источник: https://habr.com/ru/articles/744202/


Интересные статьи

Интересные статьи

Дисклеймер: B первую очередь материал будет интересен тем, кто уже значительное время занимается тестированием пользовательского интерфейса и не знает, как подойти к тестированию backend части приложе...
Привет! Меня зовут Кирилл Юрков, я SRE Team Lead в Samokat.tech. Уже более десяти лет занимаюсь ускорением и проблемами производительности, а также нагрузочным тестированием. Отвечая на вопрос, ч...
В прошлой статье я писал о запуске Alpaca на Эльбрусе. На момент написания той статьи оптимизации под Эльбрус не проводились. Однако теперь, благодаря стараниям @troosh можем протестировать Эльбрус уж...
Если вы пишете на C++, то скорее всего сталкивались с тем, что компиляция, кодогенерация и компоновка проектов, написанных на нём, занимают время и с развитием проекта начинают мешать как CI конвейеру...
В самом сердце проекта Actual, который предназначен для управления персональными финансами, лежит система синхронизации данных собственной разработки. Недавно я реализовал в проекте п...