Практическая ситуация: несколько воркеров разбирают очередь заданий, не должны ждать уже захваченные строки...

Практическая ситуация: несколько воркеров разбирают очередь заданий, не должны ждать уже захваченные строки. Как SKIP LOCKED меняет выборку и какой риск это создаёт для обработки заданий?

CREATE TABLE jobs (
    id bigint PRIMARY KEY,
    payload text NOT NULL,
    status text NOT NULL DEFAULT 'ready'
);

BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'ready'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;

UPDATE jobs SET status = 'processing' WHERE id = 42;
COMMIT;
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

SKIP LOCKED не ждёт строки, занятые несовместимой блокировкой, а пропускает их и выбирает доступную строку. Это позволяет нескольким воркерам эффективно распределять задания без ожидания одной блокировки, но порядок обработки перестаёт быть строгим: временно заблокированное или постоянно неуспешное задание может быть пропущено и столкнуться с голоданием.

Сам по себе SKIP LOCKED не подтверждает, что строка действительно была выбрана: если подходящих доступных строк нет, SELECT вернёт пустой результат. Кроме того, выбор строки и перевод её в состояние processing должны находиться в одной транзакции.

Исторический контекст

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

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

Постановка проблемы

Если несколько воркеров выполняют SELECT ... FOR UPDATE без SKIP LOCKED, они могут выстроиться в очередь на одной строке. После освобождения блокировки следующий воркер может получить уже обработанное задание и потратить время на повторную проверку.

При использовании SKIP LOCKED возникает обратный риск: воркер не видит занятые строки в текущем проходе. Поэтому нельзя считать отсутствие строки доказательством того, что очередь пуста. Также задания могут обрабатываться не в порядке ORDER BY, если более ранние строки долго заблокированы.

Подробное решение

FOR UPDATE запрашивает блокировку выбранных строк. Если строка уже удерживается другой транзакцией несовместимым образом, обычный режим ожидает её освобождения. SKIP LOCKED вместо ожидания исключает такую строку из результата текущего оператора и продолжает поиск.

В примере транзакция сначала блокирует выбранное задание, затем меняет его статус и фиксирует обе операции. Другой воркер не сможет выбрать ту же строку через такой же запрос, пока первая транзакция не завершится: строка либо будет пропущена, либо после фиксации перестанет удовлетворять условию status = 'ready'.

Проверка результата обязательна: запрос с LIMIT 1 может вернуть ноль строк. При отсутствии строки приложение должно завершить транзакцию без обновления, а не использовать заранее известный идентификатор вроде 42.

У механизма есть компромиссы:

  • повышается параллельность и уменьшается время ожидания;
  • порядок ORDER BY становится лишь предпочтительным, а не гарантированным при блокировках;
  • долго заблокированная строка может временно или многократно пропускаться;
  • нужна стратегия повторной выборки, тайм-аутов и обработки зависших заданий;
  • поведение и детали видимости зависят от конкретной СУБД, поэтому семантику следует проверять для используемой реализации.

Для надёжной очереди обычно хранят состояния ready, processing, done и время захвата задания. Если воркер завершился аварийно, отдельный механизм lease или тайм-аут может вернуть просроченное задание в состояние ready.

Ситуация из практики

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

Вариант без блокировки — сначала выполнить обычный SELECT, а затем отдельно обновить строку. Он уменьшает ожидание, но допускает выбор одного задания несколькими воркерами и требует дополнительной защиты от гонки.

Вариант с FOR UPDATE SKIP LOCKED выбирает и резервирует строку в одной транзакции. Его выбирают для очереди, потому что пропуск временно занятого задания дешевле, чем простой всех воркеров. Результат — выше пропускная способность, но для зависших и постоянно пропускаемых задач добавляют контроль времени обработки и повторное планирование.

BEGIN; WITH next_job AS ( SELECT id FROM jobs WHERE status = 'ready' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE jobs j SET status = 'processing' FROM next_job n WHERE j.id = n.id RETURNING j.id, j.payload; COMMIT;

Такой вариант атомарно резервирует найденную строку. Если RETURNING не вернул строку, доступных заданий в этом проходе нет; это не обязательно означает, что все задания отсутствуют — некоторые могут быть заблокированы.

Что кандидаты часто упускают

1. Что произойдёт, если запрос с SKIP LOCKED не найдёт строку?

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

2. Гарантирует ли ORDER BY id обработку заданий строго по возрастанию?

Нет. Сортировка определяет предпочтительный порядок среди строк, которые оператор может рассмотреть, но заблокированная строка пропускается. Поэтому задание с меньшим id может быть обработано позже задания с большим id. Строгий порядок несовместим с безусловным пропуском блокировок без дополнительного протокола координации.

3. Почему нельзя сначала выбрать строку без блокировки, а затем выполнить UPDATE?

Между этими операциями другой воркер может выбрать ту же строку и изменить её статус. В результате оба процесса будут считать одно задание своим. FOR UPDATE SKIP LOCKED захватывает блокировку во время выборки, а обновление в той же транзакции делает резервирование атомарным относительно других воркеров.