Дневник Шестеро Михаила

Apr. 17th, 2014

09:35 pm - Размер медленной выборки и «последняя страница» (MySQL - PHP - браузер/клиент)

Рассмотрим простую страндартную задачу: данные из таблицы MySQL через PHP направляются в браузер (или иную программу-клиент). В браузере они отображаются в виде таблицы разбитой на «страницы» по 20, 50 или 100 записей на странице. То есть если записей много, пользователь видит только первые, скажем, двадцать, а ниже таблицы переключатель страниц: 1,2,3,..., последняя, называемый «paginator».

Эта задача весьма «заезжанная» и в ней нет ничего сложного, если трудоёмкость выборки из СУБД не большая. В этом случае как правило скрипты PHP принимают аргументом номер показываемой «страницы» N (по умолчанию - N=1) и размер страницы M из фиксированномго множества(20,50,100, а по умолчанию, скажем, M=20) сначала делают запрос SELECT COUNT(*) …. из таблицы с условиями выборки. Полученый размер выборки T делят на M, получая количество страниц C=M/T (приводя до целого числа в большую сторону), проверяют что N<=C, затем делают выборку записей для текущей «страницы» с помощью ...LIMIT (N-1)*M,M
Однако если таблица в MySQL очень большая полная выборка или подсчёт записей в выборке COUNT(*) может затянуться на продолжительное время. При этом, выдача полезных результатов в текущую страницу (особенно в первую) может происходить удовлетворительно быстро:
При не-буверизированном чтении выборки из MySQL записи могут приходить, например, каждые несколько десятков миллисекунд. Таким образом первая «страница», может быть загружена за приемлемое время порядка секунды, но если в выборке сотни тысяч записей и более, запрос COUNT(*), считающий записи, сам по себе может выполняться минуту и более!
Конечно, большого смысла показывать пользователю более нескольких тысяч записей нет. В случае если выборка очень большая, пользователю нужно увидеть просто фрагмент данных, что бы он мог на глаз проверить, что это вообще то что нужно.
Но иногда заказчик хочет, что бы пользователь всё же мог: (a) узнать общее количество записей в выборке; (b) перейти для просмотра на последнюю страницу выбоки.
Эти условия на мой взгляд, являются вполне обоснованными, т.к. могут реально помочь пользователю соориентироваться в его работе.
Мы не можем гарантировать выдачу результата за приемлемое для интерактивного взаимодействия «запрос-ответ» время, но можем показывать пользователю динамическую оценку результата требования (a).
Я имею в виду следующее: получив в течении секунд после запроса первую «страницу» и переключатель страниц: на 1-10 страницы, пользователь наблюдает бегущее увеличивающееся значение количества записей в выборке: «найденно не менее 200 записей...», «не менее 300...», «не менее 400...» итд. По мере этого может соответствующим образом расти и переключатель страниц. Пока не увидет окончательный результат «в выборке 12345 записей!», и появится возможность переключиться на последнюю «страницу».
Как это сделать?
Ни логика реляционных СУБД (в данном случае MySQL) ни логика WWW и HTTP не была изначально расчитана на такую работу.
MySQL не может информировать о состоянии счётчика COUNT(*), до тех пор, пока не подсчёт не будет полностью завершен, то есть задав такой запрос мы должны ждать пока он не выполнится, не дождавшись полного подсчёта мы не можем получить ответ на вопрос «ну а есть ли там хотя бы 100 записей?».
HTTP не был преспособлен для установки постоянных соединений, так как это делают TCP-socket-ы. Однако тут всё же проще: есть что называется «хак», можно просто не закрывать соединение после отработки основной части PHP (предварительно отключив тайм-аут выполнения скриптов!).
После завершающего </HTML> браузер продолжает воспринимать комманды вызова JavaScript-функции с данными в аргументах вида:

<SCRIPT>counter(200);</SCRIPT>
<SCRIPT>counter(300);</SCRIPT>
…
<SCRIPT>total(12345);</SCRIPT>
Естественно, заранее надо создать и загрузить в блоке HEAD эти JavaScript фунцкии counter(...) и total(...), которые будут утилизировать динамически поступающие значения (в данном случае текущая оценка размера выборки) должным образом (показывать их пользователю в должном месте, перестраивать переключатель «страниц» итд). Это не совсем «по-правилам», но это работает. После каждой такой посылки приходится посылать килобайтик пробелов и переводов строки (не считая уж комманды flush(); в PHP), т.к. полностью отключить кеширование по всей цепочке от серверного скрипта до обработчика JavaScript в браузерах не возможно.
При работе с MySQL, ради подсчёта COUNT(*) остаётся только после посылки нужных записей продолжать читать ненужные записи в холостую, ни куда не отправляя результаты, а только пересчитывая их в цикле и вызывая через каждые, скажем, 100 посчитанных записей вывод новой порции тегов «SCRIPT», а в конце -
<SCRIPT>total(...);</SCRIPT> 


В моей работе данные отправлялись не в браузер, а в специальную программу-клиент в формате XML, где разбирались по мере поступления SAX-парсером. С XML такой подход выглядит более элегантно. Я сразу грузил данные для всех «страниц», при их переключении нового запроса к серверу не создавалось. Мой более сложный алгорим всего этого таков:
Запускается небуферезированный цикл чтения выборки. Записи выдаются в клиент, в атрибуте тега XML записи указывается порядковый номер записи. Программма-клинет показывает пользователю записи по мере поступления, и он сразу может с ними работать.
Когда выведено заданное предельное кол-во страниц P, то есть M*P записей, они вместовыдачи в клиент начинают попадать в FIFO буфер $dlist = new SplDoublyLinkedList(); // Класс SplDoublyLinkedList из SPL есть в PHP начинаяя с версии 5.3.0
Этот буфер растёт до предела равного максимальному возможному размеру страницы M:

    $dlist
->push($r); // $r - новый элемент XML с очередной записью из выборки
    
if ($dlsize>=$dlmax// буфер заполнен
    
{
      
$dlist->shift();
    }
    else
    {
      
$dlsize++; // $dlsize это размер буфера
    
}
    
Как только выборка завершилась мы выдаём содержимое этого бувера по HTTP клиенту. В моём коде это выглядит так:
 
      fwrite
$f"<lastblock size=\"$dlsize\">\n" );
      
$dlist->setIteratorMode(SplDoublyLinkedList::IT_MODE_FIFO);
      for (
$dlist->rewind(); $dlist->valid(); $dlist->next()) {
        
fwrite$f$dlist->current() );
      }
      
fwrite$f"</lastblock>\n" );
    
Клиент может понять, что это последняя «страница», во-первых, по тегу lastblock либо, во-вторых, по разрыву в порядковых нумерах записей выборки.
Если же по истечению некоторого времени после начала «чистого счёта» или например, после пересчёта заданного количества записей «конца счёту не видно», запускается второй SQL-запрос COUNT(*), выполнение которого происходит параллельно. Т.к. в первом запросе используется не буфферезированное чтение обязательно надо делать второе соединение с MySQL. Хотя второй одновременно работающий в СУБД запрос тормозит первый, такой подход всё же показывает в среднем выйгрыш в скорости обслуживания.
Эти два условных «потока» выполнения начинают работать параллельно, и информация об общем колличестве записей и о содержании последней «страницы» в конце концов берётся из того потока, который выполнился быстрее (первый по любому поставляет «бегущую» информацию о размере выборки, не позволяя пользователя соскучиться).
В ходе второго «потока» после отработки первого SQL-зароса COUNT(*) запускается второй SQL-запрос с LIMIT-ом для получения записей последней «страницы». Отслеживание его выполнения происходит в цикле чтения основного первого «потока» с помощью упомянутой мной в предыдущем посте конструкции

        
// request with LIMIT is already sent
        
$ready $error $reject = array($mysqli2); // $mysqli2 — объект второго соединения.
        // или может так?: $ready[] = $error[] = $reject[] = $mysqli2;
        
mysqli_poll$ready,$error,$reject0,50000); // wait 1/20 sec
        
if (count($ready)>0)
        { 

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

Tags: , , ,
Current Mood: [mood icon] satisfied

Apr. 4th, 2014

04:01 am - Неблокирующий запрос к MySQL из PHP

Казалось бы – что можно сказать нового к такой заезженной и тривиальной теме: доступ к данным в MySQL по запросу через браузер – Apache – PHP?
Оказывается и в такой казалось бы банальной операции можно встретить проблемы, которые мало кто знает как решать.

В задачах, с которыми столкнулся я, запросы к СУБД штатно могут быть столь тяжёлыми, что выполнятся продолжительное время. Заметные задержки при этом оказались неизбежны – даже на самых быстрых серверах с применением рейдов-0 из SSD под данные СУБД. Существенные задержки позволили появиться возможности и проблеме: пользователь может захотеть прервать выполнение запроса, передумать ждать результатов. Иными словами запрос может стать не актуальным, и тогда его надо корректно прервать. То же самое может произойти из-за аварийного обрыва соединения или из-за тайм-аута.

Реализация таких отмен запросов, может потребоваться и в простом web-интерфейсе, и при запросах данных через AJAX, и при взаимодействии с сервером специальных клиентских программами по HTTP. С серверной стороны, в принципе, подобная задача может возникнуть и при получении данных из других СУБД или вообще из других медленных источников. В этой статье я привожу решение, относящееся к извлечению данных именно из MySQL через PHP под Apache2 используя новый программный интерфейс MySQLi, то это относится к множеству вариантов клиентов, но к совершенно конкретному получению данных на сервере.

Знакомство вопросом реализации обработчиков отмены или обрыва сразу выявило принципиальное несовершенство связки PHP-Apache2: PHP-скрипт в принципе не может обнаружить обрыв или даже штатное закрытие соединения до тех пор пока не будет ничего отправлять клиенту в сеть. Причём по не регламентировано сколько данных для этого надо отправить, в указаниях все вроде соглашаются с тем что одного байта вроде бы будет не достаточно, часто отправляют холостые блоки из целого килобайта нулей или пробелов. Возможно, это зависит от конкретного сервера, версии PHP и их настроек. Экспериментально я определил, что на моём сервере можно отправлять 32 перевода строки.

В связке PHP-MySQL тоже оказался существенный изъян: все простые способы чтения данных оказались блокирующими, т.е. они приостанавливают работу PHP скрипта до тех пор, пока MySQL не вернёт их (либо не случится тайм-аут). И соответственно, в это время скрипт ничего послать в сеть не может для того, что бы проверить соединение с HTTP-клиентом. Да, можно применить небуфиризированное чтение, но в моём случае задержки не были связанны с большим количеством записей в выборках; даже извлечение одного единственного значения из СУБД может приостановить ход выполнения PHP. В случае отмены можно, конечно, просто оборвать HTTP-соединение с клиента. Но при этом не только бесполезно перенагружается сервер, который продолжает обрабатывать ненужный “зомби-запрос”, но если вы применяете сессии, а СУБД “уйдёт в себя”, ненароком может произойти глобальная блокировка не только окна браузера с запросом, но и всего сайта, да так, что придётся перегружать всё сбрасывая сессию. Блокировка сессии может случится, если на момент обращения к СУБД, которое затянулось, сессия не была закрыта на запись.

Существует одно универсальное решение – при постановке запроса в MySQL получить номер MySQL-процесса, передать его в клиент. Тот если надо, может его использовать, если понадобится, передав по другому соединению в специальный скрипт на сервере, который “собъёт” ставший не нужным запрос с помощью SQL-зароса-комманды KILL, что в свою очередь приведёт и к разблокировки PHP, обрабатывающего запрос. Этот способ, без сомнения, применим, однако он сложен, некрасив и небезопасен. При его реализации нужно опять-таки иметь в виду опасность блокировок сессии, о которой я упоминал выше.

Мне пришло в голову использовать в PHP параллельный поток (thread) для проверки наличия соединения по HTTP, приблизительно так:

class Ping0 extends Thread {
  public function 
run() {
    
//if (ob_get_level()) ob_end_clean(); 
    
sleep(1);
    echo 
str_repeat("\n",32);
    
flush();    // Error 6 (net::ERR_FILE_NOT_FOUND): The file or directory could not be found.
    
ob_flush();
  }
}

function 
db_query_long($qstring,$conn
{
  
ignore_user_abort(false);
  
//if (ob_get_level()) ob_end_clean(); 

  
$ping0 = new Ping0();
  
$ping0->start(); 
  
  
$ret db_query($qstring,$conn);

  
// $ping0->stop();

  
return $ret;
}
Но этот способ не заработал, скрипт падал с неадекватными ошибками на flush(), создающими впечатление багов в PHP. Встроенный класс потоков Thread в PHP произвёл впечатление очень “сырого”, его даже не было в стандартной инсталляции и что бы воспользоваться требовалось пересобирать PHP из исходников с поддержкой ZTS (Zend Thread Safety) (опции --enable-maintainer-zts или --enable-zts в Windows). В общем, я решил оставить этот путь, хотя его преимущество в том, что он мог бы помочь в решении задачи обработчика отмены запросов не только из MySQL но и из других медленных источников данных.
К счастью, в MySQLi есть нетривиальный способ обработки запроса к БД, который позволил мне благополучно совершить неблокирующее чтение непосредственно.
Итак к вашему вниманию вот фрагмент функции, организующий неблокирующей запрос и возвращающей результат в виде объекта mysqli_result :
    $r $gl_mysqli1->query($sqlMYSQLI_ASYNC ); 

    
ob_implicit_flush(true);
    
ignore_user_abort(false); // можно поставить true

    
for ($i=0$i<$gl_tout$i++) // $gl_tout - тайм-аут; максимальное время в секундах
    
{
      
$ready $error $reject = array($gl_mysqli1);
      
// $ready[] = $error[] = $reject[] = $gl_mysqli1;

      
mysqli_poll$ready,$error,$reject1); // ждём 1 cек; можно воспользоваться пятым параметром - микросекунды
      
if (count($ready)>0)
      {
      
// ready - данные получены
      
$r $gl_mysqli1->reap_async_query();
      if (
$r
      {
        
// успех - данные получены
        
return $r;
      }
      
// some error ??
      
return $r;
      }
      if ( 
count($error)>|| count($reject)>)
      {
      
// какая-то ошибка - error
      
trigger_error("(" $gl_mysqli1->connect_errno ") "
        
$gl_mysqli1->connect_errorE_USER_ERROR);

      return 
null;
      }

      
// проверка соединения с клиентом по HTTP - test connection
      
echo str_repeat("\n",32); // посылка нулей приводит к ошибкам и глюкам
      
flush();
      
ob_flush();

      if (
connection_status()!=CONNECTION_NORMAL)
      {
       
// соединение с клиентом оборволось, запрос на СУБД не актуален
        
return null;
      }
  
      
// возможно нормальное состояние — данные ещё не готовы, надо подождать
    
}
    
// если мы тут - время вышло - таймайт $gl_tout сек.

Метод mysqli::poll до сих пор весьма плохо документирован...
Примечание: лишние переводы строки между управляющими тегами HTML и XML обычно никак не проявляются.

Tags: , , ,

Apr. 2nd, 2014

06:45 pm - Извлечение данных из MS Access-баз простыми, кросс-платформенными и бесплатными способами

По работе мне частенько случается обмениваться большими (иногда многогигабайтовыми) объёмами табличных данных. Такие таблицы обычно приходят в понятном простому пользователю формате — MS Access. Сейчас я расскажу, как можно эффективно извлекать данные из этих файлов для того что бы пустить их в обработку или, например, ввести их в MySQL.

Проблема тут в том, что MS Access — проприетарная программа, которую надо бы покупать, да и не всегда она просто бывает под рукой на конкретной машине, да и сделана под Windows (а я часто на Linux-е). Если архив с базой приходит от коллеги уже на сервер хостинга, приходилось сначала скачивать его на рабочий компьютер, извлекать данные, а потом обычно запаковывать и закачивать извлечённые данные обратно. Кроме этого, на рабочей локальной машине открывать предельно объёмные базы MS Access им самим только лишь для извлечения данных не оптимально по расходу ресурсов оперативной памяти и времени.

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

Тогда я использовал ODBC. Драйвера для работы с Access-базами (как mdb так и accdb) можно бесплатно взять с сайта Microsoft. Для выгрузки в TSV («значения разделённые табуляцией» — простой универсальный формат, который понимют и офисные программы и MySQL) я написал простейшую программу на Qt.

Этот способ мне то же изрядно помог, однако я столкнулся с такими недостатками:

1) По мере чтения таблицы резервируется оперативная память. Для работы с действительно большими таблицами нужно иметь не менее 4 Gb свободной оперативной памяти.

2) Пожалуй самое страшное — если ресурсов не хвататет, программа прерывает работу без какого-либо сообщения или даже признака ошибки. Иногда экспортируются не все строки таблицы, но ещё хуже, когда в выходных данных пропадают (без какого либо обращающего на себя сообщения!) значения в определённых столбцах.

3) На 64-битную систему ставятся 64-битовые ODBC-драйвера, а что бы с ними работать, программа должна быть также откомпилирована как 64-битовый исполняемый модуль. А для того что бы его сделать понадобится 64-битовый Qt и компилятор…

4) Присутствие в системе JET-движка MS Access-a без MS Office у меня ставит в тупик автоматическую систему обновление Microsoft, тщетно пытающуюся обновить несуществующий Office.

5) Под Linux-ом мне не удалось этим воспользоваться. Хотя в принципе это возможно. Linux-овые ODBC-драйвера для работы с Access-базами платные, но под эмулятором Wine должно быть можно задействовать native-ODBC-драйвера (сделанные для Windows). Инструкции как это сделать есть в Интернете. Но мне не удалось: поддержка этого процесса «из коробки» в новых версиях Ubuntu, очевидно, утратилась. И «подгонять напильником» там, видимо, придётся не мало! Кстати, кому удалось использовать nativeODBC для доступа к Access-файлам через Wine под Ubuntu 12 илм 13 — отпишитесь!

Я уж подумал, ничего лучше не будет — формат то «проприетарный». Но в августе прошлого 2013-го года появилась бесплатная библиоткека Jackcess 2 (см. jackcess.sourceforge.net ), написанная на Java, которая позволяет легко и просто работать с Access-файлами, причём как старыми mdb, так и новыми accdb. Таким образом я не только элементарно самостоятельно сделал конвертер из Access в TSV но и получил его кроссплатформенным — ведь это Java. Теперь пользуюсь им и радуюсь.

Исходники и бинарники конвертеров я выложил для скачки здесь:
netdbview.com/accdb

Успехов в использовании!

Tags: , , , , , , , ,
Current Mood: [mood icon] satisfied