Распределённая блокировка в Python: подводные камни
Redis SETNX без TTL повесил очередь задач на сутки, а lock через истёкший ключ дал двум воркерам обработать один платёж. Разбираем, где ломаются распределённые блокировки в Python и как их чинить.

Двa воркера Celery одновременно списали деньги с одного счёта. В логах — оба взяли «блокировку» на ключ payment:4821, оба прошли проверку, оба выполнили списание. Причина банальна: TTL блокировки истёк за 200 мс до того, как первый воркер закончил работу, второй взял ключ и начал параллельно, а первый, закончив, удалил уже чужую блокировку командой DEL. Классическая гонка, которую не ловит ни один локальный тест.
Распределённая блокировка нужна, когда несколько процессов на разных машинах борются за один ресурс: строку в БД, файл, вызов внешнего платёжного API. Локальный threading.Lock здесь бесполезен — он живёт в памяти одного процесса. Разбираем, где именно ломаются распределённые блокировки в Python-стеке (Redis, PostgreSQL) и что с этим делать. Примеры на Python 3.11, redis-py 5.x, PostgreSQL 16.
Почему наивный SETNX не работает
Первое, что находят в интернете, — блокировка через Redis:
# ОПАСНЫЙ ВАРИАНТ, не используйте в проде
import redis
client = redis.Redis()
# захват без TTL
if client.setnx("lock:payment:4821", "1"):
do_work()
client.delete("lock:payment:4821")
Здесь два независимых бага. Первый: если процесс упадёт между setnx и delete (OOM-killer, деплой, паника), ключ останется в Redis навсегда. Ресурс заблокирован до ручного вмешательства. Второй: даже с TTL проблема остаётся, если удалять чужую блокировку — что и произошло в примере из начала.
Правильный захват: SET с NX и PX атомарно
TTL нужно выставлять той же командой, что и захват, иначе между setnx и expire процесс может упасть и оставить ключ без срока жизни. В redis-py это один вызов:
import uuid
import redis
client = redis.Redis()
# уникальный токен владельца — критичен для безопасного снятия
owner_token = str(uuid.uuid4())
# NX — только если ключа нет; PX — TTL в миллисекундах
acquired = client.set(
"lock:payment:4821",
owner_token,
nx=True,
px=30000,
)
if acquired:
try:
do_work()
finally:
release_lock(client, "lock:payment:4821", owner_token)
Ключевая деталь — owner_token. Он отвечает на вопрос «а точно ли я всё ещё владею этой блокировкой» перед снятием.
Снятие блокировки должно быть атомарным
Наивное снятие «проверил токен → удалил» — снова гонка: между GET и DEL TTL может истечь и ключ достанется другому. Проверку и удаление нужно выполнить одной операцией через Lua-скрипт, который Redis исполняет атомарно:
RELEASE_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
def release_lock(client, key, token):
# снимаем только свою блокировку
return client.eval(RELEASE_SCRIPT, 1, key, token)
Скрипт удаляет ключ, только если его значение совпадает с нашим токеном. Чужую блокировку вы не тронете, даже если ваш TTL уже истёк, а ключ перезахватил сосед.
Подводный камень номер один: истечение TTL посреди работы
Токен защищает от удаления чужой блокировки, но не от того, что два процесса окажутся в критической секции одновременно. Если работа заняла дольше TTL, ключ истёк, его взял другой воркер — и вот у вас две параллельные обработки одного платежа. Токен спасёт от неверного DEL, но деньги уже списаны дважды.
Распределённая блокировка не гарантирует взаимное исключение в момент истечения TTL. Она гарантирует его только при условии, что работа гарантированно короче TTL. Это разные вещи, и на этом ломаются продакшены.
Способов бороться два, и оба неполные:
- Продление TTL (watchdog). Фоновый поток периодически продлевает срок жизни ключа, пока основная работа идёт. Так делает библиотека
redis-pyв классеredis.lock.Lock— у него есть параметрыtimeoutиblocking_timeout, а продление реализуется черезextend(). Проблема: если поток-watchdog застрял (GIL, долгая синхронная операция), продление не случится вовремя. - Fencing token. При каждом захвате генерируется монотонно растущий номер. Защищаемый ресурс (БД, хранилище) отвергает запись со старым номером. Это единственный способ, устойчивый к паузам GC и сетевым задержкам, но он требует поддержки на стороне ресурса, а не только в клиенте.
Подводный камень номер два: Redis — не гарантия консистентности
Одиночный Redis — единая точка отказа. Если мастер упал и реплика поднялась как новый мастер, блокировка, записанная на старом мастере, могла не успеть реплицироваться. Два процесса получат блокировку на один ключ на двух разных мастерах в разные моменты.
Алгоритм Redlock (захват большинства из N независимых мастеров) предложен как решение, но его критиковали за то, что при паузах процесса и рассинхронизации часов гарантии всё равно не абсолютны. Практический вывод: если цена двойного выполнения — деньги или порча данных, не полагайтесь на Redis-блокировку как на единственный барьер. Ставьте идемпотентность на уровне операции.
Альтернатива: advisory-блокировки PostgreSQL
Если у вас уже есть PostgreSQL, часто не нужен отдельный Redis. Советующие (advisory) блокировки живут в БД и автоматически снимаются при закрытии сессии — упавший процесс не оставит зависший ключ.
import psycopg
# ключ блокировки — 64-битное целое (или пара int)
LOCK_KEY = 4821
with psycopg.connect("dbname=app") as conn:
with conn.cursor() as cur:
# блокирующий вариант ждёт освобождения
cur.execute("SELECT pg_advisory_lock(%s)", (LOCK_KEY,))
try:
do_work()
finally:
cur.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY,))
Неблокирующий вариант — pg_try_advisory_lock: вернёт true или false сразу, без ожидания. Точную сигнатуру функций сверяйте в разделе Advisory Lock Functions документации PostgreSQL для вашей версии.
Главное отличие от Redis: у advisory-блокировки нет TTL. Она держится, пока жива сессия. Это плюс (не истечёт посреди работы) и минус (зависшая сессия с открытым соединением держит блокировку неограниченно, пока не сработает statement_timeout или соединение не оборвётся).
Сравнение подходов
| Свойство | Redis SET NX PX | redis-py Lock (watchdog) | PG advisory lock |
|---|---|---|---|
| Авто-снятие при падении процесса | По TTL | По TTL | При закрытии сессии |
| Риск истечения посреди работы | Есть | Снижен, не исключён | Нет |
| Нужна отдельная инфраструктура | Redis | Redis | Уже есть, если PG в стеке |
| Устойчивость к failover | Низкая | Низкая | Зависит от репликации PG |
| Держит соединение всё время | Нет | Нет | Да |
Что ломается на границах
- Реентерабельность. Тот же процесс повторно берёт ту же блокировку и получает дедлок сам с собой. Redis-блокировка по умолчанию не реентерабельна; PG advisory на уровне сессии — реентерабельна, и снимать её нужно столько же раз, сколько взяли.
- Забытый finally. Захват без
try/finallyоставит блокировку висеть при любом исключении в критической секции. Оборачивайте всегда, даже в прототипе. - Пул соединений и advisory lock. Если вы берёте PG-блокировку из пула, а потом соединение возвращается в пул и переиспользуется другим запросом до
unlock, блокировка «утечёт» на чужой контекст. Держите блокировку в пределах одного явно управляемого соединения. - Часы и паузы GC. Долгая пауза сборщика мусора или своп могут превысить TTL незаметно для процесса: он «думает», что владеет блокировкой, а она уже перешла. Именно поэтому для критичных операций нужна идемпотентность.
Что делать дальше
Прежде чем усложнять блокировку, ответьте на вопрос: что произойдёт, если критическая секция всё-таки выполнится дважды. Если ответ «ничего страшного» — хватит простого SET NX PX с токеном и Lua-снятием. Если ответ «спишем деньги дважды» — блокировка вторична, первично делать операцию идемпотентной: уникальный ключ идемпотентности на стороне БД или платёжного шлюза.
Дальше стоит посмотреть встроенный redis.lock.Lock из redis-py вместо самописного кода и измерить в проде реальное распределение времени критической секции: p99 должен быть в разы меньше TTL, иначе истечения неизбежны. Инвалидация распределённого кеша и координация консенсуса через etcd/ZooKeeper — отдельные темы, здесь мы их не разворачивали.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: PostgreSQL 16 — Advisory Lock Functions, redis-py — Locks documentation
Частые вопросы
А если у меня старая версия redis-py, там нет параметров nx и px в set?
Redis SET NX сработал, но блокировка всё равно даёт двойное выполнение. Почему?
Что выбрать для нового проекта — Redis или PostgreSQL advisory lock?
Грозит ли зависшая advisory-блокировка вечным дедлоком в проде?
Нужен ли owner_token, если я всё равно ставлю TTL?
Redlock решает проблему единой точки отказа Redis?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.