Распределенная блокировка с ограниченным временем владения (Lease Locking) — это механизм, при котором ресурс выделяется на строго определенный интервал времени. Если процесс не освобождает ресурс до истечения срока аренды, он принудительно высвобождается. Это предотвращает «зависание» ресурсов и обеспечивает их доступность для других участников системы.

Принцип работы механизма

Система выдает уникальный идентификатор (ID) блокировки вместе с меткой времени начала аренды и длительностью таймаута. Например, при старте задачи на обработку данных выдается ID-12345 с сроком действия 30 секунд.

Процесс должен каждые N секунд (обычно интервал меньше половины таймаута) продлевать аренду, отправляя сигнал «жив» в центральный реестр или хранилище блокировок. Это может быть запись в отдельной таблице, файл с меткой времени или запись в Redis.

Если процесс перестает отвечать (падение, зависание), таймер истечет. Центральный реестр видит отсутствие обновлений и автоматически удаляет запись блокировки через заданный интервал проверки.

Реализация на практике

Выберите хранилище для меток. В высоконагруженных системах используют Redis или Memcached, так как они позволяют проверять наличие записи за миллисекунды. В менее требовательных сценариях подойдет отдельная таблица в базе данных с колонкой `expires_at`.

Настройте интервал обновления. Если таймаут равен 60 секундам, обновляйте запись каждые 15–20 секунд. Это создает запас времени на задержки сети или обработки запроса.

Реализуйте механизм принудительного освобождения. Запланируйте фоновый процесс (cron-задачу или отдельный воркер), который каждую минуту проверяет записи, где `expires_at` меньше текущего времени, и удаляет их.

Кодовые примеры

Пример логики на Python с использованием Redis для хранения блокировок:

«`python
import redis
import time

def acquire_lock(client, resource_id, ttl_seconds):
lock_key = f"lock:{resource_id}"
# Попытка установить блокировку. Если ключ уже есть — возвращаем False.
if not client.set(lock_key, "active", nx=True, ex=ttl_seconds):
return False
return True

def renew_lock(client, resource_id, ttl_seconds):
lock_key = f"lock:{resource_id}"
# Продлеваем срок действия только если ключ принадлежит нам.
if client.get(lock_key) == "active":
client.expire(lock_key, ttl_seconds)
return True
return False

def release_lock(client, resource_id):
lock_key = f"lock:{resource_id}"
# Удаляем только если ключ существует.
client.delete(lock_key)
«`

Пример логики на SQL для таблицы блокировок:

«`sql
— Создание таблицы
CREATE TABLE locks (
id VARCHAR(64) PRIMARY KEY,
resource_id VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);

— Функция получения блокировки (упрощенно)
INSERT INTO locks (id, resource_id, expires_at)
VALUES ('lock-123', 'file-001', NOW() + INTERVAL 30 SECOND)
ON DUPLICATE KEY UPDATE id = VALUES(id);

— Фоновая задача на очистку устаревших блокировок
DELETE FROM locks WHERE expires_at < NOW();
«`

Оценка результата и тестирование

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

Измерьте задержки. В высоконагруженных системах время проверки наличия блокировки должно быть минимальным (менее 10 мс). Если используется база данных, убедитесь, что индексируется колонка `expires_at` или ключ записи в Redis.

Тестируйте конкуренцию. Запустите два процесса, пытающихся захватить один ресурс одновременно. Один должен получить блокировку, второй — ошибку или ожидание.

Ограничения и риски

Риск ложного освобождения. Если таймаут слишком короткий, легитимный процесс может потерять ресурс из-за временной задержки сети или высокой нагрузки на сервер. Увеличивайте таймаут при росте нагрузки.

Проблема «гонки» при обновлении. При продлении блокировки нужно убедиться, что запись принадлежит именно вашему процессу. В Redis это делается проверкой значения перед `EXPIRE`, в БД — через транзакции или проверку владельца.

Нагрузка на хранилище. Если количество ресурсов огромно, фоновая очистка может создать пиковую нагрузку. Используйте постепенное удаление или отдельный воркер с ограничением скорости.

Практические примеры

Сценарий 1: Обработка загруженных файлов. Пользователь загружает файл, система выделяет его на обработку. Таймаут — 5 минут. Если скрипт обработки зависнет, через 5 минут файл станет доступен для другой задачи или будет переработан.

Сценарий 2: Кэширование данных. Сервер кэширует результат сложного вычисления. Блокировка нужна, чтобы не перезаписывать данные в процессе расчета. Таймаут — время жизни кэша плюс запас на обработку запроса.

Частые ошибки

Игнорирование проверки владельца. Попытка продлить блокировку без проверки, что ключ принадлежит текущему процессу, может привести к потере данных другого процесса.

Слишком короткий таймаут. Установка таймаута в 1 секунду для задачи, которая обычно выполняется за 2 секунды, приведет к постоянным конфликтам.

Отсутствие фоновой очистки. Если не удалять старые записи, хранилище блокировок переполнится, и система начнет выдавать ошибки «ресурс занят» даже при отсутствии реальных процессов.

Вопросы и ответы

Как выбрать оптимальный таймаут?

Таймаут должен быть больше максимального времени выполнения задачи плюс запас на задержки сети и обработки. Например, если задача выполняется до 10 секунд, установите таймаут в 30–45 секунд.

Можно ли использовать механизм без центрального реестра?

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

Что делать, если процесс не может обновить блокировку?

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

По теме

import redis
import time

def acquire_lock(client, resource_id, ttl_seconds):
    lock_key = f"lock:{resource_id}"
    if not client.set(lock_key, "active", nx=True, ex=ttl_seconds):
        return False
    return True

def renew_lock(client, resource_id, ttl_seconds):
    lock_key = f"lock:{resource_id}"
    if client.get(lock_key) == "active":
        client.expire(lock_key, ttl_seconds)
        return True
    return False

def release_lock(client, resource_id):
    lock_key = f"lock:{resource_id}"
    client.delete(lock_key)