Узкие места в сценариях на PHP. Часть 1: Работа с базой данных
На данный момент PHP занимает лидирующую позицию в числе инструментов разработки web-приложений. И кроме всех прочих факторов, одной из причин, объясняющих такое положение вещей, является возможность создавать на PHP достаточно быстрые и динамические приложения, скорость которых удовлетворяет потребностям Web. В большинстве случаев высокая скорость работы Web-приложения является наиболее приоритетным требованием, степень реализации которого и определяет общий успех всего проекта. Поэтому, данной статьей открываю цикл по поиску узких мест в приложениях, написанных на PHP. Скажу сразу, что я не гуру в области узких мест, но кое что вам расскажу. Итак, давайте сперва определим о чем здесь будет идти речь. Узкие места - это части кода, которые являются основными потребителями ресурсов, используемых во время выполнения. Тоесть те части кода, на которые тратиться на много больше ресурсов, по сравнению с другими, потенциально равными по потреблению ресурсов, частями кода. Заметьте, под узкими местами я имею в виду именно части кода на PHP. Чтобы было понятнее, давайте немного отвлечемся и рассмотрим, какие узкие места еще могут присутствовать при использовании Web-приложения. С момента, когда пользователь запросил страницу в своем чудесном браузере и до момента, когда он увидел эту страницу, результат проходит через некоторое количество узких мест. Кратко перечислим их по порядку: - Первое, что делает браузер - устанавливает соединение с Web-сервером (здесь я не учитываю возможную задержку на получение ip-адреса сервера на сервере доменных имен, потому что это случается довольно редко и к PHP не имеет вообще никакого отношения). Соединение устанавливается в течение долей секунды, но если сервер слишком перегружен, то соединение может потребовать больше времени.
- После получения запроса сервер генерирует ответ. Это время между получением запроса Web-сервером и началом генерации ответа, называется временем обработки сценария. В это время браузер все еще ждет ответа от сервера.
- После генерации ответа сервером, сервер начинает передавать данные ответа браузеру. Период от начала передачи этих данных до завершения этой операции называется временем доставки. Во время генерации ответа могут происходить целые серии HTTP-запросов, например, если страница содержит множество изображений. Поэтому с каждым таким запросом может возникать определенная задержка, даже если сценарий выполнился достаточно быстро. Такая временная задержка может возникнуть в двух местах: либо в сети, используемой для передачи и получения данных с сервера, либо на самом сервере из-за его ограниченных возможностей, связанных с генерацией ответов на полученные запросы. Во втором случае причиной замедления может оказаться большая загруженность сервера, неудачная настройка модуля PHP (необязательно самого сценария) и другие выполняемые на сервере процессы. Литература предлагает искать ответы на эти вопросы в анализе использования оперативной памяти и центрального процессора.
В данной статье я хочу обратить внимание на узкие места, появляющиеся во время обработки сценария, поэтому шаги 1 и 3 я опускаю. Давайте теперь подробнее рассмотрим шаг 2. Во время генерации ответа сервером происходит выполнение сценария на PHP. Этот процесс упрощенно можно описать следующей последовательностью действий: - Считывание входных параметров
- Использование значений входных параметров и принятие решений на основании этих данных
- Генерация выходных данных
Действия 1 и 3 являются неотъемлемой частью модуля PHP и Web-сервера и поэтому действительно узкие места, над которыми можно поработать скапливаются в момент 2, тоесть в момент реальной обработки данных. Большинство источников выделяют три категории узких мест, приходящихся на момент 2: - Недостаточная мощность оборудования. При этом используемое аппаратное обеспечение является не очень новым или работает в перегруженном режиме. Либо не грамотно или интенсивно используются программные средства, которые могут потреблять много аппаратных ресурсов. Например, используя средства библиотеки GD для PHP.
- Неоптимальные и неэффективные алгоритмы, приводящие, в лучшем случае, к длительному времени выполнения сценария.
- Внешние узкие места. Это может быть работа с базой данных, обращение к жесткому или сетевому диску, запросы HTTP или FTP, обращение к объектам через протоколы SOAP и RPC, механизмы взаимодействия с сокетами и т. п.
Можно найти еще много других категорий, но я привожу наиболее явные. А для начала я хотел бы рассмотреть такое внешнее узкое место как работа с базой данных. Практически каждое Web-приложение использует базу данных. В нашем контексте производительности, база данных, в большинстве случаев, - это те 20% Парето, которые выполняют основные операции по замедлению работы скрипта. И потому как в большинстве случаев это так, то следствием из закона Амдала можно утвердить факт: максимальное уменьшение времени выполнения запросов к базе данных максимально увеличивает производительность Web-приложения на PHP. Задержки происходят во все моменты обращения к СУБД. Основные потери связаны с длительным выполнение SQL-запросов, особенно тех, результаты которых предназначены для отображения пользователю. Многие авторитетные авторы утверждают, что если запрос длиться более половины секунды, то он должен тщательно анализироваться, а также, если суммарное время всех обращений к базе данных превышает три секунды, то это не хорошо. Конечно, эти параметры довольно приблизительны и конкретные их значения зависят от особенностей Web-приложения, но все же их можно взять за максимальный эталон. Чтобы определить длительность какого-либо обращения к базе данных, его можно просто измерить. Для начала маленький пример: test_db_request.php Listing №1 (PHP) $query = 'select * from users'; mysql_connect('localhost', 'root', '123'); mysql_select_db('test'); $StartTime = microtime(TRUE); mysql_query($query); $EndTime = microtime(TRUE); echo 'Запрос "' . $query . '" выполнен за ' . number_format($EndTime - $StartTime, 6) . ' секунд';
В данном простейшем случае используется функция microtime c параметром TRUE, которая возвращает временную метку с микросекундами. Этот сценарий выведет на экран что-то наподобие: echo Запрос "select * from users" выполнен за 0.000896 секунд Таким образом, вы можете измерить время, за которое PHP выполнил обращение к базе данных. Но, обновив страницу, вы увидите, что число несколько изменилось. Поэтому правильнее будет провести несколько таких измерений для установки среднего результата. test_db_request.php Listing №2 (PHP) $query = 'select * from users'; mysql_connect('localhost', 'root', '123'); mysql_select_db('test'); for($i = 0; $i < 10000; $i++) { $StartTime = microtime(TRUE); mysql_query($query); $TotalTime += microtime(TRUE) - $StartTime; } echo 'Запрос "' . $query . '" выполнен за ' . number_format($TotalTime/10000, 6) . ' секунд';
Результат: echo Запрос "select * from users" выполнен за 0.001381 секунд Вот так мы получаем более правдивый вариант. Но данный пример никак не позволит нам измерить время выполнения запросов в действующих приложениях. Для осуществления такой возможности давайте напишем маленький класс. class.timedebugger.php Listing №3 (PHP) <?php /** * Класс "Отладчик по времени" */ class TimeDebugger { /** * @var float временная переменная */ private static $_StartTime = 0; /** * @var float общее время */ private static $_AllTime = 0; /** * Начинает цикл измерения */ public static function Start() { self::$_StartTime = microtime(TRUE); } /** * Завершает цикл измерения * @param string $DebugObject объект измерения * @param string $Quote комментарий */ public static function Stop($DebugObject, $Quote = '') { self::$_AllTime += microtime(TRUE) - self::$_StartTime; $InfoString = '[TIME_DEBUGGER] '; $InfoString .= $DebugObject; $InfoString .= (($Quote !== '') ? ' (' . $Quote . ')' : ''); $InfoString .= ' Time: '; $InfoString .= number_format(microtime(TRUE) - self::$_StartTime, 6); $InfoString .= ' seconds'; self::_LogInfo($InfoString); } /** * Подитоживает все циклы */ public static function StopDebug() { $InfoString = '[TIME_DEBUGGER] All Time: '; $InfoString .= number_format(self::$_AllTime, 6); $InfoString .= ' seconds'; self::_LogInfo($InfoString); } /** * Записывает данные в файл * @param string $InfoString */ private static function _LogInfo($InfoString) { $File = 'timedubug.txt'; $PrevInfo = @file_get_contents($File); $PrevInfo .= date('[d-m-Y H:i:s]'); $PrevInfo .= $InfoString; $PrevInfo .= PHP_EOL; @file_put_contents($File, $PrevInfo); } } ?>
Получился чисто статический класс, позволяющий мерить что угодно. Попробуем измерить длительность всех обращений к базе данных с его помощью в псевдо-действующем приложении: test_db_request.php Listing №4 (PHP) <?php date_default_timezone_set('Europe/Kiev'); //какой-то код require_once 'class.timedebugger.php'; //какой-то код TimeDebugger::Start(); mysql_connect('localhost', 'root', '123'); TimeDebugger::Stop('CONNECT'); //какой-то код TimeDebugger::Start(); mysql_select_db('test'); TimeDebugger::Stop('SELECT DATABASE'); //какой-то код $query = 'select * from users'; TimeDebugger::Start(); mysql_query($query); TimeDebugger::Stop('EXECUTE_QUERY', $query); //какой-то код $query = 'select id from users'; TimeDebugger::Start(); mysql_query($query); TimeDebugger::Stop('EXECUTE_QUERY', $query); //какой-то код $query = 'select name from users'; TimeDebugger::Start(); mysql_query($query); TimeDebugger::Stop('EXECUTE_QUERY', $query); //какой-то код $query = 'select name from users where id=1'; TimeDebugger::Start(); mysql_query($query); TimeDebugger::Stop('EXECUTE_QUERY', $query); //какой-то код TimeDebugger::StopDebug(); ?>
После отработки данного скрипта в файле timedubug.txt вы увидите приблизительно такую информацию: timedubug.txt [16-07-2009 13:53:59][TIME_DEBUGGER] CONNECT Time: 0.002611 seconds [16-07-2009 13:53:59][TIME_DEBUGGER] SELECT DATABASE Time: 0.000739 seconds [16-07-2009 13:53:59][TIME_DEBUGGER] EXECUTE_QUERY (select * from users) Time: 0.001904 seconds [16-07-2009 13:53:59][TIME_DEBUGGER] EXECUTE_QUERY (select id from users) Time: 0.001649 seconds [16-07-2009 13:53:59][TIME_DEBUGGER] EXECUTE_QUERY (select name from users) Time: 0.000840 seconds [16-07-2009 13:53:59][TIME_DEBUGGER] EXECUTE_QUERY (select name from users where id=1) Time: 0.001362 seconds [16-07-2009 13:53:59][TIME_DEBUGGER] All Time: 0.008769 seconds Как можно заметить из результатов, время тратиться не только на выполнение запросов, но и на коннект и выбор базы данных. Таким образом, мы измерили время каждого обращения скрипта к базе и определили время, затраченное на все обращения. Но мы не молодцы! Почему? Потому что погрешность измерения и определения величин задержки обращений к базе довольно велика в данном случае. Посмотрите, какая большая разница результатов в листинге №1 и листинге №2. Почти в два раза! Что ж, необходимо измерить порядочное количество обращений к скрипту. Для этого нам нужно хранить промежуточные данные, которые могут быть и не окончательными. Думаю, проще для всех будет реализовать это с помощью той же базы. Для простоты создадим одну табличку, и все данные будем сохранять в нее: Listing №5 (SQL) CREATE TABLE `test`.`DEBUG` ( `id` INT( 10 ) NOT NULL AUTO_INCREMENT PRIMARY KEY , `object_id` INT( 10 ) NOT NULL , `object_name` VARCHAR( 255 ) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL , `quote_id` INT( 10 ) NOT NULL , `quote` VARCHAR( 255 ) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL , `period` FLOAT NOT NULL , `date_create` DATETIME NOT NULL, INDEX ( `object_id` , `quote_id` , `period` , `date_create` ) ) ENGINE = MYISAM
Теперь перепишем класс, добавив в него функции сохранения собранных им данных в базу и вывод соответствующих результатов: class.timedebugger.php Listing №6 (PHP) <?php /** * Класс "Отладчик по времени" */ class TimeDebugger { /** * Индекс объекта * @var int */ private static $_ObjectCounter = 0; /** * Индекс комментария * @var int */ private static $_QoutesCounter = 0; /** * @var float временная переменная */ private static $_TimeStart = 0; /** * Массив объектов * @var array */ private static $_Objects = array(); /** * Массив комментариев * @var array */ private static $_Quotes = array(); /** * Ссылка на базу данных * @var resource */ private static $_Db = NULL; /** * Начинает цикл измерения */ public static function Start() { self::$_TimeStart = microtime(TRUE); } /** * Завершает цикл измерения * @param string $DebugObject объект измерения * @param string $Quote комментарий */ public static function Stop($DebugObject, $Quote = '') { if(!isset(self::$_Objects[$DebugObject])) { self::$_ObjectCounter++; self::$_Objects[$DebugObject] = self::$_ObjectCounter; } if ($Quote == '') {$Quote = self::$_QoutesCounter;} if(!isset(self::$_Quotes[$Quote])) { self::$_QoutesCounter++; self::$_Objects[$Quote] = self::$_QoutesCounter; } self::_CreateDb(); $Sql = 'INSERT INTO `DEBUG`'; $Sql .= '(`object_id`, `quote_id`, `object_name`, `quote`, `period`, `date_create`)'; $Sql .= 'VALUES'; $Sql .= '( ' . self::$_ObjectCounter . ", " . self::$_QoutesCounter . ", '" . mysql_real_escape_string($DebugObject, self::$_Db) . "', '" . mysql_real_escape_string($Quote, self::$_Db) . "', " . number_format(microtime(TRUE) - self::$_TimeStart, 6) . ", '" . date('Y-m-d H-i-s') . "')"; mysql_query($Sql, self::$_Db); } /** * Возвращает текущие результаты * @param float $MaxTime макс. предел по времени * @return string */ public static function ShowResults($MaxTime = 0.5) { self::_CreateDb(); $Sql = 'SELECT '; $Sql .= '`d1`.`object_name` , '; $Sql .= '`d1`.`quote`, '; $Sql .= 'min( `d1`.`period` ) as min, '; $Sql .= 'avg( `d1`.`period` ) as avg, '; $Sql .= 'max( `d1`.`period` ) as max '; $Sql .= 'FROM `debug` as `d1` '; $Sql .= 'GROUP BY `d1`.`quote_id`, `d1`.`object_name`'; $QueryResult = mysql_query($Sql, self::$_Db); if (mysql_num_rows($QueryResult) == 0) { $Result = 'No test data'; return; } $Result = '<table border="1" align="center">'; $Result .= '<tr style="background-color: #AAAAAA;">'; $Result .= '<td>Object</td>'; $Result .= '<td>Quote</td>'; $Result .= '<td>MinTime(seconds)</td>'; $Result .= '<td>AvgTime(seconds)</td>'; $Result .= '<td>MaxTime(seconds)</td></tr>'; while($Row = mysql_fetch_assoc($QueryResult)) { $Result .= '<tr><td>'; $Result .= htmlspecialchars($Row['object_name']); $Result .= '</td><td>'; $Result .= htmlspecialchars($Row['quote']); $Result .= '</td><td>'; $Result .= self::_CheckTime($Row['min'], $MaxTime); $Result .= '</td><td>'; $Result .= self::_CheckTime($Row['avg'], $MaxTime); $Result .= '</td><td>'; $Result .= self::_CheckTime($Row['max'], $MaxTime); $Result .= '</td></tr>'; } $Result .= '<tr><td colspan="5">* Time limit(>'; $Result .= $MaxTime .')</tr></tr>'; $Result .= '</table>'; return $Result; } /** * Идентифицирует нарушение по времени * @param int $TimeValue * @param int $MaxTime * @return string */ private static function _CheckTime($TimeValue, $MaxTime = 0.5) { return ($MaxTime <= $TimeValue) ? '<span style="color: #FF0000;">' . number_format($TimeValue, 6) . '</span>*' : number_format($TimeValue, 6); } /** * Создает соединения с базой данных */ private static function _CreateDb() { if (self::$_Db == NULL) { self::$_Db = mysql_connect('localhost', 'root', '123'); mysql_select_db('test', self::$_Db); } } } ?>
Файл test_db_request.php оставим таким же, только удалим из него 34-ю строку - она нам более не понадобиться. Создадим еще один скрипт, который мы запустим после всех тестов, чтобы увидеть их результат: show_test_results.php Listing №7 (PHP) <?php require_once 'timedebugger.php'; ?> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <title>TestResults</title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> </head> <body> <?php echo TimeDebugger::ShowResults(0.003); ?> </body> </html>
Итак, нам осталось выполнить достаточное количество запусков test_db_request.php, что получить более правдоподобные данные. Я думаю, что вы не хотите десять тысяч раз клацать по кнопке браузера или опять что-либо писать. Обрадую вас - не только вы этого не хотели. Это за нас сделает отличная утилита, которая входит в дистрибутив Apache под названием ab. Я не буду углубляться в ее работу, просто скажу, что она может сделать то, что мы хотим. Впрочем, скажу, что она может много чего измерить. Например, сколько времени ваш скрипт реально работает с точки зрения пользователя. Ну, это сейчас не важно. Запустить наш test_db_request.php 10000 раз поможет такая строка (Windows): cmd c:\web\apache\bin\ab.exe -n 10000 http://localhost/test_db_request.php После чего она постепенно выполнит все 10000 запросов, информируя вас появлением таких строк: cmd Benchmarking localhost (be patient) Completed 1000 requests Completed 2000 requests Completed 3000 requests Completed 4000 requests Completed 5000 requests Completed 6000 requests Completed 7000 requests Completed 8000 requests Completed 9000 requests Finished 10000 requests Заметьте, вы можете запустить не только локальный скрипт, но и действующий на вашем сайте, просто указав нужный адрес. После завершения работы наши тестовые данные уже должны лежать в базе. Запускаем show_test_results.php и получаем приблизительно такой результат:  Вот теперь мы молодцы! Мы можем наблюдать более правдоподобную информацию и в более наглядном виде: минимум времени, среднее значение и максимум. При этом я поставил предел по времени в файле show_test_results.php равным 0,003 для наглядности того, какие обращения к базе более требовательны. Видно, что эти результаты отражают совсем иную картину, нежели предыдущие. Теперь этих данных более чем достаточно, чтобы понять какие обращения к базе данных можно действительно назвать узким местом и предпринять соответствующие действия по увеличению производительности приложения. Я не буду вдаваться в подробности оптимизации операторов SQL и работы с базами данных - для этого полно специальной литературы, а дам лишь небольшие, но проверенные и действенные приемы, которые всегда следует использовать: - Выполняйте подключение к базе данных лишь однажды, если только вам действительно не нужно несколько коннектов. Для этого используйте шаблон проектирования Singleton, который я применил в примере №6.
- Индексируйте все столбцы таблицы, по которым может осуществляться упорядочивание или фильтрация данных, тоесть те столбцы, которые сопровождаются операциями со знаками >, <, = и их комбинациями. Эти операторы чаще всего встречаются в конструкциях where и join. Также следите внимательно за особенностями СУБД и самого программного обеспечения по работе с метаданными. Вас может расслабить тот факт, что одно ПО или СУБД автоматически устанавливает индексы на вторичные ключи, например, а другое ПО или СУБД этого не сделают.
- Старайтесь выбирать в качестве уникальных ключей именно числовые, но не строковые. Даже если вы не сомневаетесь в уникальности строковых данных, лучше добавьте числовое поле и делайте выборку по нему.
- Минимизируйте количество запросов в коде приложения. Для этого используйте join операторы и грамотно построенные коллекции объектов. Придерживайтесь нормальной формы базы данных для возможности построения эффективных запросов к ней.
- Применяйте операторы оптимизации SQL-запросов, специфические для СУБД.
- После любых изменений в коде SQL-операторов, не забудьте протестировать новую скорость их выполнения.
Теперь вроде можно подвести итог: - Некачественные обращения к базе данных могут быть самым большим огрехом на пути работы всего приложения и связанные с этим узкие места могут оказаться самым "широким" фактором его краха.
- Необходимо всегда обращать внимание на длительность запросов к базе данных и измерять эту длительность статистическим путем для получения более точных результатов измерений.
Comments
|
Замечательная статья. всё по полочкам. А ещё главы будут?
Предлагаю продолжить тему тем, что замеры на локальном компе и на боевом сервере будут значительно отличаться, запрос запросу рознь и что для одного медленно, то для другого вполне приемлемо, так же вариант замера с помощью Slow Query Log (http://dev.mysql.com/doc/refman/5.0/en/slow-query-log.html). mysql/mysqli_connect по умолчанию при повторном подключении возвращает уже имеющуюся ссылку/объект. И ещё позужю: нормализация базы не всегда оправдана.