Shared state, как известно необходимо защищать. Иначе параллельные потоки могут его “поломать”. Это относится и к web-приложениям. Несмотря на отсутствие вменяемой поддержки параллелизма в большинстве web-ориентированных языков (PHP, Python, Ruby), concurrency в web-приложениях хватает. Запросы приходят на web-сервер параллельно, исполняются на разных процессорах параллельно и т.д. По этой причине следующий код некорректен:

$bulletin = $manager->findBulletinById($request->id);
$bulletin->setHits( $bulletin->getHits()+1 );
$manager->saveBulletin($bulletin);

Два параллельных потока могут одновременно загрузить объявление, одновременно инкрементировать счетчик, и записать объявление обратно. Это приведет к тому, что один инкремент потеряется. В общем случае, если N потоков выполняют данный код одновременно, может быть потеряно до N - 1 update’ов.

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

У нас в системе есть небольшая библиотека позволяющая реализовать в коде исключающую блокировку. Ее использование выглядит примерно следующим образом:

$lock = LockManager::getLock("bulletin:{$request->id}");
try {
  $bulletin = $manager->findBulletinById($request->id);
  $bulletin->setHits( $bulletin->getHits()+1 );
  $manager->saveBulletin($bulletin);
  $lock->release();
} catch ( Exception $e ) {
  $lock->release();
}

Пессимистическая блокировка схожа с принципом Мерфи. Она предполагает, что если что-то плохое может случится, это обязательно случится. В отличии от пессимистической, оптимистическая блокировка предполагает что во время обновления записи в БД мы будем единственными кто ее меняет. В большинстве случаев, так и есть, так что оптимизм оправдан. Тем не менее, во время UPDATE’а мы проверяем наверняка изменилась ли запись с момента ее чтения. И если изменилась, то мы обязаны прочитать последнюю версию записи из БД и повторить нашу операцию с ней.

Реализация Link to heading

Реализуется это довольно просто. Достаточно хранить с каждой записью в БД идентификатор версии и при записи проверять что он не изменился и менять его. Алгоритм выглядит следующим образом.

public function saveBulletin(Bulletin $bulletin) {
	$connection->prepareStatement("UPDATE bulletins SET version = version + 1 ... ".
		"WHERE id = :id AND version = :version")
	->int('id', $bulletin->getId())
	->int('version', $bulletin->getVersion())
	->execute();
	if ( $connection->rowsAffected() <= 0 ) {
		throw new ConcurrentModificationException();
	}
}

В данном методе при обновлении мы проверяем, что версия не изменилась, а это значит, что и запись в БД никто не менял. Если версия изменилась, мы обязаны известить об этом клиента.

Но тут есть одна загвоздка. Что будет делать клиент с этим exception’ом?

try {
	$manager->saveBulletin($bulletin);
} catch ( ConcurrentModificationException $e ) {
	// Huh?!
}

По идее, клиент должен заново прочитать объявление из БД, заново выполнить свою операцию и заново сохранить объявление. И вполне возможно что… заново получить exception, заново прочитать объявление и… Нет, так не пойдет.

В случае, если вы реализуете оптимистическую блокировку, то модель должна взять на себя логику сохранения объектов, иначе вы опухнете писать клиентов. Модель должна предоставить иной, более удобный интерфейс для оперирования над объектами. Используя возможности PHP/5.3 можно сделать следующее:

$manager->processBulletin($request->id, function(Bulletin $b) {
	$b->setHits( $b->getHits()+1 );
});

Как видно, в данном случае клиент не загружает и не сохраняет объявление. А это значит, что и с конкурентными изменениями ему иметь дело не требуется, — все это ответственность модели. Реализовать в модели цикл сохранения объекта с обработкой ошибок — дело техники.

Преимущества “оптимистов над пессимистами” Link to heading

Не блокирует клиентов, которые не меняют состояние Link to heading

Представьте себе такой код:

$bulletin = $manager->findBulletinById(...);
if ( $bulletin->getText() != $request->text ) {
	$bulletin->setText($request->text);
	$manager->saveBulletin($bulletin);
}

В случае пессимистической блокировки мы обязаны взять lock перед загрузкой объявления. Но возможно, что нам даже менять его не потребуется. Тем не менее, мы потенциально можем быть заблокированы на lock’е, даже если ничего не будем менять. В случае оптимистической блокировки мы просто не сохраняем объявление.

Избавляет клиента от необходимости заботится о lock’ах Link to heading

В случае оптимистической блокировки клиентский код проще и этого кода требуется меньше. Меньше кода → меньше проблем.

Гарантировано защищает данные Link to heading

Когды вы оперируете lock’ами могут случиться четыре типа проблем:

  • вы возьмете слишком мало локов (поломанные данные);
  • вы возьмете слишком много локов (deadlock, starvation);
  • вы возьмете не те локи (поломанные данные);
  • вы возьмете те локи, но не в том порядке (deadlock).

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

Худшее что может случится в случае оптимистической блокировки — клиент получит exception. Худшее что может случится в случае пессимистической блокировки — вы “поломаете” данные.

Что вы выбираете?