Статьи

Длительные процессы PHP: внешние ресурсы

PHP имеет несколько SAPI : например, веб-ориентированные, которые запускают процессы внутри веб-сервера, такого как Apache, и клиентские, которые запускают процессы как независимые объекты без ограничения по времени.

Когда вы начинаете использовать cli SAPI, у вас может возникнуть желание настроить длительные процессы, которые никогда не завершаются; например, для запуска рабочих, выбирающих работу из очереди или Gearman. Эти процессы выглядят так:

setup();
while (true) {
  $job = pick();   
  $job->execute(0);
}

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

Сегодня я собираюсь написать об одной из проблем, которые возникают при длительных процессах — постоянный доступ к внешним ресурсам.

Архитектура процесса

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

При приближении наш рабочий выглядит примерно так:

while (true) {
  $job = pick();   
  try {
    $job->execute(0);
  } catch (Exception $e) {
    // manage the error
  }
}

Что делать в блоке catch, зависит от приложения и задания: мы можем запланировать повторное выполнение задания позже или объявить его неудачным. Единственная определенная вещь состоит в том, что 1) мы не хотим, чтобы наш рабочий процесс завершился из-за просто ошибки, и 2) мы хотим, чтобы опция делала что-то вместо того, чтобы всплывать исключение в журнале PHP.

Внешние ресурсы

Однако процессы PHP обычно устанавливают ссылки на внешние ресурсы в своей начальной загрузке:

  • открытие соединений с базой данных для MySQL или MongoDB.
  • Открытие файловых дескрипторов для потоковой передачи выходных данных.
  • Настройка подключения к Memcache.
  • Открытие соединения с очередью или сервером заданий, чтобы что-то сделать.

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

Таким образом, обычно эти ресурсы живут так долго, как процесс; если вам случится перезапустить ваш экземпляр MySQL (или другой внешний сервер, к которому подключен процесс), вы начнете получать ошибки вроде:

[18-Dec-2013 18:30:38 Europe/Rome] DB error: MySQL server has gone away in SELECT * FROM ...

Если мы уловим эти исключения, наши работники начнут немедленно терпеть неудачу при выполнении каждой работы, возможно, пометив их всех как очень быстрые, так как при первом запросе они встретят исключение. Это очень опасное поведение, потому что, когда вы попадаете в состояние необходимости перезапуска демонов, последнее, что вы хотите сделать, — это не забывать перезапускать своих рабочих; даже если вы это сделаете, рабочие начнут выходить из строя в течение нескольких секунд, что может означать, что сотни рабочих мест будут потеряны. Мы определенно можем сделать лучше, чем это.

Смертельные исключения

Первоначальным решением для нас было настроить периодический перезапуск рабочих с помощью задания cron, что было неэффективно по нескольким причинам:

  • даже если вы перехватываете SIGTERM и подобные сигналы, отправляемые работникам для их прекращения, менеджеры процессов, такие как Supervisor, просто убивают их без уведомления, оставляя некоторые задания в промежуточном состоянии, где они начали выполняться. Я думаю, именно поэтому вы никогда не должны доверять программным объектам с именем Manager.
  • Если вы перезапустите с частотой X минут, в худшем случае работник может запустить X минут без подключения к MySQL (или аналогичного требования) с очевидными результатами.

Недавно мы ввели концепцию DeadlyException, чтобы избежать периодического перезапуска.

while (true) {
  $job = pick();   
  try {
    $job->execute(0);
  } catch (DeadlyException $e) {
    break;
  } catch (Exception $e) {
    // manage the error
  }
}

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

Когда запрос не выполняется, мы теперь проверяем ошибку внутри драйвера и:

  throw new DeadlyException("Serious error with MySQL connection or query, -1, $e);
  // $e may be a PDOException

Поскольку мы не можем изменить тип исключений, выдаваемых драйвером, мы должны выбросить новый, который может быть распознан длительными процессами. Это также дает нам гибкость завершения только тогда, когда исключения MongoException происходят только во время определенных операций, а не для каждой из них, если это ваш вариант использования.

Альтернативой является определение смертельных исключений в виде списка классов, который позволяет вам использовать уже установленную иерархию (возможно, не под вашим контролем):

$deadlyExceptions = [
  'PDOException',
  'MongoException'
];

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