Глобальные блокировки на MongoDB

// Январь 31st, 2013 // Doctrine 2, Highload, MySQL, NoSQL

cluster_iconПредставим себе типичный стартап. Начинается всё с маленького хостинга за пару баксов, потом покупается VPS или даже VDS. Потом убирается первая буква V, и проект переезжает на dedicated а то и colocation. Ну, а через какое-то время у нас уже несколько серверов — один для базы, другой — веб-сервер. А потом….

… приходит тот замечательный момент, когда веб-серверов уже несколько и деплоятся они одновременно. Получается MySQL сервер один(ну или кластер с одним мастером), а вот веб-серверов множество, и миграции на них запускаются одновременно. Что же делать? Как не повредить базу при этом?

active-active-dns-cluster

Задача

Итак, нам необходимо сделать так, чтобы мы могли безболезненно запускать миграции на нескольких веб-серерах при наличии одного сервера баз данных (или кластера с одним мастером).

Решение

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

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

Один вопрос, где мы будем хранить эти глобальные блокировки? К ним ведь есть ряд требований.

  • Глобальная доступность. Мы должны иметь возможность устанавливать и снимать блокировки с любого узла кластера веб-серверов.
  • Атомарность. Операция получения блокировки должна быть атомарна в пределах кластера.

Вначале я обратил свой взгляд собственно на MySQL с его функциями get_lock() и release_lock() . С get_lock() у меня возникла засада. Из консоли MySQL всё работало замечательно, но вот из PHP скрипта на Doctrine 2 ничего не получалось. Я использовал Native Query, но ничего не получилось. Промучавшись с ним какое-то время я решил реализовать хранение блокировок в MongoDB.

В комментах заметили: If you have a lock obtained with GET_LOCK(), it is released when you execute RELEASE_LOCK(), execute a new GET_LOCK(), or your connection terminates (either normally or abnormally) — вот поэтому у тебя через консоль получилось, а через ORM нет.

Храним глобальные блокировки в MongoDB

Интерфейс для использования блокировок не мудрствуя лукаво я взял из MySQL: get_lock(), release_lock(), is_free_lock(). Больше для счастья ничего и не надо) Концептуально штуки, который мы будем реализовывать, называются мьютексы.

Мьютексы — простейшие двоичные семафоры, которые могут находиться в одном из двух состояний — отмеченном или неотмеченном (открыт и закрыт соответственно). Когда какой-либо поток, принадлежащий любому процессу, становится владельцем объекта mutex, последний переводится в неотмеченное состояние. Если задача освобождает мьютекс, его состояние становится отмеченным.
Задача мьютекса — защита объекта от доступа к нему других потоков, отличных от того, который завладел мьютексом. В каждый конкретный момент только один поток может владеть объектом, защищённым мьютексом. Если другому потоку будет нужен доступ к переменной, защищённой мьютексом, то этот поток засыпает до тех пор, пока мьютекс не будет освобождён.

Создадим в MongoDB колелкци mutexes с документами, в которых будут поля: lockName (имя мьютекса, по смыслу — название блокируемого ресурса), pid (номер процесса, я например использую такой формат — pid@hostname) и createdAt (время создания мьютекса). На основе поля createdAt мы реализуем таймаут для мьютексов. Итак Doctrine 2 ODM Document будет выглядеть так:

My\DefaultBundle\Document\Mutex.php

<?php
namespace My\DefaultBundle\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;

/**
* Mutex или взаимная блокировка
* ВАЖНО: Для всех мьютексов установлен один и тот же TTL (см. поле createdAt)
* gc выполняется фоновым процессом в монге
* @ODM\Document(collection="mutexes", repositoryClass="My\DefaultBundle\Repository\DMutex")
*/

class Mutex extends \DoctrineExtra\ODM\DomainObject
{

/** @ODM\Id */
protected $id;

/** @ODM\String */
protected $name;

/**
* В следующей строке больше свойств не писать (см. getCreatedByTTL())
* @ODM\Date @Index(expireAfterSeconds=3600)
*/
protected $createdAt;

public function setCreatedAt($createdAt)
{
$this->createdAt = $createdAt;
}

public function getCreatedAt()
{
return $this->createdAt;
}
public function setId($id)
{
$this->id = $id;
}

public function getId()
{
return $this->id;
}

public function setName($name)
{
$this->name = $name;
}

public function getName()
{
return $this->name;
}

/**
* Set mutex lifetime, based on current per-collection lifetime(see index)
*/
public static function getCreatedAtByTTL($ttl)
{
// Get Mongo's TTL for whole collection
$prop = new \ReflectionProperty(__CLASS__, 'createdAt');
$docBlock = $prop->getDocComment();
$tmp = explode('expireAfterSeconds=', $docBlock);
$tmp = explode(')', $tmp[1]);
$globalTTL = (int) $tmp[0];

// Calculate offset
$now = new \DateTime();
$now = (int)$now->format('U');
$createdAt = (int) ($now - $globalTTL + $ttl);
$newCreatedAt = new \DateTime();
$newCreatedAt->setTimestamp($createdAt);
return $newCreatedAt;
}

}

Тут есть военная хитрость. Мы используем MongoDB TTL Collections, однако в Монге есть ограничение — на одну коллекцию — один общий TTL (time-to-live), т.е. мы не можем настроить разыне TTL для разных блокировок. Но я всё-таки это сделал. Релизовал я это с помощью пересчёта поля createdAt в зависимости от нужного для конкретной блокировки TTL. Получается и волки сыты, и овцы целы. А если вам понадобится реальное время создания блокировки, то можно сохранять его в другом поле. Дальше я покажу вам файл репозитария, в котором реализуется интерфейс работы с блокировками.

My\DefaultBundle\Repository\DMutex.php

<?php
namespace My\DefaultBundle\Repository;

use \Doctrine\ODM\MongoDB as ODM;

/**
* Интерфейс аналогичен локам в MySQL
* @see http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock
* пример работы:
* $dm = $this->getContainer()->get('doctrine_mongodb.odm.default_document_manager');
* $repo = $dm->getRepository('My\DefaultBundle\Document\Mutex');
* var_dump($repo->isFreeLock('some_resource'));
* $repo->getLock('some_resource');
* var_dump($repo->isFreeLock('some_resource'));
* $repo->releaseLock('some_resource');
* var_dump($repo->isFreeLock('some_resource'));
*/
class DMutex extends ODM\DocumentRepository
{
/**
* Проверяет заблокирован ли ресурс с именем $name
* @param $name
* @return bool
*/
public function isFreeLock($name)
{
$count = $this->dm->getRepository('My\DefaultBundle\Document\Mutex')->createQueryBuilder()
->field('lockName')->equals($name)
->getQuery()->execute()->count();

return ($count > 0)? false : true;
}

/**
* Устанавливает и проверяет блокировку на ресурс с именем $name.
* Если ресурс удаётся заблокировать - возвращает true. Если ресурс перехватил другой процесс - то возвращает false.
* @param $name
* @return bool
*/
public function getLock($name, $ttl = null)
{
/** @var $mongo MongoClient */
$dbName = $this->dm->getConfiguration()->getDefaultDB();
/** @var $metadata \Doctrine\ODM\MongoDB\Mapping\ClassMetadata */
$metadata = $this->getClassMetadata();
$collectionName = $metadata->collection;
$mongo = $this->dm->getConnection()->getMongo()->selectDB($dbName);
// Set lock
$update = array('lockName' => $name, 'pid' => $this->getPid());
if($ttl) {
$dateTime = \My\DefaultBundle\Document\Mutex::getCreatedAtByTTL($ttl);
$mongoDate = new \MongoDate($dateTime->format('U'));
$update['createdAt'] = $mongoDate;
}

$result = $mongo->command(array(
'findandmodify' => $collectionName,
'query' => array('lockName' => $name),
'update' => $update,
'upsert' => true
));

// Validate it's my own lock
$count = $this->dm->getRepository('My\DefaultBundle\Document\Mutex')->createQueryBuilder()
->field('lockName')->equals($name)
->field('pid')->equals($this->getPid())
->getQuery()->execute()->count();
return ($count == 1)? true : false;
}

/**
* Освобождает блокировку ресурса с именем $name
* @param $name
*/
public function releaseLock($name)
{
$this->dm->getRepository('My\DefaultBundle\Document\Mutex')->createQueryBuilder()
->field('lockName')->equals($name)
->remove()->getQuery()->execute();
}

/**
* Возвращает pid в формате pid @ hostname
* @return string
*/
public function getPid()
{
return getmypid().'@'.gethostname();
}
}

А вот простенький пример работы с мьютексами.
MutexDemo.php

$dm = $this->getContainer()->get('doctrine_mongodb.odm.default_document_manager');
$repo = $dm->getRepository('My\DefaultBundle\Document\Mutex');
var_dump($repo->isFreeLock('some_resource'));
$repo->getLock('some_resource', 3600);
var_dump($repo->isFreeLock('some_resource'));
$repo->releaseLock('some_resource');
var_dump($repo->isFreeLock('some_resource'));

Ожидаемый вывод:

1 // ресурс свободен
0 // ресурс занят, т.к. мы установили блокировку
1 // ресурс снова свободен, т.к. мы сняли блокировку

Заключение

Получается теперь мы можем блокирвоать глобально-доступный ресурс в кластере. К тому-же мы преодолели ограничение MongoDB «one-ttl per ttl-collection». В следующей статье, я покажу на практике, как можно использовать глобальные блокировки в веб-проекте.

Ссылки

http://docs.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/indexes.html

http://docs.mongodb.org/manual/tutorial/expire-data/

Share

Спасибо!


Если вам помогла статья, или вы хотите поддержать мои исследования и блог - вот лучший способ сделать это:


2 Responses to “Глобальные блокировки на MongoDB”

Комментировать