В следующем сценарии обе транзакции успешно читают одну строку, но затем зависают. Объясните, как совместимые блокировки чтения превращаются в взаимную блокировку при попытке обновления.
CREATE TABLE inventory (
id integer PRIMARY KEY,
quantity integer NOT NULL
);
-- Каждая сессия выполняет независимо
BEGIN;
SELECT quantity FROM inventory
WHERE id = 1 FOR SHARE;
UPDATE inventory
SET quantity = quantity - 1
WHERE id = 1;
FOR SHARE устанавливает совместимую блокировку чтения: обе транзакции могут получить её одновременно. Когда каждая затем выполняет UPDATE, ей требуется усилить свою блокировку до исключительной, но вторая транзакция всё ещё удерживает совместимую блокировку. Поэтому обе ждут друг друга, возникает взаимная блокировка при усилении блокировки.
СУБД обнаруживает цикл ожидания и прерывает одну из транзакций. Без такого обнаружения транзакции могли бы ждать бесконечно.
Блокировки чтения и записи появились как способ обеспечить согласованность при общем доступе к данным: читатель должен видеть допустимое состояние, а изменение не должно одновременно конфликтовать с другими операциями. Разделение блокировок на совместимые и несовместимые позволяет нескольким читателям работать параллельно, не разрешая конфликтующие записи.
Проблема усиления возникает из компромисса между конкурентностью и безопасностью. Если сразу брать исключительную блокировку, конфликт обнаруживается раньше, но параллельность снижается; если сначала читать с общей блокировкой, чтение становится более конкурентным, однако последующее усиление может образовать цикл ожидания.
В сценарии две транзакции получают общие блокировки на одну строку. Общие блокировки совместимы, поэтому ни одна транзакция не ждёт на этапе SELECT.
Для UPDATE нужна блокировка, несовместимая с общей блокировкой другой транзакции. Обе транзакции пытаются перейти от режима чтения к режиму записи и каждая ждёт освобождения блокировки, удерживаемой другой. Если приложение не обрабатывает отмену одной транзакции, операция может завершиться ошибкой вместо ожидаемого последовательного выполнения.
Механизм состоит из двух фаз:
FOR SHARE блокирует строку в режиме, совместимом с другим чтением, но несовместимом с изменением.UPDATE запрашивает более сильную, обычно исключительную, блокировку.Условно граф ожидания выглядит так: транзакция T1 ждёт освобождения блокировки T2, а T2 одновременно ждёт блокировки T1. Это цикл, то есть deadlock. СУБД периодически анализирует граф ожиданий, выбирает одну транзакцию жертвой, откатывает её и освобождает блокировки; другая получает возможность продолжить.
Точный режим FOR SHARE, совместимость блокировок и момент обнаружения зависят от СУБД. В PostgreSQL такая форма запроса поддерживается; в других системах могут использоваться иные названия или режимы блокировок. Сам принцип усиления общей блокировки до исключительной является общим, но детали нельзя переносить между СУБД без проверки документации.
Обычно проблему решают одним из способов:
FOR UPDATE; это упрощает протокол, но раньше блокирует конкурирующие транзакции;UPDATE с подходящим условием; это уменьшает окно между чтением и записью;Блокировка строки сама по себе не гарантирует отсутствие deadlock: цикл может возникнуть из-за нескольких строк, разных типов блокировок или взаимодействия с индексами и ограничениями.
Минимальный вариант с упреждающей блокировкой выглядит так:
Здесь транзакция сразу запрашивает исключительную блокировку. Вторая транзакция будет ждать уже на FOR UPDATE, а не пытаться усилить собственную общую блокировку, поэтому именно этот сценарий не образует цикл.
Сервис резервирования товара сначала использовал FOR SHARE, чтобы проверить остаток, а затем выполнял UPDATE. При пиковых запросах два параллельных заказа иногда получали ошибки взаимной блокировки: оба запроса успевали прочитать остаток и затем пытались усилить блокировку.
Вариант с сохранением FOR SHARE требовал бы сложной обработки повторов и не давал преимуществ, поскольку строку всё равно нужно было изменить. Вариант с глобальной блокировкой таблицы устранил бы конфликт, но резко снизил бы пропускную способность.
Выбрали SELECT ... FOR UPDATE в начале транзакции, короткую транзакцию и повтор всей операции при transient-ошибке. В результате ожидание стало последовательным на уровне конкретной строки, а операции с разными товарами сохранили параллельность. Дополнительно ограничили время ожидания блокировки, чтобы зависшие транзакции не занимали ресурсы слишком долго.
Можно ли считать FOR SHARE безопасным способом подготовить строку к последующему UPDATE?
Нет, если несколько транзакций могут одновременно выполнить такой протокол. Общая блокировка защищает строку от записи другими транзакциями, но допускает наличие других общих блокировок. Поэтому последующее усиление несколькими участниками может привести к deadlock. Для намеренного последующего изменения обычно выбирают блокировку записи сразу.
Почему после обнаружения deadlock повторяют всю транзакцию, а не только UPDATE?
К моменту ошибки СУБД откатывает транзакцию-жертву целиком либо делает её непригодной для продолжения — это зависит от СУБД и режима обработки ошибок. Кроме того, предыдущие чтения и решения приложения могли быть основаны на состоянии, которое уже изменилось. Повтор должен заново выполнить проверку условий, чтение и изменение в новой транзакции.
Устранит ли FOR UPDATE все проблемы конкурентного доступа?
Нет. Он предотвращает конкурирующее изменение заблокированной строки до завершения транзакции, но не устраняет конфликты по другим строкам, диапазонам, внешним ключам или ресурсам. При захвате нескольких строк по-прежнему нужен единый порядок, а при необходимости защиты предиката может потребоваться сериализуемая изоляция или диапазонные блокировки, если их поддерживает конкретная СУБД.