Дневник Шестеро Михаила - Неблокирующий запрос к MySQL из PHP

Apr. 4th, 2014

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

Previous Entry Add to Memories Tell A Friend Next Entry

Казалось бы – что можно сказать нового к такой заезженной и тривиальной теме: доступ к данным в 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: , , ,