Оптимизация PHP-скриптов: практические советы

coding

В этой статье речь пойдет о том, как оптимизировать какой-нибудь PHP-скрипт, чтобы он выполнялся как можно быстрее, затрачивая как можно меньше процессорного времени. Также я опишу некоторые простые техники оптимизации PHP-кода, которые я лично использовал, и которые принесли ощутимый результат. Я коснусь только вопроса оптимизации непосредственно PHP-кода и языка (без всяких оптимизаций запросов к БД, дополнительных расширений, кэширующих опкоды и т.д.). У меня такая задача появилась после написания одной из первых версий движка для проведения психологических тестов (расположен здесь). Скрипт был размещен на облачном хостинге, где веб-мастер платит деньги соразмерно величине израсходованных ресурсов, поэтому в моих интересах было оптимизировать скрипт таким образом, чтобы максимально снизить нагрузку.

Сразу скажу, что заниматься преждевременной оптимизацией не нужно. Стоит задуматься о переработке кода только в следующих случаях:
[+] ваш скрипт подолгу выполняется (например, делает какие-то занимающие длительное время операции в цикле);
[+] ваш скрипт очень часто выполняется (это был мой случай - иногда в день тест проходили порядка десяти тысяч человек - а это около миллиона обращений к скрипту в сутки);
[+] от используемых скриптом ресурсов зависит то, сколько денег вы платите. Если скрипт выполняется на обычном виртуальном хостинге и не превышает лимит нагрузки на процессор, то, скорее всего, можно и не париться;
[+] вы собираетесь массово распространять скрипт, и его будет использовать в конечном итоге множество людей, которым критично, сколько ресурсов скрипт потребляет.

Для анализа производительности PHP-скриптов мы будем использовать XDebug. Это расширение для Zend хорошо описано на многих расурсах в Интернете (в том числе и на русском языке), поэтому не буду подробно останавливаться на этом. Вкратце - это расширение позволяет выполнять отладку PHP-скриптов, отслеживать исключительные ситуации в них и профилировать их по времени выполнения.

Далее я кратко опишу, как устанавливается это расширение на Windows (и веб-сервер Apache), так как сам занимался профилированием именно под Windows. Желающие сделать это под Linux без проблем смогут найти информацию по установке в сети. Итак, скачиваем подходящий бинарный дистрибутив XDebug с официального сайта. Кладем скачанную DLL'ку в директорию, содержащую все экстеншены PHP (например, C:\Program Files\Apache Software Foundation\Apache2.2\php5\ext). Далее открываем php.ini (он лежит в директории Windows) и в секции настроек [PHP] (например, в самый ее конец), дописываем следующее:

Ни в коем случае нельзя включать XDebug на рабочем сервере, только на отладочном, потому что после его включения все PHP-скрипты начнут выполняться очень медленно и производить массу отладочной информации.

Наконец, перезапускаем Web-сервер. Всё готово для профилирования. Обращаемся к интересующему нас скрипту. После завершения выполнения скрипта в каталоге, указанном в настройке xdebug.profiler_output_dir, создастся файл с именем вида cachegrind.out.1381302243-C__Program_Files_Apache_Software_Foundation_Apache2.2_htdocs_index_php. Он-то нас и интересует. Для того, чтобы просмотреть его содержимое в читаемом виде, нам потребуется программа WinCacheGrind. Она не требует установки. Запускаем ее и открываем в ней произведенный XDebug'ом файл. Вот как выглядит интерфейс этой программы (на примере исполнения главного файла движка теста старой версии 0.11):

interface

Мы видим список вызовов функций, определенных в скрипте (и в скриптах, которые подключаются к нему) и нативных функций PHP. Программа покажет, сколько в среднем занимает вызов каждой функции (кроме разве что совсем быстрых) включая все внутренние вызовы (колонка Avg. Cum.) и учитывая только код самой функции без внутренних вызовов (Avg. Self), сколько вызовов той или иной функции было совершено (Calls), сколько в сумме времени заняло выполнение той или иной функции (Total Cum., Total Self). Нас в большей степени будут интересовать колонки Avg. Cum., Avg. Self и Calls. Помимо статистики вызовов, можно увидеть список конкретных мест вызовов со стектрейсами для всех функций.

Для начала отсортируем все вызовы по колонке Total Self, получив в итоге список функций, которые в сумме выполнялись дольше всего. Их-то код и следует перерабатывать.

total_self

Далее отсортируем по колонке Calls. Если та или иная функция была вызвана много раз, вероятно, стоит уменьшить количество ее вызовов (так как на сам вызов тоже затрачивается время).

calls

Далее я приведу несколько реальных примеров вызовов, которые были оптимизированы в моем движке.

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

$this->scales - это массив, содержащий элементы типа TestScale (характеристика). Соответственно, функция проверяет, есть ли в массиве характеристика с заданным идентификатором. Для оптимизации этой функции я сделал ключом массива $this->scales идентификатор хранящейся в соответствующем значении характеристики, после чего функция TestPreferences::scaleExists() стала выглядеть так:

Кроме того, эта функция вызывалась всего из одного места, и теперь, после того, как она привелась к единственной строке кода, ее вообще стало возможным убрать.

Далее по количеству затраченного времени идет TestPreferences::processTestCondition(). Эта функция обрабатывала список итоговых результатов теста, и ее оказалось возможным просто убрать при загрузке главной страницы со списком тестов, как и TestPreferences::loadQuestion() (загружает вопрос), TestPreferences::loadAdditionalQuestion() (загружает дополнительный вопрос) и TestPreferences::readScales() (загружает характеристику) и многие другие вспомогательные функции. В последней версии движка эти функции вызываются только при прохождении теста.

Еще один интересный случай - функция TestArgs::isNumber(). Она проверяет, является ли аргумент, переданный ей, числом. Вот ее изначальный код:

А вот - оптимизированный вариант:

Об этой оптимизации я подробнее расскажу дальше. А пока что - вот скриншот анализа новой версии движка теста (всё то же обращение к главной странице):

new_engine_1

Итого получаем: старая версия при отображении главной страницы затрачивает 1066 миллисекунд (разумеется, время преувеличено, так как скрипт запускался под XDebug, но порядок понятен), а новая - всего 53 миллисекунды! Старая версия при этом делает 23608 вызовов функций, а новая - всего 499. Разумеется, сравнение проведено при одинаковом наборе плагинов и тестов.

Теперь посмотрим на скриншоты анализа старой и новой версии движка, когда человек нажимает кнопку "Начать тест":

old_engine_2

new_engine_2

Как видно, результат улучшился примерно в два раза: 1003 миллисекунды против 1784-х и 23096 вызовов против 41073. Был сильно переработан механизм работы с плагинами (функция TestPluginManager::NotifyPlugins() заняла 578 миллисекунд за почти 500 вызовов в старой версии, а в новой - 250 вызовов и 31 миллисекунду). Эта функция предназначена для оповещения загруженных плагинов о различных событиях, происходящих в движке (например, "тест загружен", "пользователь передал ответы" и т.д.) Соответственно, она вызывает по очереди различные функции в каждом плагине в зависимости от типа события. Имеется несколько типов функций (ничего не возвращающая, возвращающая значение и фильтрующая). В первых версиях движка в функции TestPluginManager::NotifyPlugins() имелся объемный switch-case по типу функции, а дальше в каждом case шел вызов нужной функции для каждого плагина. В новой версии весь код, осуществляющий вызов, был перенесен внутрь классов, представляющих функции. В результате switch-case был убран, и некоторые блоки кода стали выполняться единожды, хотя раньше вызывались в цикле для каждого плагина (например, формирование аргументов и имени вызываемой функции).

Где было возможно, добавилось кэширование вызовов, поэтому плагины стали дергаться гораздо реже. Часть функционала была перенесена из функции TestPluginManager::NotifyPlugins() в TestPluginFunction::callPlugins() (раньше эта функция называлась TestPluginFunction::call()).

Кроме того, была изменена внутренняя структура хранения вопросов и ответов пользователя, в результате чего получилось убрать подолгу выполнявшуюся функцию TestSession::findCurrentQuestionRecursively(), а размер файла сессии при этом уменьшился в несколько раз.

Кстати, можно заметить, что некоторые функции стали выполняться чуть дольше, но это связано с тем, что в новой версии движка появился различный новый функционал.

Были проведены и многие другие оптимизации помимо тех, которые я привел, и это принесло однозначно положительный результат, позволив значительно уменьшить расход ресурсов хостинга, при этом еще и нарастив возможности движка!

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

1. В самых высоконагруженных скриптах есть смысл отказаться от использования ООП. Это сильно замедляет выполнение скриптов. Иногда можно вместо классов применять массивы - это быстрее. Вызов метода класса всегда дольше, чем вызов статического метода, что в свою очередь дольше, чем вызов обычной функции. Обращение к члену класса всегда дольше, чем обращение к статическому члену класса, что, в свою очередь, дольше, чем обращение к обычной переменной. Я от ООП не отказался (потому что это достаточно удобно), но кое-где ограничил его применение.

2. Если ООП используется, то есть смысл отказаться от геттеров и сеттеров в самых простых случаях. Обращение к членам класса напрямую всегда быстрее, чем вызов методов.

3. Следует везде, где возможно, использовать строгие сравнения (=== и !==) вместо нестрогих (== и !=). Если сравниваются две переменные, типы которых должны быть одинаковы, следует использовать строгое сравнение. Это позволит избежать массы ненужных приведений типов.

4. Избегайте регулярных выражений, проверяющих те или иные данные, в тех местах, где можно обойтись и без них. Такой пример я привел выше (оптимизация метода TestArgs::isNumber()). В качестве более быстрой альтернативы регулярным выражениям можно привести функции filter_var, ctype_digit, checkdate и т.д.

5. Избегайте использования type hinting в функциях, которые часто вызываются. Проверка типов переданных аргументов замедляет их вызов.

6. Применяйте конкатенацию и строки в одинарных кавычках вместо интерполяции. $a = "test $b" гораздо медленнее, чем $a = 'test ' . $b.

7. Выносите функции из циклов, чтобы они не выполнялись каждую итерацию. Очень часто вижу подобный код:

Который лучше было бы написать так:

8. Используйте готовые функции PHP для выполнения каких-либо задач, и не пишите собственные. Например, для считывания файла функция file_get_contents() или file() будет гораздо быстрее, чем собственная функция из fopen() + fread().

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

10. Используйте везде преинкремент, если не требуется постинкремент.

На этом список заметок по оптимизации я завершаю. Однако, я привел лишь те советы, которым следовал сам, и которые принесли определенный результат. Если есть желание углубиться в тему оптимизации PHP-скриптов без их рефакторинга и попробовать еще массу техник (кстати, не факт, что все работают, многие уже устарели), можно прочитать эту статью.

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

Оптимизация PHP-скриптов: практические советы: 22 комментария

  1. Спасибо за статью.
    По поводу первого пункта не совсем согласен. Если отказаться от ООП, то скорость выполнения скорее всего не сильно поднимется, а скорость разработки может сильно увеличиться.
    Последний пункт доставил ) Думаю, этому вообще не стоит уделять внимания.
    Ничего не написано про работу с внешними ресурсами (БД). Оптимизация запросов к БД или уменьшение их количества может существенно увеличить скорость выполнения скриптов.
    Также кэширование опкодов может увеличить производительность скриптов в целом и использование nginx.

    1. Рассказать про итераторы в крестах и про разницу ++it и it++, на которую ругается почти любой статический анализатор?
      Оптимизация запросов к БД или уменьшение их кол-ва - вопрос скорее архитектурный и сильно связан со структурой БД и умением писать запросы, а не с PHP непосредственно.
      Кэширование и nginx - опять же, скорее вопрос администрирования и рабочего окружения.

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

      1. Насчет итераторов C++ - таки да, я не знал об этой существенной разнице в работе пре/постинкремента, о которой прочитал. Тогда для единого стиля кода действительно лучше использовать преинкремент.

          1. На самом деле в наши дни все компиляторы уже это оптимизируют, но если будет написан какой-нибудь хитрый итератор, то могут и не суметь.

    2. Про БД, кеширование опкодов и прочее я не рассказал, потому что это не относится напрямую к языку PHP. Если уж на то пошло, то я даже не рассказал про буферизацию вывода в PHP и про то, как она может ускорить выполнение скриптов. Все аспекты вопроса оптимизации затронуть сложно, и я описал то, что применял.

      А ООП в PHP действительно прилично замедляет выполнение скриптов. Без ООП, возможно, большие проекты разрабатывать не так удобно будет, но в некоторых критичных местах от него можно и отказаться. В целом, в PHP ООП не настолько прокачанное, как в языках типа C#, C++ или Java, а местами даже ущербное.

    1. Выучить может и стоит, хотя бы ради собственного развития. Да и речь в статье идет только о скриптах, которые требуют оптимизации. К слову, в движке теста я ООП не ликвидировал.

    1. Да ну? 0.02 - 0.03 секунды на отображение списка тестов и 0.03 - 0.05 секунд на каждый вопрос при прохождении теста.

      1. У меня эта штука нагроможденная до нельзя аджаксами открывается пол часа!

        1. Это проблема в тормознутом браузере/компе, а статья вообще-то про оптимизацию серверной части, а не клиентской.

        1. Сюда? Это вообще-то наш блог изначально (вообще, если совсем изначально, то блог Kaimi), и сюда больше никто не пишет.

  2. Добрый день! Кто может настроить и оптимизировать сервер за $, чтобы выдерживал пиковую нагрузку?

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *