Оптимизация PHP-приложения
07-04-2024Время чтения ~ 10 мин.PHP 854
![](https://maxsite.org/website/uploads/pages/874-rocket.webp)
Главная проблема современных php-приложений — их скорость. Они очень медленные. Для Albireo CMS я задался вопросом: насколько быстро она может работать на большом количестве файлов? Поскольку все записи в системе хранятся в файлах, то что будет, если довести количество файлов до 1000? А если 10000?
Начну издали.
Не важно какое устройство системы. Она где-то хранит данные. Если данных много, то они будут получаться и обрабатываться долго. Например если на сайте 10 рубрик, то они будут выведены очень быстро. Но что если это 100 рубрик? Скорость уже упадёт и совсем будет печалька, если эти рубрики имеют иерархию: технически это требует использования рекурсии, а значит это на несколько порядков увеличивает время обработки.
С файловой CMS всегда есть опасение, что у неё есть некий предел, когда количество файлов станет настолько большим, что система не сможет их быстро обрабатывать.
Проясню. Перед тем, как что-то выдать, система должна получить в своё распоряжение всё что есть. То есть элементарно пройтись по всем существующим файлам, выбрать из них нужные данные, а уже потом этими данными манипулировать.
Таким образом есть некий цикл, который обходит указанные каталоги, включая их подкаталоги, и время на обработку будет напрямую зависеть от количества файлов. Аппаратные условия мы считаем постоянными.
Чтобы всё верно оценить в системе следует полностью отключить кэширование. Кэширование — полезная вещь, но она скрывает говнокод. Я уверен, что кэширование полезно, но только после того, когда выполнена вся оптимизация.
И речь не о микрооптимизации.
При работе с сайтом всегда полезно держать индикатор времени выполнения скрипта и затраченную php-память. Для баз хорошо выводить ещё и кол-во sql-запросов. Эта информация будет намного полезней всяких внешних отладчиков. В процессе работы с сайтом всегда есть нюансы, когда одна страница показывает нормальные результаты, а другая — повышенные. На это можно сразу обратить внимание и подумать как исправить.
Для оценки проблемных мест, следует довести условия тестирования до экстремальных. 83% разработчиков не тестируют свой код. И речь не про юнит-тесты, а про то, что разработчики не проверяют свои творения в боевых условиях. То есть сидит разраб на игровом компе, где всё лихо крутится и он думает, что у всех также. Хер там!
Так вот систему нужно нагрузить таким количеством данных, чтобы они многократно перекрыли предполагаемую типовую.
В Albireo CMS — каждая страница — это один файл. Этот мой сайт работает с 2006 года и за 18 лет здесь было опубликовано 874 записи. То есть для меня нормальная публикация — это примерно 50 записей в год (на самом деле это не так).
Для других норма может быть выше, я могу предположить, что 350 записей в год, при условии каждодневной публикации. Вряд ли кто-то пишет больше. То есть 1000 записей — это блог за примерно 3 года.
Чтобы оценить производительность системы, следует её нагрузить. Я с помощью Python набросал небольшую программку, которая сгенерировала мне 1000 фиктивных файлов в формате Albireo CMS со случайными данными и текстом. Размер файла также имеет значение и я решил, что пусть будет примерно 50Кб. Это большой текст, который вполне себе тянет на лонгрид.
Изначально было 1000 файлов, которые показали, что система показывает примерно одно и и тоже время работы несколько секунд, поэтому это неинформативно.
Это на моём стареньком ноуте, на сервере скорость примерно в 100 раз выше.
Тогда я сделал 10000 файлов и запустил систему. Мне пришлось ждать аж 78 секунд, пока отобразилась главная страница. Вот теперь с этим можно работать. Экстрим в чистом виде.
Дальше мы ищем узкие места. Очевидно, что дело в обсчёте всех файлов. Я использую стандартную связку RecursiveDirectoryIterator
+ RecursiveIteratorIterator
и сразу же сортировку в виде PageSortedIterator
. Оказалось всё дело именно в этом участке кода. Вместо этого я написал код на базе scandir()
и теперь скорость работы систему увеличилась примерно в 10 раз — время сократилось до 7-9 секунд.
Проблемной зоной, как я выяснил, оказалась сортировка в процессе работы итератора RecursiveDirectoryIterator
. То есть сам по себе итератор работает быстро, но если к нему прикрутить сортировку, то его производительность резко падает. Поэтому намного эффективней вначале получить все файлы, а уже потом отсортировать массив. Ловите первый совет.
Второй — лучше старый scandir()
, чем новомодный RecursiveDirectoryIterator
Неправильный путь — это было бы забить на всё это и просто закешировать результат сканирования — даже если это десятки секунд — их заметит только первый посетитель. Вот так делать не нужно. Правильно — это оптимизировать код, а уже потом кэшировать.
Следующий этап — ещё ускорить работу системы. Потому что получить список файлов, даже 10 тысяч, как оказалось, не так и долго. Значит следующий тормоз — их обсчёт.
В Albireo CMS (и Framework) служебная часть страницы размещается в секции /** **/
. То есть грузится весь файл с помощью file_get_contents()
, а потом в этом тексте регуляркой выбирается служебная часть.
Файлы, как я указал, достаточно большие для текста — по 50Кб. На 10000 — это приличный объём итераций (примерно 470Мб). Поэтому я изменил код на более сложный, который считывает файл построчно fgets()
. То есть теперь система считает только небольшую служебную часть, без основного контента. Время отдачи сократилось примерно до 5-7 секунд.
Неплохой результат для такого объёма данных и улучшить его уже невозможно, поскольку упираемся в возможности PHP и железа.
Теперь прикрутим кэш. Кэш может быть двух видов — на текстовых файлах и в базе.
Текстовый файл как правило — это просто серилизованный массив, чтобы не париться с форматом хранения php-данных. При 10000 файлах размер кэша оказался примерно 13Мб. Довольно большой размер. Сложность текстового кэша в том, что у него нет гарантированных транзакций.
Запись данных в файл — всегда медленная операция. Может быть ситуация, когда кэша нет, но есть два параллельных пользователя, которые запустят процесс построения кэша. В итоге получится два потока записи в один файл, а PHP это никак не контролирует и в итоге, файл будет повреждён. А это в свою очередь тянет другие проблемы.
Поэтому я сделал кэш на sqlite. Тот же файл, только в виде базы данных.
Sqlite, в свою очередь, требует немного больше ресурсов на подключение, поскольку в PHP это отдельная библиотека.
Если сравнивать скорость отдачи кэша, то получаются интересные цифры. Скорость примерно одинакова — примерно 1.3 секунды, но вот потребления php-памяти резко отличается. В файловым кэшем это почти 32Мб, а с sqlite — примерно 4Мб.
Если же сильно уменьшить количество файлов, например до 100, то sqlite всё также работает примерно около секунды, а файловый кэш работает уже меньше, чем за 0.1 секунду. То есть файловый кэш работает хорошо на небольших объемах, а sqlite сохраняет стабильность во всех случаях. Это говорит о том, что время тратится скорее на подключение самой библиотеки.
В Albireo CMS кэш устроен как два уровня. Второй уровень — это когда идёт очень много запросов к сайту и в этом случае кэш не проверяется, а сразу берётся как есть. Это защита от DDOS-атак. С включенным L2, скорость отдачи страниц держится где-то на уровне 0.1-0.2 секунды. Это на 10000 файлах.
Каков вывод?
За счёт экстремального тестирования мне удалось оптимизировать алгоритмы системы и позволить работать с большим количеством файлов. На реальном сервере, где мощные процессоры, память и SSD-диски, скорость сайта не превышает 0.1 секунду. 10000 файлов — это примерно 28 лет каждодневных публикаций на сайте.
Оптимизация алгоритмов всегда важна, но в системе общая производительность складывается из каждой её части. По простому — если какая-то функция тормозит, то это это будет означать и тормоза всей системы.
В программировании единица кода — это функция. Поэтому оптимизировать следует работу каждой функции. Первый шаг — это выкинуть всё то, что никак не связано с получением итогового результата. Второй — улучшить алгоритм. Третий использовать кэширование.
На уровне функций кэширование требуется не всегда. Есть всего несколько признаков, когда следует прибегать к кэшированию.
Самый очевидный — это работа с файлами. Простой пример, функция, которая отдаёт значение из конфигурационного файла. Пользователь может использовать её многократно и если не предусмотреть кэширование, то это будет эквивалентно многократному обращению к диску. Зачем?
Вместо этого нужно использовать static-кэш. Делается static переменная равная null и после выполнения функции, она заполняется итоговым значением. При следующем обращении к функции, сверяем эту переменную с null и если она уже что-то содержит, сразу отдаём результат. Таким образом обращение к диску сводится к одной загрузке файла.
Следующий случай — обращение к файлам в цикле. Это не очевидная проблема, но она есть. Например есть tpl-шаблонизатор, который запускается в foreach-цикле — это типовое использование. То есть каждый раз будет подключаться tpl-файл.
Поскольку мы знаем, что работа функции будет скорее всего в цикле, то нам будет достаточно закэшировать только последний результат функции.
Для этого мы используем static- переменные $old_file
и $old_fcontent
и просто сверяем их с входящим $file
. Если они равны, значит это повтор файла (что характерно для циклов) и мы сразу отдаём старый контент файла. Таким образом мы опять сократили обращение к файлу до одного раза в цикле.
Другая причина прибегнуть к кэшированию функции — если в ней используется много регулярок. На самом деле каждая регулярка по сути это цикл со сложным обсчётом, но поскольку он работает на уровне PHP, то выполняется очень быстро. Проблема может возникнуть только, если объем обрабатываемого текста очень большой, а регулярок много. Таким образом кэш строится по хэшу входящих данных. Что-то вроде такого:
$_content_hash = substr(hash('md5', $_Content), 0, 20);
Дальше просто сохраняем итог обсчёта в массиве с ключем в виде хэша и тем самым позволяем функции хранить сразу много результатов обсчёта. Это, конечно, увеличивает потребляемую php-память, но это разумный обмен.
Ещё одной причиной использования кэша функций — это рекурсия. Рекурсия — это не сколько сложно, сколько неочевидно. Поэтому лучше сразу предусмотреть кеширование. Если же это рекурсия для обращения к базе с кучей sql-запросов, то кэш лучше всего использовать внешний. То есть кэш будет работать не только с одним посетителем.
Рекурсии лучше вообще избегать. Часто мы используем рекурсию там, где от неё в принципе можно отказаться.
Например рекурсивный обход каталогов. Ок, мы получили многомерный ассоциативный массив. Казалось бы классно, но на самом деле — это большая проблема, поскольку работать с таким массивом нужно опять через рекурсию.
Обычно рекурсия — это построение дерева. Но, если включить мозги, то нужно двигаться от итоговой задачи. Например зачем нужно дерево каталогов? Чтобы его вывести на сайте. Таким образом нам не нужно дерево, вполне достаточно обойтись одномерным массивом, где ключ — это имя файла/каталога. Для вывода на сайте, то есть формирование html-кода нам нужен просто уровень вложенности каждого каталога. Как это посчитать? Ну просто же — кол-во символов DIRECTORY_SEPARATOR
.
Часто стоит задача формирования html-кода UL/LI-списка. На HTML нам нужна сложная вложенность этих тэгов. И почти всегда все бьются над этой задачкой, потому что она решается только через рекурсию. То есть вначале рекурсия чтобы сформировать сложный массив, потом рекурсия, чтобы его вывести.
Лично я отказался от этой херни в пользу margin-left: <?= $level * 30 ?>px
. Визуально хер кто отличит от вложеного списка.
Таким образом алгоритм функции не всегда очевидный, но если двигаться от конкретной задачи (что хотим увидеть в итоге), то многие вещи можно сильно упростить.
К обходу каталогов. На самом деле не всегда нужна рекурсия, если предположить, что есть некое ограничение на уровень вложенности. Например есть только три уровня. Чтобы не создавать себе проблем делаем примерно так:
foreach (glob('lib/*.php') as $file) require $file; foreach (glob('lib/*/*.php') as $file) require $file; foreach (glob('lib/*/*/*.php') as $file) require $file;
Три строчки которые заменят килотонну сторонней библиотеки.
Если бы программисты аккуратно подходили к свой работе и тестировали функции, то большинство php-приложений работали бы быстро. На практике же очень многие тупят и не понимают зачем вообще пишут код. Фундаментально есть два уровня ограничений — уровень языка и уровень системы.
Уровень языка — это уровень, когда разраб использует возможности языка программирования в «чистом» виде. Есть некая встроенная функция — это и есть минимальный «кирпичик». С его помощью можно строить очень тонкие и быстрые системы.
Есть мнение, что это неэффективно с точки зрения трудозатрат. Дескать зачем изобретать велосипед. В принципе это разумно, но тогда следует принять и точку зрения, что это велосипед должен работать корректно и эффективно. Если же этот велосипед сделан из говна, пусть и популярного, то зачем его использовать в своем проекте? Одна капля говна может испортить бочку мёда.
Второе ограничение — это уровень системы. То есть программист просто вынужден работать с той системой, которую выбрал клиент. В итоге часто программист бьётся с задачей, пытаясь её решить методами этой системы. Он лезет в документацию, находит подходящую функцию, видит, что она не совсем то что нужно, пытается это как-то обойти и т.д.
На самом деле, проще решить задачу, обойдя ограничения системы. Потому что система создаёт искусственные ограничения, хотя на самом деле максимальное ограничение — это возможности самого языка программирования. То есть можно написать свой код, который не будет использовать код системы. Понятно, что если в систему уже есть готовое решение, то нужно им воспользоваться. Но если нет, то нет смысла переживать — пишем на обычном PHP, ни на кого не оглядываясь.
Слава Украине! Смерть рашистам!
Цікава стаття з погляду теорії. Я своєю чергою вигадував граблі створюючи локальні текстові кеші. Наприклад, читаємо кілька rss-джерел, генеруємо з них текстовий зведений файл-кеш (тут як фантазія спрацює - хоч одразу в блоки html). Далі віддаємо цей локальний кеш кожного разу при виводі у браузер, оновлюючи його раз на пів години, як заманеться.
Або ще. Обходимо якусь папку, скажімо в Upload, куди юзер (та хоч адмін), періодично додає-видаляє файли pdf, jpg, не суть. Тоді створення локального кешу папки в рази прискорює генерацію виводу сторінки, причому сам кеш може бути json, в якому вже є інфа, автоматом зчитана при формуванні кешу (розмір, мета, дата розміщення тощо). Це працює і для кешу мініатюр. Головне в процесі виводу - кеш читаємо завжди, а при його відсутності - генеруємо хоч обходом, хоч сторонньою бібліотекою, хоч по закінченню строку життя.
Єдиний випадок, коли такий кеш спрацював погано - генерація денної статистики для окремої сторінки, коли неминуче виникла ситуація одночасного доступу до текстовика при додаванні запису. Це добре описано в статті, в частині гарантованих транзакцій. В моєму випадку, краш настав доволі бистро - при навантаженні лише у десяток звернень за пів години, наприклад. Такий собі новачковий експеримент.
Ну смотри. Когда ты получаешь rss, то это обращение к другому серверу, а значит могут быть тормоза. Поэтому итог rss и нужно в кэш кидать. Но когда читаешь папку на своём сервере, то нет смысла её кэшировать, потому что это очень быстрая и легкая операция для сервера.
Другое дело, если тебе нужно считать данные их файлов. Тогда да, может и кэш поможет ускориться. Но вообще, я бы рекомендовал проверять эффективность кэша хотя бы по общей статистике времени генерации сайта и потребляемой памяти. Статистика в цифрах избавляет от субъективности.