Компонент Lock

Компонент Lock создаёт и управляет блокировками - механизмом, который предоставляет эксклюзивный доступ к общему источнику.

Установка

1
$ composer require symfony/lock

Также вы можете клонировать репозиторий https://github.com/symfony/lock.

Note

If you install this component outside of a Symfony application, you must require the vendor/autoload.php file in your code to enable the class autoloading mechanism provided by Composer. Read this article for more details.

Использование

Блокировки используются для гарантии эксклюзивного доступа к некоторыми общим источникам. В приложениях Symfony вы можете использовать блокировки, к примеру, чтобы гарантировать, что одномоментно команда не будет выполнена больше, чем единожды (на одном или разных серверах).

Блокировки создаются классом Factory, который в свою очередь подключает другой класс для управление хранением блокировок:

1
2
3
4
5
use Symfony\Component\Lock\Factory;
use Symfony\Component\Lock\Store\SemaphoreStore;

$store = new SemaphoreStore();
$factory = new Factory($store);

Блокировка создаётся вызовом метода createLock(). Её первый аргумент - произвольная строка, которая представляет заблокированный ресурс. Потом вызов метода acquire() попытается получить блокировку:

1
2
3
4
5
6
7
8
9
// ...
$lock = $factory->createLock('pdf-invoice-generation');

if ($lock->acquire()) {
    // Источник "pdf-invoice-generation" заблокирован.
    // Здесь вы можете безопасно вычислять и генерировать счёт.

    $lock->release();
}

Если блокировку нельзя вычислить, метод возвращает false. Метод acquire() может быть безопасно вызван несколько раз, даже если блокировка уже вычислена.

Note

В отличие от других реализаций, Компонент Lock различает экземпляры блокировок даже когда они создаются для одного и того же источника. Если блокировка должны быть использована несколькими сервисами, они должны иметь одинаковый экземпляр Lock, возвращённый методом Factory::createLock.

Tip

Если вы не выпустите блокировку ясно, то он будет автоматически выпущен при разрушении экземпляра. В некоторых случаях, может быть полезно заблокировать источник по нескольким запросам. Чтобы отключить поведение автоматичекого релиза, установите третий аргумент метода createLock(), как false.

Блокировка блокировок

По умолчанию, когда блокировку нельзя извлечь, метод acquire немедленно возвращает false. Чтобы (бесконечно) ждать, пока будет создана блокировка, передайте true в качестве аргумента метода acquire(). Это называется блокировка блокировки, так как выполнение вашего приложения останавливается, пока не будет вычислена блокировка.

Некоторые из встроенных классов Store поддерживают эту функцию. Когда этого нет, они могут быть украшены классом RetryTillSaveStore:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use Symfony\Component\Lock\Factory;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\Lock\Store\RetryTillSaveStore;

$store = new RedisStore(new \Predis\Client('tcp://localhost:6379'));
$store = new RetryTillSaveStore($store);
$factory = new Factory($store);

$lock = $factory->createLock('notification-flush');
$lock->acquire(true);

Блокировки с истечением срока действия

Блокировками, созданными удалённо, тяжело управлять, так как удалённому Store невозможно знать, жив ли ещё процесс блокировки. В связи с багами, неустранимыми ошибками или ошибками сегментации, невозможно гарантировать, что метод release() будет вызван, что приведёт к неограниченной блокировке источника.

Лучшим решением в этом случае будет создание блокировок с истечением срока действия, которые выпускаются автоматически после того, как прошло некоторое время (называемые ВЖ - Время Жизни). Это время (в секундах) конфигурируется в качестве второго аргумента метода createLock(). Если необходимо, эти блокировки также могут быт выпущены раньше, с помощью метода release().

Самая сложная часть при работе с блокировками со сроком действия - это выбор правильного ВЖ. Если оно слишком короткое, другие процессы могут получить блокировку до окончания работы; если оно слишком длинное и процесс вызовет сбой до вызова метода release(), источник останется заблокирован до тайм-аута:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ...
// создать блокировку со сроком действия 30 секунд
$lock = $factory->createLock('charts-generation', 30);

$lock->acquire();
try {
    // выполнить работу быстрее, чем за 30 секунд
} finally {
    $lock->release();
}

Tip

Чтобы не оставлять блокировку в состоянии блокировки, рекомендуется обернуть задачу в блок try/catch/finally, чтобы всегда пытаться выпускать блокировку с истекающим сроком действия.

В случае долгосрочных задач, лучше начинать с не очень долгого ВЖ и потом использовать метод refresh(), чтобы переустановить ВЖ в его изначальное значение:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ...
$lock = $factory->createLock('charts-generation', 30);

$lock->acquire();
try {
    while (!$finished) {
        // выполнить маленькую часть задачи.

        // обновить блокировку ещё на 30 секунд.
        $lock->refresh();
    }
} finally {
    $lock->release();
}

Tip

Другая полезная техника для долгоработающих задач - передача своего TTL как аргумента метода refresh() для изменения TTL блокировки по умолчанию:

1
2
3
4
5
6
7
$lock = $factory->createLock('charts-generation', 30);
// ...
// обновить блокировку на 30 секунд
$lock->refresh();
// ...
// обновить блокировку на 600 секунд (следующий вызов refresh() будет опять на 30 секунд)
$lock->refresh(600);

New in version 4.1: Возможность передать своего TTL как аргумента метода refresh() появилась в Symfony 4.1.

Доступные хранилища

Блокировки создаются и управляются в Stores, которые являются классами, реализующими StoreInterface. Компонент включает в себя следующие встроенные типы хранилищ:

Хранилище Область Блокировка Истечение срока
FlockStore local да нет
MemcachedStore remote нет да
RedisStore remote нет да
SemaphoreStore local да нет

FlockStore

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

1
2
3
4
use Symfony\Component\Lock\Store\FlockStore;

// аргумент - это путь каталога, где создаются блокировки
$store = new FlockStore(sys_get_temp_dir());

Caution

Имейте в виду, что некоторые файловые системы (например, некоторые типы NFS), не поддерживают блокировку. В таких случаях, лучше использовать каталог на локальном диске или удалённом хранилище, основанном на Redis или Memcached.

MemcachedStore

MemcachedStore сохраняет блокировки на сервере Memcached, он требует подключения Memcached, реализующего класс \Memcached. Это хранилище не поддерживает блокировку, и ожидает TTL (Time To Live - время жизни), чтобы избежать затянутых блокировок:

1
2
3
4
5
6
use Symfony\Component\Lock\Store\MemcachedStore;

$memcached = new \Memcached();
$memcached->addServer('localhost', 11211);

$store = new MemcachedStore($memcached);

Note

Memcached не поддерживает TTL менее 1 секунды.

RedisStore

RedisStore сохраняет блокировки на сервере Redis, он требует подключения Redis, реализующего классы \Redis, \RedisArray, \RedisCluster или \Predis. Это хранилище не поддерживает блокировку, и ожидает TTL, чтобы избежать затянутых блокировок:

1
2
3
4
5
6
use Symfony\Component\Lock\Store\RedisStore;

$redis = new \Redis();
$redis->connect('localhost');

$store = new RedisStore($redis);

SemaphoreStore

SemaphoreStore использует функции PHP-семафора для создания блокировок:

1
2
3
use Symfony\Component\Lock\Store\SemaphoreStore;

$store = new SemaphoreStore();

CombinedStore

CombinedStore создан для приложений Высокой Доступности, так как он синхронно управляет несколькими хранилищами (например, несколькими серверами Redis). Когда блокировка обнаружена, он перенаправляет вызов ко всем управляемым хранилищам, и собирает их ответы. Если простое большинство хранилищ обнаружили блокировку, то она считается обнаруженной; иначе - нет:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use Symfony\Component\Lock\Strategy\ConsensusStrategy;
use Symfony\Component\Lock\Store\CombinedStore;
use Symfony\Component\Lock\Store\RedisStore;

$stores = [];
foreach (array('server1', 'server2', 'server3') as $server) {
    $redis= new \Redis();
    $redis->connect($server);

    $stores[] = new RedisStore($redis);
}

$store = new CombinedStore($stores, new ConsensusStrategy());

Вместо простой стратегии простого большинства (ConsensusStrategy) можно использовать UnanimousStrategy для запроса обнаружения блокировки во всех хранилищах.

Caution

In order to get high availability when using the ConsensusStrategy, the minimum cluster size must be three servers. This allows the cluster to keep working when a single server fails (because this strategy requires that the lock is acquired in more than half of the servers).

Reliability

The component guarantees that the same resource can't be lock twice as long as the component is used in the following way.

Remote Stores

Remote stores (MemcachedStore and RedisStore) use an unique token to recognize the true owner of the lock. This token is stored in the Key object and is used internally by the Lock, therefore this key must not be shared between processes (session, caching, fork, ...).

Caution

Do not share a key between processes.

Every concurrent process must store the Lock in the same server. Otherwise two different machines may allow two different processes to acquire the same Lock.

Caution

To guarantee that the same server will always be safe, do not use Memcached behind a LoadBalancer, a cluster or round-robin DNS. Even if the main server is down, the calls must not be forwarded to a backup or failover server.

Expiring Stores

Expiring stores (MemcachedStore and RedisStore) guarantee that the lock is acquired only for the defined duration of time. If the task takes longer to be accomplished, then the lock can be released by the store and acquired by someone else.

The Lock provides several methods to check its health. The isExpired() method checks whether or not it lifetime is over and the getRemainingLifetime() method returns its time to live in seconds.

Using the above methods, a more robust code would be:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ...
$lock = $factory->createLock('invoice-publication', 30);

$lock->acquire();
while (!$finished) {
    if ($lock->getRemainingLifetime() <= 5) {
        if ($lock->isExpired()) {
            // lock was lost, perform a rollback or send a notification
            throw new \RuntimeException('Lock lost during the overall process');
        }

        $lock->refresh();
    }

    // Perform the task whose duration MUST be less than 5 minutes
}

Caution

Choose wisely the lifetime of the Lock and check whether its remaining time to leave is enough to perform the task.

Caution

Storing a Lock usually takes a few milliseconds, but network conditions may increase that time a lot (up to a few seconds). Take that into account when choosing the right TTL.

By design, locks are stored in servers with a defined lifetime. If the date or time of the machine changes, a lock could be released sooner than expected.

Caution

To guarantee that date won't change, the NTP service should be disabled and the date should be updated when the service is stopped.

FlockStore

By using the file system, this Store is reliable as long as concurrent processes use the same physical directory to stores locks.

Processes must run on the same machine, virtual machine or container. Be careful when updating a Kubernetes or Swarm service because for a short period of time, there can be two running containers in parallel.

The absolute path to the directory must remain the same. Be careful of symlinks that could change at anytime: Capistrano and blue/green deployment often use that trick. Be careful when the path to that directory changes between two deployments.

Some file systems (such as some types of NFS) do not support locking.

Caution

All concurrent processes must use the same physical file system by running on the same machine and using the same absolute path to locks directory.

By definition, usage of FlockStore in an HTTP context is incompatible with multiple front servers, unless to ensure that the same resource will always be locked on the same machine or to use a well configured shared file system.

Files on file system can be removed during a maintenance operation. For instance to cleanup the /tmp directory or after a reboot of the machine when directory uses tmpfs. It's not an issue if the lock is released when the process ended, but it is in case of Lock reused between requests.

Caution

Do not store locks on a volatile file system if they have to be reused in several requests.

MemcachedStore

The way Memcached works is to store items in memory. That means that by using the MemcachedStore the locks are not persisted and may disappear by mistake at anytime.

If the Memcached service or the machine hosting it restarts, every lock would be lost without notifying the running processes.

Caution

To avoid that someone else acquires a lock after a restart, it's recommended to delay service start and wait at least as long as the longest lock TTL.

By default Memcached uses a LRU mechanism to remove old entries when the service needs space to add new items.

Caution

Number of items stored in the Memcached must be under control. If it's not possible, LRU should be disabled and Lock should be stored in a dedicated Memcached service away from Cache.

When the Memcached service is shared and used for multiple usage, Locks could be removed by mistake. For instance some implementation of the PSR-6 clear() method uses the Memcached's flush() method which purges and removes everything.

Caution

The method flush() must not be called, or locks should be stored in a dedicated Memcached service away from Cache.

RedisStore

The way Redis works is to store items in memory. That means that by using the RedisStore the locks are not persisted and may disappear by mistake at anytime.

If the Redis service or the machine hosting it restarts, every locks would be lost without notifying the running processes.

Caution

To avoid that someone else acquires a lock after a restart, it's recommended to delay service start and wait at least as long as the longest lock TTL.

Tip

Redis can be configured to persist items on disk, but this option would slow down writes on the service. This could go against other uses of the server.

When the Redis service is shared and used for multiple usages, locks could be removed by mistake.

Caution

The command FLUSHDB must not be called, or locks should be stored in a dedicated Redis service away from Cache.

CombinedStore

Combined stores allow to store locks across several backends. It's a common mistake to think that the lock mechanism will be more reliable. This is wrong The CombinedStore will be, at best, as reliable as the least reliable of all managed stores. As soon as one managed store returns erroneous information, the CombinedStore won't be reliable.

Caution

All concurrent processes must use the same configuration, with the same amount of managed stored and the same endpoint.

Tip

Instead of using a cluster of Redis or Memcached servers, it's better to use a CombinedStore with a single server per managed store.

SemaphoreStore

Semaphores are handled by the Kernel level. In order to be reliable, processes must run on the same machine, virtual machine or container. Be careful when updating a Kubernetes or Swarm service because for a short period of time, there can be two running containers in parallel.

Caution

All concurrent processes must use the same machine. Before starting a concurrent process on a new machine, check that other process are stopped on the old one.

Overall

Changing the configuration of stores should be done very carefully. For instance, during the deployment of a new version. Processes with new configuration must not be started while old processes with old configuration are still running.

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.