Прямой ответ
SELECT ... FOR UPDATE блокирует прочитанные строки до конца транзакции. Другие транзакции, пытающиеся заблокировать те же строки, ждут. Это пессимистичный подход: предполагается, что конфликт вероятен, поэтому доступ сериализуется заранее.
BEGIN;
SELECT balance
FROM accounts
WHERE id = 'acc-42'
FOR UPDATE; -- строка заблокирована до COMMIT
UPDATE accounts
SET balance = balance - 100
WHERE id = 'acc-42';
COMMIT;
Варианты блокировки в PostgreSQL:
| Директива | Что блокирует | Когда применять |
|---|---|---|
FOR UPDATE |
Запрещает все изменения и другие FOR UPDATE |
Полное изменение строки |
FOR NO KEY UPDATE |
То же, но не конфликтует с FOR KEY SHARE |
Изменение неключевых полей |
FOR SHARE |
Запрещает изменения, разрешает чтение с блокировкой | Чтение перед зависимой записью |
FOR KEY SHARE |
Запрещает изменение ключа | Внешние ключи |
Два полезных модификатора:
SELECT ... FOR UPDATE NOWAIT; -- ошибка сразу, если строка занята
SELECT ... FOR UPDATE SKIP LOCKED; -- пропускает заблокированные строки
SKIP LOCKED — основа реализации очередей на таблице:
BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'new'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED; -- каждый воркер берёт свою задачу
UPDATE jobs SET status = 'in_progress' WHERE id = :id;
COMMIT;
Без SKIP LOCKED все воркеры выстраивались бы в очередь за одной строкой; с ним каждый берёт первую незаблокированную задачу, и параллелизм работает.
Практика: безопасное использование блокировок
1. Блокировка работает только внутри транзакции. Запрос FOR UPDATE вне явной транзакции освобождает блокировку немедленно, поскольку каждый запрос выполняется в собственной транзакции.
2. Держите транзакцию короткой. Блокировка удерживается до COMMIT. Внешний HTTP-вызов внутри транзакции означает, что строка заблокирована на время сетевого обмена — это частая причина исчерпания пула соединений.
3. Соблюдайте единый порядок блокирования. Если одна транзакция блокирует счета A затем B, а другая — B затем A, возникает взаимная блокировка. Фиксированный порядок, например по возрастанию идентификатора, устраняет проблему; о диагностике — в статье про deadlock-и.
4. Задавайте таймаут блокировки. Параметр lock_timeout прерывает запрос вместо бесконечного ожидания:
SET LOCAL lock_timeout = '2s';
Без таймаута медленный клиент блокирует всех остальных, и проблема выглядит как зависание базы.
5. Используйте NOWAIT, когда ожидание бесполезно. Если задача может быть выполнена другим процессом, нет смысла ждать; лучше сразу взять другую.
6. Проверяйте индекс. FOR UPDATE по неиндексированному условию может заблокировать значительно больше строк, чем ожидается, поскольку проверка выполняется по всем прочитанным строкам.
7. Различайте блокировку строк и таблиц. FOR UPDATE блокирует строки; LOCK TABLE — всю таблицу. Смешение в одном коде приводит к неожиданной сериализации.
8. Проверяйте влияние на реплики. Блокировки действуют только на основном узле; чтение с реплики их не видит и может вернуть данные, которые в этот момент меняются. Для согласованного чтения нужен основной узел либо явная проверка состояния репликации.
Рекомендации:
- Применяйте при высокой конкуренции. Если конфликты частые, ожидание дешевле отклонённых попыток; о противоположном подходе — в статье про оптимистичную блокировку.
- Избегайте внешних вызовов в транзакции. Все данные, нужные для решения, читаются до начала блокирующей секции.
- Измеряйте время ожидания. Метрика длительности блокировок показывает узкие места раньше, чем они станут инцидентом.
- Учитывайте уровень изоляции. На уровне
SERIALIZABLEявная блокировка часто избыточна, поскольку конфликты обнаруживаются автоматически; об уровнях — в статье про изоляцию транзакций. - Не блокируйте больше, чем нужно. Точечное условие в
WHEREсокращает число заблокированных строк и снижает конкуренцию.
Практический сценарий: очередь задач обрабатывалась одним воркером из восьми
Контекст: восемь воркеров забирали задачи из таблицы. Фактически задачи выполнялись последовательно, а производительность не отличалась от одного воркера.
- Диагностика. Запрос выбора задачи использовал
FOR UPDATEбезSKIP LOCKED. Все воркеры блокировали первую строку и ждали завершения первого. - Проверка.
pg_locksпоказал восемь транзакций, ожидающих одну и ту же строку. Среднее время ожидания составляло несколько секунд. - Дополнительная находка. Часть воркеров удерживала блокировку во время внешнего вызова, поэтому транзакции длились до десяти секунд.
- Действия. (а) Добавили
SKIP LOCKEDк выбору задачи; (б) перенесли внешний вызов за пределы транзакции: задача помечалась как взятая, транзакция закрывалась, затем выполнялась работа; (в) установилиlock_timeout = 2s, чтобы ожидание не было бесконечным; (г) добавили индекс на(status, created_at), поскольку выборка выполнялась по неиндексированному условию; (д) добавили метрику времени ожидания блокировки. - Результат. Восемь воркеров начали работать параллельно; пропускная способность очереди выросла примерно в семь раз, а ожидание блокировки перестало превышать десятки миллисекунд.
Вывод: без SKIP LOCKED очередь на таблице сериализуется на первой строке. Длительность транзакции влияет на блокировку напрямую, поэтому внешние вызовы должны находиться за её пределами.
FAQ
Что произойдёт при взаимной блокировке? PostgreSQL обнаружит цикл, прервёт одну из транзакций с ошибкой deadlock и запишет детали в лог. Приложение должно обработать ошибку и повторить транзакцию.
Можно ли использовать FOR UPDATE для чтения без изменения? Да, если последующая запись зависит от прочитанного и должна быть согласована. Для простого чтения блокировка не нужна и только снижает параллелизм.
Чем SKIP LOCKED отличается от NOWAIT?
NOWAIT немедленно возвращает ошибку, если строка заблокирована. SKIP LOCKED пропускает заблокированные строки и возвращает остальные, что удобно для очередей.
Влияет ли блокировка на чтение без FOR UPDATE? Нет. Обычное чтение использует снимок данных и не блокируется, что обеспечивается многоверсионностью; о механизме — в статье про MVCC.
Источники: PostgreSQL docs (explicit locking, SELECT, lock runtime config) — ссылки в шапке.