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

Почему блокировка не делает задачу безопасной
Блокировка Redis отвечает только на один узкий вопрос: кто может начать прямо сейчас. Она не доказывает, что воркер завершит работу, аккуратно освободит блокировку или оставит систему в корректном состоянии, если умрёт на середине.
Этот разрыв создаёт ложное чувство безопасности. Люди добавляют Redis-блокировки, видят, что два воркера перестают одновременно заходить в один и тот же код, и думают, что задача теперь защищена. Это не так. Блокировка уменьшает пересечение. Она не делает саму работу корректной.
Сбоя достаточно, чтобы показать проблему. Допустим, воркер получает блокировку, отправляет запрос на списание в платёжный сервис, а потом процесс останавливается до того, как запишет в базу данных "готово". Через несколько секунд срок аренды истекает, и другой воркер подхватывает ту же задачу. Второй воркер может отправить то же списание ещё раз. Первый воркер упал, но часть его работы всё равно успела выйти в мир.
То же самое происходит с отправкой писем, доставкой вебхуков, созданием счетов, обновлением остатков и фоновыми задачами синхронизации. Как только происходит побочный эффект, блокировка уже не может вернуть его назад. Если другой воркер повторит задачу, можно получить двойную работу, дубли сообщений или конфликтующее состояние.
Поэтому блокировка и корректность — это две разные задачи. Блокировка помогает координировать доступ в короткое окно времени. Корректность зависит от того, как вы устроили обработчик: может ли он безопасно повторяться, может ли он понять, что работа уже выполнена, и может ли он не освобождать или не продлевать блокировку, которой он больше не владеет.
Относитесь к блокировке как к небольшому инструменту координации, а не как к доказательству безопасности задачи. Практическое решение простое: делайте аренду короткой, выдавайте каждому воркеру свой токен владельца и делайте обработчик идемпотентным, чтобы повтор не создавал новый беспорядок.
Что ломается, когда процесс падает
Задача может начаться нормально и всё равно оставить за собой беспорядок. Один воркер берёт блокировку Redis, начинает работу, а потом процесс умирает до того, как снимет блокировку или сохранит результат. Блокировка помогла на мгновение, но теперь задача зависла в неопределённом состоянии.
Сбои почти никогда не происходят на аккуратной границе. Воркер может остановиться после того, как списал деньги с клиента, но до того, как отметил заказ как оплаченный. Он может записать только часть строк для отчёта и так и не дописать остальные. Если смотреть только на блокировку, нельзя понять, задача вообще не стартовала, завершилась полностью или остановилась на середине.
Когда срок аренды заканчивается, другой воркер может увидеть ту же задачу и взять её снова. Вот тут и начинается настоящая проблема:
- повторяется уже выполненное действие;
- создаются дубликаты записей;
- частичная работа затирается устаревшими данными;
- пропускается шаг, потому что система считает, что первый воркер уже всё сделал.
В этом и состоит слабое место Redis-блокировок. Они уменьшают пересечение, пока аренда активна, но не доказывают, что работа безопасна. Они не сохраняют прогресс, не убирают побочные эффекты и не сообщают следующему воркеру, что именно изменил умерший процесс.
Простой пример делает это очевидным. Допустим, воркер создаёт новый аккаунт клиента, отправляет приветственное письмо и открывает платёжный профиль. Если процесс умирает сразу после письма, следующий воркер может отправить то же письмо ещё раз. Если он умирает после создания платёжного профиля, но до сохранения результата, следующий воркер может создать второй платёжный профиль. Блокировка не остановила ни одну из этих проблем.
Иногда всё ломается наоборот. Второй воркер вообще не подхватывает задачу, потому что срок аренды слишком длинный, и задача просто лежит и ждёт. Двойной работы нет. Но работа застряла.
Поэтому работа со сбоями начинается с жёсткого предположения: воркер может умереть на любой строке кода. Когда вы проектируете систему с учётом этого, вы перестаёте воспринимать блокировку как гарантию безопасности. Вы воспринимаете её как короткое право попытаться выполнить задачу прямо сейчас.
Используйте короткую аренду вместо долгой блокировки
Длинная блокировка кажется безопасной, но обычно делает сбой болезненнее. Если воркер берёт блокировку на 30 минут и умирает через 2 секунды, задача будет висеть ещё 29 минут 58 секунд, пока никто не делает полезную работу.
Короткая аренда работает лучше, потому что она исходит из того, что сбой случится. Срок блокировки истекает сам, зависшая задача снова становится доступной, и другой воркер может подхватить её без ручной чистки.
Начинайте со срока, который соответствует реальному времени выполнения задачи, а не пожеланиям. Измерьте, сколько обычно занимает работа, и добавьте небольшой запас. Если задача обычно завершается примерно за 8 секунд, аренда на 15 секунд — разумный вариант. Если она часто занимает 40–50 секунд, попробуйте 90 секунд, а не 10 минут.
Вам не нужен один идеальный срок навсегда. Для разных задач могут быть разные сроки аренды. Быстрая отправка писем, перестроение кэша и большие импорты не должны жить под одним тайм-аутом.
Помогает простое правило:
- ставьте первую аренду близко к обычному времени выполнения;
- оставляйте запас на краткие замедления;
- продлевайте до того, как срок аренды приблизится к нулю;
- прекращайте продление, как только воркер становится нездоровым.
Последний пункт важнее, чем кажется. Продление должно происходить только пока воркер жив, всё ещё владеет блокировкой и всё ещё может выполнять задачу. Если процесс наполовину умер, завис или завершает работу, он должен перестать продлевать аренду и дать блокировке истечь.
Держите цикл продления скучным и строгим. Продлевайте через фиксированный интервал, например каждые 3–5 секунд для аренды на 15 секунд. Перед каждым продлением проверяйте токен владельца. Если Redis недоступен или воркер пропускает окно продления, пусть срок аренды истечёт. Не пытайтесь защитить сломанный воркер более длинным тайм-аутом.
Короткие аренды также уменьшают ущерб от неверных оценок. Команды часто ставят длинное время блокировки, потому что одна редкая задача когда-то заняла намного больше времени, чем ожидалось. Но за это вы расплачиваетесь долгими периодами блокировки и ожидания. В большинстве систем быстрый повтор — более удачный сценарий отказа.
В этом и состоит практичный подход к Redis-блокировкам, которые переживают сбои. Блокировка временная, работу можно повторить, а истёкшая аренда быстро освобождает зависшие задачи. Это помогает очереди двигаться дальше, даже если воркер исчез в самый плохой момент.
Дайте каждому владельцу блокировки свой токен владельца
Одного имени блокировки недостаточно. Каждый воркер, который получает блокировку, должен создать свой случайный токен и сохранить этот токен как значение блокировки.
Этот небольшой шаг останавливает распространённый сбой после падения или долгой паузы. Воркер A получает блокировку и сохраняет токен abc123. Срок его аренды истекает. Позже ту же блокировку получает воркер B и сохраняет xyz789. Если воркер A проснётся и выполнит обычное удаление, он может по ошибке удалить блокировку воркера B.
В случае Redis-блокировок безопасное правило простое: воркер может продлевать или освобождать блокировку только если значение всё ещё совпадает с его собственным токеном.
Почему токен важен
Токен — это доказательство владения. Имя блокировки показывает, какая задача защищена. Токен показывает, кто владеет ею прямо сейчас.
Каждый раз, когда воркер получает блокировку, используйте новый случайный токен. Не переиспользуйте идентификатор процесса, имя хоста или имя задачи. Их легко угадать и легко повторить после перезапуска.
Безопасный сценарий выглядит так:
- воркер генерирует случайный токен;
- он записывает блокировку с этим токеном и короткой арендой;
- прежде чем продлить или удалить блокировку, он проверяет, что Redis по-прежнему хранит тот же токен.
Эта последняя проверка должна быть атомарной. Если разделить её на отдельные операции чтения и удаления, между ними может вклиниться другой воркер. Короткий Lua-скрипт решает это аккуратно: сравнить сохранённое значение с токеном воркера, а затем продлить или удалить только при совпадении.
Это защищает от неприятного пограничного случая. Зависший воркер может думать, что всё ещё владеет блокировкой, потому что не заметил истечение срока аренды. Проверка токена заставляет его остановиться. Он не сможет тронуть более новую блокировку, которая уже принадлежит другому воркеру.
Если вы делаете AI-задачи, очереди или фоновые задания для небольшой команды, это правило избавляет от реальных проблем. Oleg часто подталкивает команды к лёгким системам, которые переживают перезапуски и повторы. Токены владельца хорошо вписываются в такой подход: небольшое изменение, меньше двойных запусков, гораздо меньше путаницы при сбоях.
Сделайте обработчик безопасным для двукратного запуска
Повторы случаются. Воркер может упасть после начала задачи, но до того, как зафиксирует успех. Срок аренды может истечь, другой воркер подхватит ту же задачу, и работа начнётся снова. Redis-блокировки уменьшают пересечение, но не делают дублирование невозможным.
Дайте каждой задаче стабильный идентификатор и сохраняйте его до начала любых внешних действий. Этот идентификатор должен оставаться тем же при всех повторах. Когда обработчик стартует, он ищет этот ID в надёжном хранилище. Если находит завершённую запись, пропускает работу и возвращает сохранённый результат. Если находит частичную запись, может решить, продолжать ли, ждать или остановиться.
Это особенно важно, когда задача взаимодействует с другой системой. Представьте задачу, которая списывает деньги с клиента, а потом пишет в вашу базу данных "оплачено". Если процесс падает после успешного списания, но до обновления базы, повтор может списать деньги ещё раз. Решение простое: сначала сохраните ID задачи, отправляйте тот же ID во внешний запрос, если API поддерживает дедупликацию, и сохраняйте внешний идентификатор, который получили в ответ.
Обычно достаточно небольшой записи:
- ID задачи;
- текущее состояние;
- внешний идентификатор;
- финальный результат или финальная ошибка.
Держите состояния простыми и понятными. "pending", "sent" и "done" работают лучше, чем длинный список, которому никто не доверяет. При повторе обработчик читает запись и действует на основе фактов, а не догадок.
Записывайте результат в форме, которую потом можно проверить. Сохраняйте payment ID, email message ID, created order ID или что угодно, что доказывает, что побочный эффект уже произошёл. Тогда следующий запуск сможет задать один простой вопрос: эта конкретная задача уже завершилась?
Если на этот вопрос можно ответить одним запросом, короткие аренды и безопасные повторы перестают казаться рискованными.
Простая схема, которой можно следовать
Начните с записи о задаче, а не с самой блокировки. В Redis-блокировках блокировка лишь говорит, кто может работать прямо сейчас. Запись о задаче говорит, завершилась ли работа уже, кто пытался сделать её последним и должен ли повтор вообще что-то делать.
Дайте каждой задаче стабильный task id. Стройте его от бизнес-действия, а не от процесса воркера. Например, используйте что-то вроде invoice-4821-send или user-991-sync. Если одна и та же задача попадает в очередь дважды, оба воркера всё равно говорят об одной и той же задаче.
Практический поток выглядит так:
- Создайте task id и случайный токен владельца для этого воркера.
- Попробуйте захватить блокировку с короткой арендой, например на 10–15 секунд.
- Если Redis сказал "нет", остановитесь и попробуйте позже. Не ждите бесконечно.
- После захвата выполните один небольшой кусок работы и сохраните прогресс.
- Если работа может занять дольше, чем аренда, продлевайте её только если токен владельца по-прежнему совпадает с вашим.
Маленький кусок работы важнее, чем многие думают. Не обрабатывайте весь пакет под одной арендой, если можно обработать один элемент, сохранить результат и перейти к следующему. Меньшие шаги делают сбои менее болезненными. И они сильно упрощают безопасные повторы.
Токен владельца нужен, чтобы один воркер не удалил блокировку другого. Сохраняйте токен как значение блокировки. Когда продлеваете или освобождаете её, сначала проверяйте, что токен в Redis всё ещё совпадает с вашим. Если нет, срок аренды истёк и теперь блокировка принадлежит кому-то другому. Остановитесь.
Перед тем как освободить блокировку, запишите для task id запись о завершении. Эта запись может быть простой: статус done, finished_at и, возможно, идентификатор результата. Это и есть часть, которая предотвращает двойную работу после сбоя. Если воркер завершил задачу, но умер до очистки, срок аренды истечёт, другой воркер подхватит задачу, увидит запись о завершении и выйдет.
Простое правило удерживает всю схему в порядке: блокировка управляет тем, кто работает сейчас, а запись о завершении — тем, нужна ли работа ещё вообще. Делайте аренду короткой, продлевайте её только пока вы всё ещё владеете ею, и делайте каждый обработчик безопасным для двукратного запуска. Это схема, которая переживает реальные падения процессов.
Реалистичный пример
Представьте биллингового воркера, который отправляет ежемесячные счета в первый день месяца. Он использует Redis-блокировки, чтобы только один воркер обрабатывал конкретного клиента и расчётный период одновременно. Блокировка помогает с координацией, но не доказывает, что работа завершена.
Допустим, воркер получает аренду на 30 секунд, отправляет счёт, записывает надёжную запись "done" для этого клиента и месяца, а потом собирается подтвердить задачу. Этот последний шаг важен меньше, чем многие думают. Если процесс умрёт после отправки счёта, срок аренды всё равно истечёт.
Обычный сбой выглядит так:
- Воркер A получает блокировку для клиента 1842 и месяца 2026-04.
- Он создаёт счёт и отправляет его в почтовый или биллинговый сервис.
- Он сохраняет запись о завершении в базе данных, например
(customer_id, month), а потом падает до того, как успевает отметить задачу в очереди как выполненную. - Срок аренды Redis истекает, воркер B подхватывает повтор, сначала проверяет запись о завершении и выходит, не отправляя ещё один счёт.
Именно эта запись о завершении удерживает деньги и доверие в безопасности. Без неё воркер B видит только то, что срок аренды закончился. Он не может знать, завершил ли воркер A отправку или умер на середине. Может уйти второй счёт, и тогда поддержку ждёт беспорядок.
Вот почему короткие аренды хорошо работают, когда обработчик идемпотентен. Вы быстро даёте сроку аренды истечь, если процесс падает, чтобы другой воркер мог скоро повторить попытку. А затем вы делаете повтор безопасным, проверяя надёжную запись перед повторной внешней операцией.
Если вам нужно одно простое правило для Redis-блокировок, используйте такое: блокировка решает, кто может попытаться выполнить работу прямо сейчас, а запись о завершении решает, произошла ли работа уже. Разделяйте эти две идеи, и восстановление после сбоя станет намного проще.
Ошибки, которые приводят к двойной работе
Большинство случаев двойной работы начинается с одного плохого предположения: "Если я взял блокировку, задача в безопасности". Блокировка лишь ограничивает, кто начинает одновременно. Она не говорит, отправлял ли уже упавший воркер письмо, списывал ли деньги или записал ли только половину данных.
Одна частая ошибка — использовать одну длинную аренду для всех задач. Команды ставят 10 или 30 минут "на всякий случай", а потом мёртвый воркер слишком долго блокирует повторные попытки. Короткая аренда с продлением даёт гораздо меньшее окно отказа и позволяет другому воркеру повторить попытку раньше.
Ещё одна ошибка — освобождать блокировку только по имени. Если воркер A потерял аренду, а воркер B получил ту же блокировку, воркер A не должен удалять её на выходе. Старому воркеру нужно сначала проверить свой токен владельца. Если токен не совпадает, он должен оставить блокировку в покое.
Истечение срока блокировки тоже вводит людей в заблуждение. Истёкшая блокировка не доказывает, что никакая работа не происходила. Воркер может завершить внешнее действие, упасть до сохранения прогресса и оставить следующего воркера повторять то же самое. Так клиенты получают двойное списание или одно и то же сообщение снова.
Порядок действий важнее, чем думают многие команды. Если код вызывает внешний API до того, как записывает состояние для повтора, сбой стирает единственную подсказку, что вызов уже был. Сначала запишите достаточно состояния, чтобы распознать повтор, затем сделайте вызов, затем сохраните результат. Если API поддерживает idempotency key, используйте его каждый раз.
Redis-блокировки тоже не исправляют все гонки в системе. Они не защищают вашу строку в базе данных, сообщение в очереди и запрос к стороннему API, если каждый шаг не умеет переживать повтор. Блокировка помогает уменьшить пересечение, но обработчик всё равно должен нормально вести себя, когда появляется повторная попытка.
Более безопасная схема обычно проста:
- держите аренду короткой и продлевайте её, пока воркер жив;
- храните с каждой блокировкой уникальный токен владельца;
- сохраняйте состояние для повторов до внешних вызовов;
- делайте каждый обработчик безопасным для двукратного запуска;
- ожидайте сбоев и повторов, а потом проектируйте систему под них.
Небольшой пример делает это понятным. Воркер отправляет письмо со счётом и планирует отметить его как отправленное после этого. Он падает после вызова отправки, но до записи в базу. Повтор видит истёкшую блокировку и отправляет счёт снова. Если воркер сначала пишет запись о попытке отправки и переиспользует тот же idempotency key при повторе, второй запуск не причиняет вреда.
Короткий чек-лист перед запуском
Если воркер умирает посреди задачи, система должна восстановиться без догадок. Блокировка должна скоро исчезнуть, другой воркер должен повторить попытку, а задача всё равно должна завершиться одним правильным результатом.
Перед тем как доверять Redis-блокировкам в продакшене, проверьте пять пунктов:
- Держите аренду короткой. Если воркер падает, блокировка должна истекать за секунды, а не висеть минутами. Здоровые воркеры могут продлевать аренду по ходу работы.
- Привязывайте блокировку к токену владельца. У каждого воркера свой уникальный случайный токен, и Redis принимает продление или освобождение только от того воркера, у которого есть именно этот токен.
- Дайте каждой задаче стабильный ID. Используйте один и тот же ID при каждом повторе, например ID заказа, ID счёта или ID письма, чтобы система понимала, что это та же самая работа.
- Сделайте обработчик безопасным для двукратного запуска. Повтор должен проверить, что уже произошло, и пропустить дублирующие действия вместо того, чтобы дважды списывать деньги, отправлять два письма или записывать конфликтующие строки.
- Сохраняйте достаточно состояния, чтобы отличить завершённую работу от наполовину выполненной. "Начато" — это не то же самое, что "завершено". Сохраняйте ясный финальный результат и фиксируйте прогресс так, чтобы следующий воркер мог его прочитать.
Платёжная задача — хороший тест. Убейте воркер после того, как он списал деньги с карты, но до того, как он сохранил успех. При повторе новый воркер должен использовать тот же task ID, увидеть, что списание уже существует, и завершить учётные записи вместо повторного списания.
Вот к чему нужно стремиться. Если хотя бы один пункт из этого списка отсутствует, блокировка может уменьшить пересечение, но она не делает работу безопасной.
Что делать дальше
Выберите одну фоновую задачу, которая сильнее всего болит, когда выполняется дважды или зависает. Хорошие кандидаты — списание платежа, создание счёта, обновление остатков, отправка писем или выдача доступа к аккаунту. Начните с неё. Исправить одну задачу полностью полезнее, чем везде добавить Redis-блокировки и надеяться, что они выдержат.
Хороший первый шаг простой: оставьте блокировку, но перестаньте считать её гарантией безопасности. Дайте каждому владельцу блокировки свой токен владельца и храните отдельную запись о завершении задачи. Тогда воркер сможет доказать "это моя блокировка" перед тем, как продлить её или освободить, а система сможет доказать "эта работа уже завершена" перед повторным запуском обработчика.
После этого сократите время аренды. Длинные аренды кажутся безопаснее, но обычно они скрывают зависших воркеров и замедляют восстановление после сбоя. Короткая аренда с продлением понятнее. Если процесс умирает, другой воркер сможет скоро подхватить задачу вместо того, чтобы ждать долгий тайм-аут.
Затем специально проверьте неприятные случаи:
- убейте воркер посреди обработчика;
- поставьте его на паузу, чтобы срок аренды истёк;
- запустите двух воркеров на одной задаче;
- повторно отправьте то же сообщение после успешного первого выполнения;
- проверьте, что второй запуск завершится чисто и не сделает работу снова.
Если этот тест проваливается, проблема не в блокировке как таковой. Обработчик всё ещё должен быть идемпотентным, а у потока задачи всё ещё должна быть чёткая запись о завершении.
Такая проверка обычно быстро находит слабые места. Один пропущенный токен владельца или один обработчик, который пишет дважды, потом оборачиваются днями ручной чистки. Если вам нужен ещё один взгляд на архитектуру, Oleg Sotnikov может проверить схему, путь повторов и сценарии сбоев до того, как они появятся в продакшене.