Речь пойдет о PHP,JavaScript и MySQL как стартовой точке. Я также приведу некоторые цифры тестов производительности и потери времени, которые могут убить проект, на примере одного из продуктов, которые мне пришлось недавно вскрыть на предмет поиска проблемных мест, и покажу как можно в три шага убить проект.
Предисловие.
Недавно мне была поставлена задача, помочь найти проблемы в одном из веб проектов, созданных как обычно на PHP+MySQL, и все это кроме того завернуто в Symfony Framework. База данных начала сильно расти, так-как люди собирали эвенты поведения (допустим автопарка) которые вливались буквально каждые 5 минут. Естественно таблица эвентов выросла, и дабы MySQL с ней хоть как то справлялась, ее разбили на партиции. В итоге все это сводилось к разного рода выборкам и отчетам, т.е. аналитике. В итоге, даже простая выборка за период, плюс небольшой подсчет, занимали от 11 сек и выше. Видимо поэтому и было принято решение, ограничивать выбираемый период в днях.
И так поехали:
Проблема 1 – База
База была выбрана MySQL. Видимо то, что она бесплатна, подкупает всех. Но вот вешать на эту базу третьего эшелона, тяжелую аналитику, считаю первой ошибкой и самой главной. На тестовом примере в 700K записей в таблице эвентов, выборка за период возрастала до 30 сек. Вся сложность оказывалась в данных. Таблица events имела запись “события разного типа” со свойством ON или OFF, т.е. начало и конец определенного эвента. Все довольно таки стандартно, и любой бы с легкостью сделал выборку:
SELECT
ev1.event_point,ev1.event_date,
(select event_date from test_events
where ev1.event_point = event_point
and event_type = (CASE WHEN ev1.event_type = 1 THEN 2 ELSE 1 END)
and event_date > ev1.event_date limit 1) as end_date
FROM test_events as ev1
WHERE
ev1.event_date BETWEEN '2012-1-1' AND '2012-1-31' AND ev1.event_type IN (1 , 2)
для примера и теста использовался один тип события 1 начало и 2 конец, хотя в базе их порядка 40 пар. В итоге просто выбор записей за период, с добавлением сразу конца этого события, забирает у MySQL порядка 20-30 сек.
Для сравнения, была взята также бесплатная база, SQL Server 2008 в редакции Web, все это было запущено на MS Server 2008 R2 на виртуальной среде VirtualBоx. Скажу сразу что Web редакция хоть и бесплатна, но не имеет ряда важных опций, особенно важных для аналитики, например нет оптимизации и кэширования View и Procedure.
Все тесты показали что MS SQL с легкостью выполняет запрос менее чем за 1 сек при 1КК данных.
Далее нужно было сделать простой подсчет и выбрать сколько времени занимали те или иные типы событий за определенный кусок времени. Такое подсчитать на уровне SQL сервера не составляет никакого труда, накладываем сверху условие и получаем готовый отчет:
SELECT event_point,SUM(TIMESTAMPDIFF(HOUR,event_date,end_date)) FROM (
SELECT
ev1.event_point,ev1.event_date,
(select event_date from test_events
where ev1.event_point = event_point
and event_type = (CASE WHEN ev1.event_type = 1 THEN 2 ELSE 1 END)
and event_id > ev1.event_id limit 1) as end_date
FROM test_events as ev1
WHERE
ev1.event_date BETWEEN '2012-1-1' AND '2012-1-31' AND ev1.event_type IN (1 , 2)
) report
GROUP BY event_point
Вот такой пример на той же MS SQL базе выполняется мгновенно, менее чем за 1 сек. А вот с MySQL можно и не дождаться результата, увеличивая период, база ложилась намертво. Видимо те, кто собирал проект, это поняли и решили остановится на обработке выборки прямо в PHP. И дабы не терять все также 20-30 сек на JOIN, было решено делать простой SELECT за период, закинуть все в Array и там уже пробежкой найти начало и конец и потом с легкостью выдать суммы. Что и было сделано, в итоге я снова пришел тупик, рост периода начинал забирать до 1 мин. на расчет. И я начал изучать вторую проблему, пытаясь понять, почему просто прогон по Array занимает такое огромное время. Я надеялся что там будет просто опечатка или ошибка, но оказалось что просто пробежка по Array занимает огромное время. Вот и вторая проблема.
Проблема 2 – Array в PHP
Изучив все данные, я понял, что PHP не может быстро обрабатывать большие массивы. Почитав на различных форумах, я увидел обсуждения, подтверждающие эту версию. Это проблема PHP. Было решено написать простой тест для проверки фактов.
<?php
$summary[] = array();
$count = 5000;
for($i1=0;$i1<$count;++$i1){
$summary[$i1] = $i1;
}
$t = microtime(true);
for($i1=0;$i1<$count;++$i1){
for($i2=0;$i2<$count;++$i2){
if("5468735354987"!="654654655465"){
$summary[$i1] = $i1*$i2;
}
}
}
echo "<li>time: ".(microtime(true)-$t).' ms</li>';
$sum = 0;
for($i1=0;$i1<$count;++$i1){
$sum = $sum + $summary[$i1];
}
echo "<li>test["+$sum+"]";
?>
Код был создан на основе задачи в проекте. В данном тесте идет перебор 5000*5000 = 25КК циклов, причем даже использовалось ++$i вместо $i++ для поднятия скорости. В результате данный тест выдал 11 сек. Попытка запустить этот код просто прямо в консоли без Веб сервера, выдало мне 10 сек. И это все запускалось на моем компьютере а не на хосте или виртуальном машине, и все при конфигурации: Intel Core i5, 8GB. PHP 5.3.9
Понимая что для PHP это предел что можно выжать, я решил проверить этот код на других платформах, имея под рукой виртуальную машину c MS Server 2008 R2. Код теста был легко перекинут в ASP, ASPX, WSC,VBS а также NodeJS. В итоге я получил такие данные:
На скриншоте мы видим три варианта теста. 1) виртуальная машина 2) мой компьютер 3) доступные мне сервера в интернете.
1. PHP на удивление выдал очень близкие результаты, учитывая что запускалось все совершенно на разных ресурсах и мощностях.
2. ASP близкий аналог PHP выдал результат хуже, причем это еще и зависит от мощностей среды.
3. WCS сильно разочаровал, учитывая что Microsoft его в свое время описывал как компилируемый вариант ASP, который должен работать гораздо быстрее, что оказалось совершенно не так.
4. VBS это чисто консольный вариант скрипта, хоть он и показал результат лучше чем PHP, но для веб проекта он неприемлем.
5. ASPX показал просто отличный результат. Тут я не удивлен, все таки это C#
6. NodeJS также выдал, как и предполагалось, просто отличные показатели.
Вот все варианты скриптов:
<?php
$summary[] = array();
$count = 5000;
for($i1=0;$i1<$count;++$i1){
$summary[$i1] = $i1;
}
$t = microtime(true);
for($i1=0;$i1<$count;++$i1){
for($i2=0;$i2<$count;++$i2){
if("5468735354987"!="654654655465"){
$summary[$i1] = $i1*$i2;
}
}
}
echo "<li>time: ".(microtime(true)-$t).' ms</li>';
$sum = 0;
for($i1=0;$i1<$count;++$i1){
$sum = $sum + $summary[$i1];
}
echo "<li>test["+$sum+"]";
?>
<%
count = 5000
Dim summary(5000)
for i1=0 to count
summary(i1) = i1
next
t = timer()
for i1=0 to count
for i2=0 to count
if ("5468735354987"<>"654654655465") then
summary(i1) = i1*i2
end if
next
next
response.write timer()-t
sum = 0
for i1=0 to count
sum = sum + summary(i1)
next
response.write "sum:"&sum
response.write "<br>test-wsc<br>"
Dim obj
Set obj = GetObject("script:"&Server.MapPath("test.wsc"))
Call obj.run()
%>
<?xml version="1.0"?>
<component>
<?component error="true" debug="true"?>
<registration
description="test"
progid="test"
version="0.1"
classid="{13e4b1b3-c698-40ea-8450-9cbc9b33ef03}"
>
</registration>
<public>
<method name="run"/>
</public>
<implements type="ASP" id="ASP"/>
<script language="VBScript">
<![CDATA[
Function run()
count = 5000
Dim summary(5000)
for i1=0 to count
summary(i1) = i1
next
Dim t : t = timer()
for i1=0 to count
for i2=0 to count
if ("5468735354987"<>"654654655465") then
summary(i1) = i1*i2
end if
next
next
response.write (timer()-t)
sum = 0
for i1=0 to count
sum = sum + summary(i1)
next
response.write "sum:"&sum
End Function
]]>
</script>
</component>
count = 5000
Dim summary(5000)
for i1=0 to count
summary(i1) = i1
next
t = timer()
for i1=0 to count
for i2=0 to count
if ("5468735354987"<>"654654655465") then
summary(i1) = i1*i2
end if
next
next
msgbox (timer()-t)
sum = 0
for i1=0 to count
sum = sum + summary(i1)
next
msgbox "sum:"&sum
<%@ Page validateRequest="false" Debug="true" %>
<%@ Import Namespace="System.IO" %>
<%@ Import Namespace="System.Data" %>
<%@ Import Namespace="System.Diagnostics" %>
<%@ Import Namespace="System.Threading" %>
<script language="C#" runat="server">
public void Page_Load(Object sender, EventArgs E)
{
int count = 5000;
int[] summary = new int[5000];
for (int i1 = 0; i1 < count; i1++)
{
summary[i1] = i1;
}
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i1 = 0; i1 < count; ++i1)
{
for (int i2 = 0; i2 < count; ++i2)
{
if("5468735354987"!="654654655465"){
summary[i1] = i1*i2;
}
}
}
stopwatch.Stop();
Response.Write("<li>time: " + (stopwatch.ElapsedMilliseconds) + " ms</li>");
long sum = 0;
for (int i1 = 0; i1 < count; ++i1)
{
sum = sum + summary[i1];
}
Response.Write("<li>test[" + sum + "]");
}
</script>
var fs = require('fs'),
http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/html'});
summary = new Array();
count = 5000;
for(var i1=0;i1<count;++i1){
summary[i1] = i1;
}
var start = new Date().getTime();
for(var i1=0;i1<count;++i1){
for(var i2=0;i2<count;++i2){
if("5468735354987"!="654654655465"){
summary[i1] = i1*i2;
}
}
}
res.write("<li>time: "+(new Date().getTime() - start)+" ms");
var sum = 0;
for(i1=0;i1<count;++i1){
sum = sum + summary[i1];
}
res.write("<li>sum: ["+sum+"]");
res.end();
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');
Полученные данные натолкнули на мысль, что JavaScript в браузере аналогичен ASPX и NodeJS, потому было решено проверить тоже самое просто в браузере. Снова код мгновенно переделывается в тест под HTML и получаем такой код:
<!DOCTYPE html>
<html>
<head>
<script>
function testClear()
{
var summary = [];
var count = 5000;
for(var i1=0;i1<count;++i1){
summary[i1] = i1;
}
var start = new Date().getTime();
for(var i1=0;i1<count;++i1){
for(var i2=0;i2<count;++i2){
if("5468735354987"!=="654654655465"){
summary[i1] = i1*i2;
}
}
}
document.getElementById("testClear").innerHTML = "time: "+(new Date().getTime() - start)+" ms";
var sum = 0;
for(i1=0;i1<count;++i1){
sum = sum + summary[i1];
}
alert("sum: ["+sum+"]");
}
</script>
</head>
<body>
<p><a href="#" onclick="testClear();"><div>test clear:<span id='testClear'></span></div></a></p>
</body>
</html>
Запуск в браузере выдал мне 75 ms, что просто отлично. Решение понятно сразу — вырезать обработку массива из PHP и передать клиенту в массив, и там сделать расчет. Что было и сделано… НО!.. время при запуске снова оказалось свыше 10 sec. Все тесты и поиски в коде, вывели меня на новую проблему под названием Prototype.
Проблема 3 – Prototype и AJAX
Дело в том, что в интерфейсе проекта, все было построено на варианте с AJAX. Т.е. разработчики сделали в духе Web 2.0, модно и красиво. Пользователь выбирает параметры выбора данных, нажимает CREATE REPORT и получает аяксом ниже загруженный отчет. Все считают это сейчас уже стандартом, выдавать информацию без перезагрузки. Вот тут и крылся коварный враг.
Оказывается Prototype имет два варианта загрузки страницы, за что отвечает параметр evalScripts [true/false]. При (evalScripts=false) Prototype загружает информацию и выдает ее на экран просто как текст, и естественно все блоки с JavaScript не будут выполнены. Вот чтобы загруженные динамически страницы отрабатывали и ставится evalScripts=true, и происходит просто парсинг с выполнением eval() для скриптовой части. В итоге вместо чистого кода с его 75 ms мы получаем порядка 11 сек!!! Для меня это был шок. Выходит, что нельзя загружать аяксом код и выполнять его. И это опасение подтвердилось, как только я вынес расчет конкретного отчета в шапку интерфейса. Т.е. в загружаемой странице оставил Array выдаваемый PHP и сделал вызов внешней функции с расчетом из глобальной среды передав массив как параметр, и получил обратно свои 75 ms.
Данный факт натолкнул меня на написание очередного теста, с тем же кодом но для проверки разных браузеров и также сравнить с поведением аякса в JQuery.
В итоге я получил вот такие данные:
Примеры кода можно увидеть тут:
<!DOCTYPE html>
<html>
<head>
<script>
function testClear()
{
var summary = [];
var count = 5000;
for(var i1=0;i1<count;++i1){
summary[i1] = i1;
}
var start = new Date().getTime();
for(var i1=0;i1<count;++i1){
for(var i2=0;i2<count;++i2){
if("5468735354987"!=="654654655465"){
summary[i1] = i1*i2;
}
}
}
document.getElementById("testClear").innerHTML = "time: "+(new Date().getTime() - start)+" ms";
var sum = 0;
for(i1=0;i1<count;++i1){
sum = sum + summary[i1];
}
alert("sum: ["+sum+"]");
}
</script>
</head>
<body>
<p><a href="#" onclick="testClear();"><div>test clear:<span id='testClear'></span></div></a></p>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<script type='text/javascript' src='jquery.js'></script>
<script>
function test()
{
$('#test').html('loading...');
$.get('test_ajax.html', function(data) {
$('#test').html(data);
});
}
</script>
</head>
<body>
<p><a href="#" onclick="test();"><div>test jquery ajax:<span id='test'></span></div></a></p>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<script type='text/javascript' src='prototype.js'></script>
<script>
function testFn()
{
$('test').update('loading...');
new Ajax.Updater('test','test_ajax.html', {
method: 'get',
asynchronous:true,
evalScripts:true,
onSuccess: function(transport) {
$('test').update('in process...');
},
onFailure: function(transport) {
alert("Error");
}
});
}
</script>
</head>
<body>
<p><a href="#" onclick="testFn();"><div>test prototype ajax:<span id='test'></span></div></a></p>
</body>
</html>
<script>
summary = new Array();
count = 5000;
for(i1=0;i1<count;++i1){
summary[i1] = i1;
}
var start = new Date().getTime();
for(var i1=0;i1<count;++i1){
for(var i2=0;i2<count;++i2){
if("5468735354987"!="654654655465"){
summary[i1] = i1*i2;
}
}
}
document.getElementById("test").innerHTML = "time: "+(new Date().getTime() - start)+" ms";
var sum = 0;
for(i1=0;i1<count;++i1){
sum = sum + summary[i1];
}
alert("sum: ["+sum+"]");
</script>
В итоге картина вышла довольно таки страшная.
1. Почему 64bit вариант IE с чистым кодом дал такую потерю скорости, работая в 64bit среде, я так и не понял
2. Скорость работы Javascript из под Prototype просто ужасна. Между чистым кодом и вариантом с аякс, разница просто огромна.
3. JQuery тоже удивил. Хоть и не так сильно, как тот же Propotype, но тоже далек от чистого выполнения JS кода.
4. Дальше анализ приводит в полный тупик. Почему Chrome как и FireFox выдали такие ужасные результаты по сравнению с IE. Притом что IE9 был в виртуальной машине, а IE10 уже просто на компьютере.
Других вариантов перепроверить не нашлось. Что было под рукой, то и использовал. Вы для себя можете сами взять и проверить. Но ситуация уже, в принципе, понятна.
Проблема 4 – String Replace
Буквально в момент написания статьи, наткнулся еще на одну проблему, но уже в другом веб проекте, и уже на ASP. Там база выдавала отчетность, и в итоге получался вывод порядка 3,5Mb, с расставленными маркерами для последующего заполнения. В итоге только операция с Replace(), занимала порядка 11 сек. Разбивка на куски с пробежкой, а также регулярка, прироста скорости не дали. В итоге был написан еще один тест для сравнения разных платформ. Имея небольшой отчет HTML, размером так под 3,5Mb, решил скормить его коду и выполнить реплейс при одинаковых условиях чтобы сравнить аналогично работе с Array.
Итак, мы имеем тестовый файл размером 3,5М, с расставленными метками по тексту для теста, а также один и тот же вариант кода, импортированный на разные платформы.
Прогнав тест, мы получаем такую картину:
В данном тесте (local host) это просто UsbWebServer, запущенный на моем компьютере, остальное это разные провайдеры, к которым был доступ.
1. ASP имеет огромные проблемы работы со строками. Вариант с регуляркой давал результат только хуже, как и разбивка на мелкие куски и обработка. Еще и огромная зависимость от сервера и его мощностей.
2. Тоже оказалось и с ASPX скриптом. В виртуальной машине выдало 13 сек, а вот на простом веб сервере, в самом простом варианте веб хоста, что предлагает 1and1, скорость упала в 4 раза, притом что я запускал скрипт в виртуальной машине с выделенными небольшими ресурсами под нее.
3. PHP снова показал определенную стабильность, но вот Киевский
4. NodeJS выдал лучший результат в этом забеге. Но, к сожалению, не порадовал меня, так-как более 2 сек — это довольно критично, притом что отчет был использован не самый большой. Так что задачу с парсингом пришлось решать поиском других методов.
Варианты кода:
<%
'time: 13.58594
'time: 58.01563
Set FSO = CreateObject("Scripting.FileSystemObject")
Set File = FSO.GetFile(Server.MapPath("test_replace.txt"))
Set Reader = FSO.OpenTextFile(File, 1,False)
buffer = Reader.ReadAll
Reader.Close
t = timer()
For rec=1 to 10
For col=0 to 100
buffer = Replace(buffer,"{{"&rec&":"&col&"}}","-|-")
Next
Next
response.write "time: "&timer()-t
response.write "<hr>"
response.write buffer
%>
<%@ Page validateRequest="false" Debug="true" %>
<%@ Import Namespace="System.IO" %>
<%@ Import Namespace="System.Data" %>
<%@ Import Namespace="System.Diagnostics" %>
<%@ Import Namespace="System.Threading" %>
<script language="C#" runat="server">
public void Page_Load(Object sender, EventArgs E)
{
//time: 8736 ms
//time:[1and1] 16183 ms
string buffer = File.ReadAllText(Server.MapPath("test_replace.txt"));
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int rec = 0; rec < 10; rec++)
{
for (int col = 0; col < 100; col++)
{
buffer = buffer.Replace("{{" + rec + ":" + col + "}}", "-|-");
}
}
stopwatch.Stop();
Response.Write("<li>time: " + (stopwatch.ElapsedMilliseconds) + " ms</li>");
Response.Write(buffer);
}
</script>
<?php
//time: 4.570 ms
//time:ks.ua 10.3491752148 ms
//time:servmax 5.7612838745117 ms
$buffer = file_get_contents('test_replace.txt');
$t = microtime(true);
for($rec=0;$rec<10;++$rec){
for($col=0;$col<100;++$col){
$buffer = str_replace( "{{".$rec.":".$col."}}","-|-", $buffer );
}
}
echo "time: ".(microtime(true)-$t).' ms';
echo "<hr>";
echo $buffer;
?>
var fs = require('fs'),
http = require('http');
http.createServer(function (req, res) {
fs.readFile('test_replace.txt', function (err, data) {
data = String(data);
if (err) {
res.writeHead(404);
res.end(JSON.stringify(err));
return;
}
res.writeHead(200, {'Content-Type': 'text/html'});
var start = new Date().getTime();
for (var rec = 0; rec < 10; ++rec)
{
for (var col = 0; col < 100; ++col)
{
data = data.replace(new RegExp( "{{" + rec + ":" + col + "}}", "g" ), "-|-");
}
}
console.log(new Date().getTime()-start);
res.end(data);
});
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');
Итог
P.S. Хотел описать коротко, выдав только цифры, но без пояснений скорее всего было бы кому то непонятно. Так что приношу извинения за длинный текст, ну и стиль написания, для меня это довольно таки большая проблема… не писатель я.
P.S.S. Очень сильно смущает тенденция грузить все проекты по дефолту на MySQL. Все-таки это база третьего эшелона, и она больше подходит для WordPress, форума и других мелких задач. То что база бесплатная, в итоге выходит гораздо дороже. Вместо того, чтобы выполнить всю аналитику прямо в базе, поручив ей заниматься оптимизацией и кэшированием, разработчики занимаются написанием километров кода на PHP вместо одного запроса в базе. Если вопрос стоит только в цене, то возьмите MS SQL Server в редакции Web, которая тоже бесплатна, но даст вам все преимущества аналитики, или же используйте Oracle, все таки это базы высшего эшелона. PostgresSQL не выдал приемлемого результата, потому был сразу отброшен для дальнейшего тестирования. Если кому-то вдруг будет интересно, то вот аналог кода SQL Server который вы сможете экспортировать в ту или иную базу:
CREATE TABLE test_events
( event_id int IDENTITY (1, 1), event_type int, event_point int, event_date datetime )
--------------
ALTER TABLE [test_events] ADD CONSTRAINT [IDX_event_type] DEFAULT ((1)) FOR [event_type]
GO
ALTER TABLE [test_events] ADD CONSTRAINT [IDX_event_point] DEFAULT ((1)) FOR [event_point]
GO
ALTER TABLE [test_events] ADD CONSTRAINT [IDX_event_date] DEFAULT (NULL) FOR [event_date]
GO
--------------
DECLARE @counter bigint = 0;
DECLARE @event_type smallint = 1;
--------------
BEGIN TRANSACTION;
WHILE @counter < 1000000
BEGIN
IF @event_type=1
SET @event_type=2
ELSE
SET @event_type=1
INSERT INTO test_events (event_type,event_point,event_date)
VALUES (@event_type,ROUND(5 * RAND() + 100,0),DATEADD(MINUTE,@counter*10,'1/1/2011'))
SET @counter = @counter + 1
END
COMMIT TRAN;
--------------
SET NOCOUNT ON
SELECT event_point,SUM(DATEDIFF(hour,event_date,end_date)) FROM (
SELECT
ev1.event_point,ev1.event_date,
(select top 1 event_date from test_events
where ev1.event_point = event_point
and event_type = (CASE WHEN ev1.event_type = 1 THEN 2 ELSE 1 END)
and event_id > ev1.event_id) as end_date
FROM test_events as ev1
WHERE
ev1.event_date BETWEEN '2012-1-1' AND '2012-1-31' AND ev1.event_type IN (1 , 2)
) report
GROUP BY event_point
Всем спасибо! Надеюсь что эта информация будет вам полезна.
Автор: IhorL