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

в 6:00, , рубрики: alawar, alawar entertainment, lock, php, redis, rediska, yii, Блог компании «Alawar Entertainment», блокировка, Веб-разработка, метки: , , , , , , ,

Введение

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

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

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

  • отсутствие 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 );
				}
			}

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

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

Автор: andreysapegin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js