Реализация Lock’ов на сайтах Alawar

от автора

Введение

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

Часто для обеспечения такой блокировки используется схема с созданием специального файла, наличие которого определяет факт занятости того или иного ресурса.

Такой подход достаточно прост в реализации, но имеет ряд недостатков. Среди недостатков можно выделить:

  • отсутствие 100% гарантии блокировки при большом количестве потоков;
  • блокировка работает в рамках одного сервера;
  • и самое неприятное – если процесс, который поставил блокировку почему-то её не снял, то остальные процессы так и не смогут получить доступ к этому ресурсу, пока вручную или каким-то другим способом эта блокировка не будет снята.

Когда нужны блокировки?

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

Как сделать самому?

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

При реализации мы определили основные операции при работе с блокировками:

  • взять блокировку на определённое время
  • снять блокировку через заданное время
  • продлить блокировку
  • отработать кейс когда блокировка была перехвачена

На самом деле не очень важно, что будет использоваться в качестве провайдера блокировок, это могут быть и файлы, и mysql, и memcache, и любой другой удобный вам инструмент.

Нам был ближе Redis, поэтому мы реализовали свой механизм блокировок, который не имеет недостатков перечисленных выше, на Redis-е.

Как сделаны блокировки у нас

Сегодня мы представляем вам нашу реализацию «как есть» с дополнительными комментариями почти к каждой строке и примером использования.

Наша реализация используется в проекте на фреймворке Yii и использует для подключения к Redis через библиотеку Rediska. Но завязка на Yii, как и на Rediska, небольшая, поэтому этот код можно будет использовать в любом проекте на PHP.

Итак, приступим к самому интересному:

Базовый класс блокировки
<?php /**  * Универсальный класс блокировок  *  */ class Lock { 	/** 	 * Возвращает ключ по которому производится блокировка 	 * 	 * @param string $key 	 * @return string 	 */ 	static protected function getKey( $key ) 	{ 		return $key; 	}  	/** 	 * Сообщает true, если блокировку сделал текущий инстанс скрипта 	 * 	 * @param string $key - ключ лока 	 * @param float $timeWait - время ожидания на захват лока в секундах 	 * @param float $maxExecuteTime - время на выполнение операции в секундах 	 * @return bool 	 */ 	static public function getLock( $key, $timeWait = 0, $maxExecuteTime = 3600 ) 	{ 		throw new Lock_Exception('Not defined method getLock'); 		return false; 	}  	/** 	 * Определение идентификатора текущего процесса кросс-серверно 	 * 	 * @return string 	 */ 	static protected function getCurrentProcessId() 	{ 		static $myProcessId = false;  		if ( $myProcessId === false ) 		{ 			$uname = posix_uname(); 			$mypid = getmypid(); 			$myProcessId = $uname['nodename'] . '_' . $mypid; 		}  		return $myProcessId; 	}  	/** 	 * Снятие лока 	 * 	 * @param string $key - ключ лока 	 * @param float $delayAfter - установка времени жизни блокировки после снятия лока в секундах 	 * @return bool 	 */ 	static public function releaseLock( $key, $delayAfter = 0 ) 	{ 		throw new Lock_Exception('Not defined method releaseLock'); 		return false; 	}  	/** 	 * Попытка продлить время лока 	 * 	 * @param string $key - ключ лока 	 * @param float $timeProlongate - время продления лока в секундах 	 * @return bool - если вернулся false, значит продлить не получилось 	 */ 	static public function prolongate( $key, $timeProlongate ) 	{ 		throw new Lock_Exception('Not defined method prolongate'); 		return false; 	} }  class Lock_Exception extends Exception {  } class Timeout_Lock_Exception extends Lock_Exception {  } class LostLock_Timeout_Lock_Exception extends Timeout_Lock_Exception {  } 

Класс RedisLock

В этом классе блокировки осуществляются с использованием Redis:

<?php /**  * Универсальный класс блокировок на базе Redis  *  */ class RedisLock extends Lock { 	/** 	 * Возвращает ключ в noSQL хранилище 	 * 	 * @param string $key 	 * @return string 	 */ 	static protected function getKey( $key ) 	{ 		// используем префикс lock@ для разделения с остальными ключами 		return 'lock@'.$key; 	}  	/** 	 * Сообщает true, если блокировку сделал текущий инстанс скрипта 	 * 	 * @param string $key - ключ лока 	 * @param float $timeWait - время ожидания на захват лока в секундах 	 * @param float $maxExecuteTime - время на выполнение операции в секундах 	 * @param integer $policy - политика захвата лока:  	 * 		0 - если лок занят на долго, то даже не пытаемся его брать,  	 * 		1 - проверяем каждые 10мс, освободился ли лок 	 * @return bool 	 */ 	static public function getLock( $key, $timeWait = 0, $maxExecuteTime = 3600, $policy = 0 ) 	{ 		/** 		 * время, когда залочивание станет неактуальным 		 */ 		$timeStop = microtime(true) + $timeWait;  		// в данном примере используется Yii и подключение к Redis через библиотеку Rediska 		$rediska = Yii::app()->rediskaConnection->connect();  		while ( true ) 		{ 			$currentTime = microtime(true);  			if ( $policy == 0 ) 			{ 				/** 				 * посмотрим время, когда планируется освободить лок 				 */ 				$expireAt = $rediska->getFromHash( self::getKey($key), 'expireAt' );  				/** 				 * если время, на которое был поставлен лок, превышает наше допустимое время ожидания, 				 * то даже не встаем в очередь за локом 				 */ 				if ( $expireAt > $timeStop ) 				{ 					return false; 				} 				/** 				 * иначе если надо немного подождать, то подождём то самое время, когда лок освободится 				 */ 				elseif ( $expireAt > $currentTime ) 				{ 					usleep( 1000000 * intval($expireAt - $currentTime) ); 					$currentTime = microtime(true); 				} 			} 			elseif ( $policy == 1 ) 			{ 				/** 				 * посмотрим время, когда планируется освободить лок 				 */ 				$expireAt = $rediska->getFromHash( self::getKey($key), 'expireAt' );  				while ( $expireAt > $timeStop || $expireAt > $currentTime ) 				{ 					usleep( 10000 );  					/** 					 * посмотрим время, когда планируется освободить лок 					 */ 					$expireAt = $rediska->getFromHash( self::getKey($key), 'expireAt' );  					$currentTime = microtime(true);  					if ( $currentTime >= $timeStop ) 					{ 						return false; 					} 				} 			}  			$getLock = false;  			/** 			 * пробуем перехватить старый лок 			 * конструкция getConnectionByKeyName нужна для тех проектов, где используется более одного инстанса Redis 			 */ 			$transaction = $rediska->transaction( $rediska->getConnectionByKeyName( self::getKey($key) ) ); 			$transaction->watch( self::getKey($key) );  			$arData = $rediska->getHash( self::getKey($key) ); 			// идентификатор владельца текущего захвата 			$daddy = isset($arData['daddy']) ? $arData['daddy'] : ''; 			// ожидаемое время освобождения захвата 			$expireAt = isset($arData['expireAt']) ? $arData['expireAt'] : 0;  			/** 			 * если лок не снят, а время его блокировки истекло, то пробуем перехватить лок 			 * также этот кейз сработает в самый первый раз 			 */ 			if ( $daddy != self::getCurrentProcessId() && $expireAt < $currentTime ) 			{ 				$transaction->setToHash( 					self::getKey($key), 					array( 						'daddy' => self::getCurrentProcessId(), 						'expireAt' => $currentTime + $maxExecuteTime 					) 				); 				$transaction->expire( self::getKey($key), ceil($currentTime + $maxExecuteTime), true );  				try { 					$transaction->execute(); 					$getLock = 1; 				} 				catch ( Rediska_Transaction_Exception $e ) 				{ 					/** 					 * перехват лока не произошел 					 */ 					$getLock = false; 				} 			} 			else 			{ 				$getLock = false; 				$transaction->discard(); 			}  			/** 			 * попытка взять лок обычным способом 			 */ 			if ( $getLock != 1 ) 			{ 				// HSETNX 				$getLock = $rediska->setToHash( self::getKey($key), 'daddy', self::getCurrentProcessId(), false ); 			}  			/** 			 * если лок наш 			 */ 			if ( $getLock == 1 ) 			{ 				/** 				 * обновим время, до которого поставлена блокировка 				 */ 				$rediska->setToHash(self::getKey($key),	'expireAt', $currentTime + $maxExecuteTime); 				$rediska->expire(self::getKey($key), ceil($currentTime + $maxExecuteTime), true);  				return true; 			} 			else 			{ 				/** 				 * если ещё есть время на повторную попытку, то сделаем её с небольшой задержкой 				 */ 				if ( $timeStop > $currentTime ) 				{ 					usleep(20000); 				} 				/** 				 * иначе сообщаем, что лока нет 				 */ 				else 				{ 					return false; 				} 			} 		} 	}  	/** 	 * Снятие лока 	 * 	 * @param string $key - ключ лока 	 * @param float $delayAfter - установка времени жизни блокировки после снятия лока в секундах 	 * @return bool 	 */ 	static public function releaseLock( $key, $delayAfter = 0 ) 	{ 		$currentTime = microtime(true);  		$rediska = Yii::app()->rediskaConnection->connect();  		$transaction = $rediska->transaction( $rediska->getConnectionByKeyName( self::getKey($key) ) ); 		$transaction->watch( self::getKey($key) );  		$arData = $rediska->getHash( self::getKey($key) ); 		if ( is_array($arData) && isset($arData['daddy']) && isset($arData['expireAt']) ) 		{ 			$daddy = $arData['daddy']; 			$expireAt = $arData['expireAt']; 		} 		else 		{ 			$daddy = false; 			$expireAt = 0; 		}  		/** 		 * Если на текущий момент лок был поставлен этим же процессом, то пробуем снять лок 		 */ 		if ( $daddy == self::getCurrentProcessId() ) 		{ 			$transaction->setToHash(self::getKey($key), 'expireAt', $currentTime + $delayAfter); 			$transaction->expire(self::getKey($key), ceil($currentTime + $delayAfter), true); 			$transaction->deleteFromHash( self::getKey($key), 'daddy' ); 			/** 			 * Попытка атомарно снять лок 			 */ 			try { 				$transaction->execute(); 				$result = true; 			} 			catch (Rediska_Transaction_Exception $e) 			{ 				$result = false; 			} 		} 		else 		{ 			$transaction->discard(); 			$result = false; 		}  		// если разлочивание сделали позже обозначенного времени 		if ( $expireAt < $currentTime ) 		{ 			if ( $result ) 			{ 				/** 				 * всё получилось, но продлевать надо вовремя 				 */ 				throw new Timeout_Lock_Exception('Timeout Lock on release'); 			} 			else 			{ 				/** 				 * значит лок был перехвачен другим процессом 				 */ 				throw new LostLock_Timeout_Lock_Exception('Timeout Lock and it was lost before release'); 			} 		} 		return $result; 	}  	/** 	 * Попытка продлить время лока 	 * 	 * @param string $key - ключ лока 	 * @param float $timeProlongate - время продления лока в секундах 	 * @return bool - если вернулся false, значит продлить не получилось 	 */ 	static public function prolongate( $key, $timeProlongate ) 	{ 		$rediska = Yii::app()->rediskaConnection->connect();  		$transaction = $rediska->transaction( $rediska->getConnectionByKeyName( self::getKey($key) ) ); 		$transaction->watch( self::getKey($key) );  		$arData = $rediska->getHash( self::getKey($key) ); 		$daddy = $arData['daddy']; 		$expireAt = $arData['expireAt'];  		$currentTime = microtime(true);  		$result = false; 		if ( $daddy == self::getCurrentProcessId() ) 		{ 			$transaction->setToHash( self::getKey($key), 'expireAt', $currentTime + $timeProlongate ); 			$transaction->expire(self::getKey($key), ceil($currentTime + $timeProlongate), true); 			try { 				$transaction->execute(); 				$result = true; 			} 			catch (Rediska_Transaction_Exception $e) 			{ 				$result = false; 			} 		} 		else 		{ 			$transaction->discard(); 			$result = false; 		}  		if ( $expireAt < $currentTime ) 		{ 			if ( $result ) 			{ 				throw new Timeout_Lock_Exception('Timeout Lock on prolongate'); 			} 			else 			{ 				throw new LostLock_Timeout_Lock_Exception('Timeout Lock and Lost them on prolongate'); 			} 		}  		return $result; 	} } 

Пример использования

Допустим, у нас есть скрипт, который формирует отчёты. И этот скрипт не имеет смысла запускать одновременно в несколько потоков, т.к. они все в результате выдадут один и тот же результат, но каждый потребует в ходе работы большое количество ресурсов. Мы знаем, что этот скрипт в среднем отрабатывает за 40-50 минут, поэтому сделаем небольшой запас и поставим блокировку на 60 минут.

			$lockKey = 'cron-report'; 			$timeWait = 0; 			$timeLock = 3600;  			if ( RedisLock::getLock( $lockKey, $timeWait, $timeLock ) ) 			{ 				// лок взяли, теперь работаем с ресурсом 				...  				// когда время подошло к концу или закончили работу с ресурсом, то пробуем снять лок 				try 				{ 					RedisLock::releaseLock( $lockKey, 0 ); 					echo 'Ok'; 				} 				catch ( Timeout_Lock_Exception $e ) 				{ 					// опасная ситуация 					// этот кейс говорит о том, что ресурс бронировался на меньшее время, чем он был использован и сейчас он ещё не занят другим процессом 					echo 'Timeout_Lock_Exception ' . ( $endTime - $currentTime ); 				} 				catch ( LostLock_Timeout_Lock_Exception $e ) 				{ 					// плохая ситуация 					// этот кейс говорит о том, что ресурс бронировался на меньшее время, чем он был использован и на момент снятия лока уже был занят другим процессом 					echo 'LostLock_Timeout_Lock_Exception' . ( $endTime - $currentTime ); 				} 			} 

Надеемся, что наша реализация будет вам полезна.

Ждём ваши вопросы и комментарии.

ссылка на оригинал статьи http://habrahabr.ru/company/alawar/blog/158799/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *