AzoftБлогРешение проблемы длительных операций и интерактивного отображения статуса выполняемой операции в PHP

Решение проблемы длительных операций и интерактивного отображения статуса выполняемой операции в PHP

Алексей Багрянцев Октябрь 3, 2012

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

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

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

Предыстория

На одном из PHP проектов среднего размера возникли проблемы, связанные с выполнением тяжеловесных операций и интерактивном отображении статуса исполняемой операции. Кроме блокировки исполнения других запросов и перехода по другим ссылкам портала пользователь пребывал в неведении и не догадывался, что же происходит в данный момент времени, когда же закончится операция, возможно, произошла ошибка и т.д.  Проект изначально носил статус экспериментального, требования к выполняемым фазам часто менялись на лету, изменялись приоритеты и вектор развития системы. Для формирования большего представления хотелось бы описать стек используемых технологий на проекте:

  • MVC Framework CakePHP 2.1 (PHP5.3);
  • MVC Framework CakePHP 2.1 (PHP5.3);
  • MySQL;
  • jQuery, jQuery UI, jQuery plugins;
  • Selenium server, Snoopy server side browser simulator;
  • IMAP Server, etc.

В качестве операционной системы была выбрана: CentOS

Выбор фреймворка был обусловлен опытом его использования командой разработчиков, выбор же реляционной СУБД MySQL в качестве data storage является довольно стандартным решением и обусловлено стабильностью и распространенностью использования этой СУБД в качестве “движка” хранения данных.

На этапе проектирования системы сразу было трудно предугадать, что в системе появятся очень “тяжелые” операции, процесс выполнения которых придется пересматривать, реализовывать дополнительные компоненты и в общем перерабатывать архитектуру приложения. К тому же система была неким прототипом, где требования часто менялись, как и то, куда будет расти система в дальнейшем.

На одном из этапов был реализован ряд операций, которые оказались довольно “тяжеловесными” (highweighted operations) и мало того, что они блокировали выполнение других операций в рамках той же сессии, но и вообще процесс перехода по ссылкам системы становился невозможным до тех пор, пока “тяжелая” операция не была завершена. При рассмотрении проблемы на более низком уровне оказалось, что любой запрос блокировал файл сессии при его открытии, соответственно, пока операция не выполниться полностью и процесс не сбросит (flush) сессионные данные в файл сессии и не освободит его, сняв блокировку, другие запросы будут терпеливо ждать в очереди. Действительно, по умолчанию в PHP в качестве механизма хранения сессий используется файл. При открытии сессии срабатывает функция, наподобие fopen(), которая и блокирует файл на чтение и запись для других процессов. Сразу напрашивается решение о смене session storage для снятия блокировки, но поговорим об этом попозже.

Подходы

  1. Разбиение операции на шаги
  2. Ajax Polling
  3. Long Polling
  4. Forever lframe
  5. Streaming
  6. Comet-server
  7. Web-sockets

 

Коротко рассмотрим каждый из этих подходов для решения задачи выполнения тяжеловесных операций и интерактивного оповещения пользователя о прогрессе выполнения.

Разбиение операции на шаги

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

В выполняемом проекте данный подход оказался не слишком удачным по причине того, что тяжеловесные операции были практически не делимы на шаги. Кроме того использовались сторонние и запущенные на сервере сервисы, для работы с которыми приходилось создавать и подготавливать соответствующие объекты во время исполнения запроса. После исполнения запроса, которые представлял 1 шаг операции, объекты “прибивались”. Возможно стоило подумать о их переиспользовании, но до мы этого не дошли.

Ajax Polling

Следующее решение, которое напрашивается, заключается в запуске операции на сервере и постоянном опросе сервера о статусе выполняемой операции путем посылки серии ajax-запросов через определенные интервалы времени. На клиенте же можно проанализировать такой ответ сервера (например, это может быть JSON, содержащий “message”, “percentage”, “error” и “redirect”) и отрисовать progress-bar, отображающий статус выполнения текущей операции.

Была попытка использования Polling подхода на проекте с двумя разными способами хранения результата выполнения операции:

  • Хранение результатов выполнения в файле – <session_id>+<operation>.txt

  • Хранение результатов в БД, в соответствующей таблице – high_weight_operations

Пользователь инициировал выполнение операции с помощью ajax-запроса, после этого клиентский скрипт периодически опрашивал сервер и получал статус и прогресс текущей операции – /operations/get_status/<operation_id>.

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

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

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

Решение заключалось в смене Session Storage, что обеспечивало работу с сессией без блокировки при ее открытии. В качестве нового хранилища сессии можно выбрать один из следующих вариантов:

  • MySQL Database;
  • MongoDB;
  • Memcached;

Для того, чтобы сменить  Session Storage  в PHP предусмотрена функция – session.save_handler(), которая устанавливает пользовательские функции хранения сессии. Они используются для хранения и получения данных, ассоциированных с сессией. В сети можно найти множество примеров её использования, существуют уже реализованные классы, служащие для переноса сессии в БД или  memcached.

Примечание:  Для работы функции необходимо установить опцию session.save_handler  в значение  user  в вашем файле конфигурации  php.ini.

На проекте в качестве эксперимента было использовано хранение сессий в  mysql  и  mongodb.

Примечание: Если понадобится, чтобы только что записанный в сессию параметр был доступен для других запросов, то сессию необходимо “сбосить (flush)” в  mysql  или  mongodb, чтобы отработала функция записи сессии. Для этого необходимо закрыть её на запись и переоткрыть снова:

public function session_flush() {
		session_write_close();
		session_start();
    }

После реализации соответствующих компонент, сессия перестала блокироваться и позволила обрабатываться нескольким запросам, ассоциируемых с одной и той же сессией, одновременно. Теперь она не “лочится” (lock), как это было с файлами, при открытии файла функцией  fopen , плюс ко всему скорость работы сессии значительно возросла.

Примечание: Для использования  mongodb  в PHP необходимо установить mongodb как сервис, а также драйвер работы на PHP.

Пример установки на  CentOS :

  1. Создать репозиторий

/etc/yum.repos.d/10gen.repo

  1. Заполнить содержимым (в зависимости от разрядности ОС)
[10gen]
name=10genRepository
baseurl=http://downloads-distro.mongodb.org/repo/redhat/os/x86_64
gpgcheck=0
enabled=1
  1. Вызвать

yum update

  1. Далее

yum install mongo-10gen mongo-10gen-server

  1. Запустить “демон”

service mongod start

  1. Установить драйвер
yum -y install php-devel
sudo pecl install mongo
extension=mongo.so

После этого можно спокойно пользоваться  mongodb.

В качестве примера прилагаю листинг компоненты CakePHP для переноса Session Storage и MongoDB

mongo = new Mongo($connection_string);
    	/* indexes */
    	$this->mongo->{self::MONGO_DATABASE}->{self::MONGO_COLLECTION}->ensureIndex("id", array('id' => 1));
    	$this->mongo->{self::MONGO_DATABASE}->{self::MONGO_COLLECTION}->ensureIndex("id", array('id' => 1, "expires" => 1));
    	// Register this object as the session handler
    	if ($this->forceSaveHandler) {
	    	session_set_save_handler(
	    			array($this, "open"),
	    			array($this, "close"),
	    			array($this, "read"),
	    			array($this, "write"),
	    			array($this, "destroy"),
	    			array($this, "gc")
	    	);
    	}
    	$this->_timeout = Configure::read('Session.timeout') * 60;
	}
	public function __destruct() {
		try {
			$this->mongo->close();
			session_write_close();
		} catch (Exception $e) {
		}
	}
	public function open() {
		return true;
	}
	public function close() {
		$probability = mt_rand(1, 150);
		if ($probability <= 3) {
			$this->gc();
		}
		return true;
	}

	public function read($id) {
		$cursor = $this->mongo->{self::MONGO_DATABASE}->{self::MONGO_COLLECTION}->find(array("id" => $id));
		if ($cursor->count() == 1) {
			$cursor->next();
		} else {
			return false;
		}
		$result = $cursor->current();
		if (!empty($result) && isset($result['data'])) {
			return $result['data'];
		}
	}

	public function write($id, $data) {
		if (!$id) {
			return false;
		}
		
		$expires = time() + $this->_timeout;
		$session = array("id" => $id, "data" => $data, "expires" => $expires);
        $filter = array("id" => $id);
    	
        $options = array(
        	'safe'    => true,
        	'fsync'   => true,
        );
        $collection = $this->mongo->{self::MONGO_DATABASE}->{self::MONGO_COLLECTION};
        if ($collection->findOne($filter) == null) {
        	return $collection->insert(am( array("_id" => new MongoId($id)), $session), $options);
        } else {
        	return $collection->update($filter, array('$set' => $session), am($options, array('upsert' => false)));
        }
	}

	public function destroy($id) {
   		return $this->mongo->{self::MONGO_DATABASE}->{self::MONGO_COLLECTION}->remove(array("id" => $id), true);
	}
	public function gc($time = null) {
		if (empty($time)) {
			$time = time();
		}
		return $this->mongo->{self::MONGO_DATABASE}->{self::MONGO_COLLECTION}->remove(array("expires" => array('$lt' => $time)), true);
	}
}

Реализация компоненты для переноса сессий в MySQL  аналогична и отличается лишь в реализации функций  gc(), destroy(), open(), write(), read(), close()

Примечание: Не забудьте изменить конфигурационный файл core.php проекта.

Configure::write('Session', array(
		'defaults' => 'database',
		'handler' => array('engine' => 'MongoSession')
	));

Также можно добавить защиту на количество одновременных запросов в рамках одной сессии. На нашем проекте этим занималась отдельная компонента, отвечающая за тяжеловесные операции в целом – HighWeightedOperationsComponent.php, которая отслеживала количество операций, запущенный в рамках одной сессии  и в зависимости от настроек разрешала запускать строго определенное количество операций.

Итог:

  1. Сменен Session Storage. Блокировок сессии больше нет.

  2. На клиенте инициируется ajax-запрос на старт операции.

  3. Реализована серия Polling-запросов на обновление статуса и progress-bar.

  4. Создана компонента управления “тяжелыми операциями”.

Long polling

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

Принцип: клиентский скрипт обращается к серверу и говорит: "Eсли у тебя появятся данные, я их готов сразу забрать, а потом снова подключусь". В некоторых реализациях сервера существует буферизация, когда сервер не сразу отдает данные, а ждет: вдруг сейчас появится что-то еще, тогда отправлю все сразу. Но такая буферизация вредна, так как вносит задержки, а мы хотим достичь максимальной скорости! После получения данных браузер должен снова открыть новое соединение. Длительность такого соединения может измеряться часами, но это в теории. Обычно время намного меньше и максимум достигает 5 минут, после которых просто создается новое соединение. Делается это потому, что серверы не любят такие долгоживущие сессии, да и сам протокол HTTP не очень приспособлен к такому использованию.

На проекте такой подход использован не был, но обсуждения были.

Forever IFrame

Подход экспериментально был использован на проекте.

Коротко: На странице создается скрытый iFrame, который инкрементально рендерит информацию о прогрессе операции или выполняет java-скрипты. Но для этого HTTP-сервер и PHP должны быть настроены так, чтобы умели отдавать данные кусочно в процессе выполнения операции. Об этом рассказано подробно в следующем пункте.

Итак, мы инициируем выполнение операции через скрытый на странице элемент iFrame. На сервере операция отдает данные вызов кусочно и тут-же отсылает клиенту ответ, IFrame пытается выполнить присланный ответ:

Streaming

Подход экспериментально был использован на проекте.

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

Сначала нам нужно было найти и применить соответствующие настройки для Apache-сервера и PHP, что привело к поиску информации в сети и серии тестов. В итоге настройки выглядят следующим образом:

public function prepare() {
		// Turn off output buffering
		ini_set('output_buffering', 'off');
		
		// Turn off PHP output compression
		ini_set('zlib.output_compression', false);
		
		// Implicitly flush the buffer(s)
		ini_set('implicit_flush', true);
		ob_implicit_flush(true);
		
		// Clear, and turn off output buffering
		while (ob_get_level() > 0) {
			// Get the curent level
			$level = ob_get_level();
			// End the buffering
			ob_end_clean();
			// If the current level has not changed, abort
			if (ob_get_level() == $level) break;
		}
		
		// Disable apache output buffering/compression
		if (function_exists('apache_setenv')) {
			apache_setenv('no-gzip', '1');
			apache_setenv('dont-vary', '1');
		}
	}

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

Comet-сервер

Согласно “Wikipedia”,  Comet  — любая модель работы web-приложения, при которой постоянное HTTP-соединение позволяет веб-серверу отправлять данные браузеру без дополнительного запроса со стороны браузера.  

Общая черта таких моделей состоит в том, что все они основаны на технологиях, непосредственно поддерживаемых браузером (например, JavaScript), а не на проприетарных плагинах.

На данном проекте была попытка использования реализации Comet-сервера  Dklab Realplexor .

Dklab Realplexor — это Comet-сервер, позволяющий держать одновремено сотни тысяч долгоживущих открытых HTTP-соединений с браузерами пользователей. JavaScript-код, запущенный в браузере, подписывается на один или несколько каналов Realplexor-а и вешает обработчик на поступление данных. Сервер может в любой момент записать сообщение в один из таких каналов, и оно будет моментально передано всем подписчикам (хоть одному, хоть тысяче), в режиме реального времени и с минимальной нагрузкой для сервера. 

Web-sockets

Также обсуждалась возможность использования WebSockets. Но от этого подхода мы отказались по причине “не поддержки” старых браузеров.

WebSocket — протокол полнодуплексной связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером в режиме реального времени.

Итог:

В результате задача была реализована Polling-подходом, с переносом сессий в MongoDB. Но последующие обсуждения, увеличение количество “тяжеловесных” задач и их сложности привели к применению более стандартного и надежного решения – реализации очереди выполняемых задач с использованием CRON-планировщика. Действительно, после проверки всех прав на исполняемую операцию мы можем сохранить ее контекст в базу (таблица cron_taks), предварительно сериализовав его. На сервере по определенным интервалам времени будет запускаться CRON shell, который будет брать очередную задачу из очереди, менять ее статус на IN_PROGRESS и передавать ее соответствующему обработчику (TaskDispatcherComponent). Обработчик будет принимать десериализованный контекст запланированной задачи и исполнять ее в отдельном процессе. Ему доступны все модели и компоненты системы. Для определения статуса исполняемой задачи можно воспользоваться подходами Polling и LongPolling, а также организовать просмотр очереди своих задач отдельным отображением. Такой подход оказался более надежным и понятным, хоть и требует определенных архитектурных изменений в системе.

Комментарии

комментарии