Блокировки строк
Редактор читает план курса, увеличивает его и сохраняет результат. Если два клиента сделали это одновременно по старому значению, один может затереть смысл действия другого. Блокировка строки позволяет согласовать короткую последовательность «получить актуальный объект, вычислить изменение, записать его», когда оба клиента соблюдают один протокол.
Используем PostgreSQL 17.11, исходный JavaScript-курс с планом двадцать и версией один. Снимок catalog-db-advanced/lesson-19 лежит в архиве продолжения. SQL не выполнялся. Для будущего ручного опыта нужны две живые сессии A и B; reset запускается отдельно до опыта, а не между его шагами.
Чтение с намерением изменить
Обычный SELECT получает видимую строку, но не резервирует её для последующего редакторского решения. В короткой транзакции можно запросить соответствующую строковую блокировку:
BEGIN;
SELECT id, planned_lessons, version
FROM catalog.courses
WHERE id = 2
FOR UPDATE;
Это шаг A1. Ожидается исходный план двадцать. Сессия A остаётся открытой и удерживает блокировку до завершения блока. Нельзя закрыть клиент после SELECT и считать строку по-прежнему занятой: при завершении соединения незавершённая транзакция прекращается, а её ресурсы освобождаются.
FOR UPDATE относится к выбранным строкам, а не создаёт вечный флаг в колонке курса. После COMMIT или ROLLBACK протокол должен заново получить актуальную строку. Подробности режимов приведены в руководстве явных блокировок PostgreSQL. В этой главе используем один режим и один неизменяемый ключ, чтобы избежать неоднозначности порядка объектов.
Сессия B затем начинает свой блок и отправляет такой же SELECT FOR UPDATE. Пока A не завершилась, B должна ждать несовместимую блокировку. Она не получает автоматически исходное число для независимого редактирования. Если среда настроена на ограничение ожидания, вместо продолжения возможна ошибка по таймеру; это нужно учитывать при ручном повторении.
Последовательное изменение
Пока B ожидает, в A выполняется следующий шаг:
UPDATE catalog.courses
SET planned_lessons = planned_lessons + 2,
version = version + 1
WHERE id = 2
RETURNING planned_lessons, version;
COMMIT;
Ожидаются двадцать две главы и версия два. После фиксации A ожидавший SELECT B на обычном Read Committed должен получить актуальную строку с этим планом. Только после возврата SELECT выполните B2:
UPDATE catalog.courses
SET planned_lessons = planned_lessons + 2,
version = version + 1
WHERE id = 2
RETURNING planned_lessons, version;
COMMIT;
Ожидается итог двадцать четыре и версия три. Каждый клиент добавил две главы к согласованному текущему состоянию. Если B заранее сохранила число двадцать из другого чтения и затем просто присвоила двадцать два, ожидание само по себе не исправило бы её устаревший расчёт. Данные для решения должны быть получены внутри соблюдаемой границы.
Для простого арифметического увеличения одной строки отдельный SELECT иногда вообще не нужен: UPDATE с выражением planned_lessons + 2 способен выразить действие непосредственно. Здесь чтение с блокировкой введено для понимания протокола более сложного решения. Не добавляйте лишний обмен автоматически, когда само изменение уже полностью описано одной командой.
Что продолжает работать
Обычные читатели каталога не обязаны ждать такую строковую блокировку, чтобы показать прежнее подтверждённое состояние. В PostgreSQL многоверсионное чтение отделено от несовместимых изменений строки. Это не значит, что любые операции над таблицей никогда не блокируют друг друга: изменение структуры и другие режимы имеют свои правила.
Поэтому выражение «мы заблокировали курс» не означает, что посетитель сайта перестал открывать его карточку. Оно означает выбранное согласование определённых действий над строкой. Полезно называть, какая команда ожидает какую другую команду, вместо представления одной большой блокировки всего приложения.
И обратное: блокировка курса не гарантирует автоматически защиту всех связанных строк уроков. Если предметная операция одновременно меняет несколько глав, её набор объектов и порядок получения ресурсов нужно выбрать отдельно. Другой клиент может соблюдать иной протокол и затронуть данные, о которых текущий SELECT ничего не сообщает.
Не держим замок во время формы
Представьте, что A прочитала курс и оставила транзакцию открытой на время, пока пользователь выбирает количество глав. B будет ждать не вычисления базы, а человеческого решения. Такая граница не подходит обычной форме редактирования. Сначала человек выбирает действие, затем приложение коротко согласует актуальные данные и выполняет запись.
Для обнаружения устаревшей формы удобен другой механизм — сравнение версии. Он будет в следующем уроке. Блокировка полезна внутри короткой операции, где решение действительно зависит от защищённого текущего состояния; версия полезна для связи действия с ранее прочитанным объектом без длительного удержания ресурса.
Ожидание также требует политики клиента. Можно установить ограничение времени или использовать NOWAIT. В последнем случае несовместимая блокировка приведёт к ошибке вместо ожидания. Это не автоматическая потеря данных: приложение сообщает, что объект сейчас занят, и выбирает понятный способ повторить действие позже.
Пропуск занятого объекта
SKIP LOCKED позволяет пропустить недоступные для блокировки строки. Такой приём часто рассматривают для очереди независимых задач, но он не заменяет согласованное чтение произвольного каталога. Если редактор запросил конкретный курс, пустой ответ после пропуска может скрыть существующий занятый объект. Приложение должно понимать отличие отсутствия от временной недоступности.
В основном снимке SKIP LOCKED не используется. Нам нужен один определённый курс и явное ожидание. Не превращайте этот пример в «быструю выборку всех свободных карточек», сохранив прежнее обещание полного списка. Условия доступа меняют наблюдаемый набор, а не только производительность.
Отбор строки и получение блокировки
Условия SELECT FOR UPDATE тоже входят в предметное решение. Если операция должна менять только опубликованный курс, это условие не следует проверять исключительно старой копией карточки в памяти. Состояние может измениться, пока запрос ждёт чужую транзакцию. После получения результата серверная операция должна работать с теми значениями, которые действительно прочитала, и оценивать отсутствие строки согласно своему договору.
Наш пример намеренно выбирает курс по неизменному id и выполняет арифметическое увеличение текущего плана. Он не подставляет обратно старое число 20 из формы. Поэтому второй клиент после ожидания продолжает с подтверждённого плана 22. Если бы он записал «план равен 22» независимо от актуального чтения, собственное увеличение могло бы исчезнуть, несмотря на наличие блокировки в одном из шагов.
Блокировка делает выбранный участок работы последовательным относительно конфликтующей операции, но не решает, какие строки принадлежат участку. Будущая команда добавления главы потребует отдельно рассмотреть уроки и уникальную позицию. Блокировать только строку courses и предполагать, что любой чужой INSERT lessons обязательно подчинится нашему правилу, нельзя без общего протокола всех записывающих клиентов.
Для ручного опыта важно сохранить открытым именно то соединение, которое выполнило BEGIN. Если закрыть окно клиента между A1 и A2, сервер не держит забытый блок до бесконечности как сохранённое состояние приложения. Поэтому шаги загружаются в две живые сессии, а не выполняются разными одноразовыми подключениями. Такая техническая последовательность необходима для объяснения ожидаемого ожидания B.
Порядок ручных шагов
Будущий опыт выполняется так: A1 получает строку, B1 отправляется и ожидает, A2 сохраняет и завершает, B1 возвращается, B2 сохраняет своё увеличение. Шаги загружаются через \i в тех же интерактивных клиентах. Последовательный запуск всего B-файла до A2 или завершение процесса после A1 разрушает требуемое наблюдение.
Если вы отменили ожидание или получили ошибку, сначала откатите текущий блок. cleanup.sql содержит только ROLLBACK и подходит для такой очистки. Для повторения исходных двадцати глав завершите обе сессии, затем отдельно восстановите reset. Не запускайте восстановление схемы, пока другой клиент держит экспериментальную транзакцию.
Теперь по ожидаемому итогу можно объяснить вклад каждого клиента: двадцать, двадцать две, двадцать четыре. Блокировка согласовала короткие действия, но не подменила расчёт, авторизацию и предметную область. Именно ясный протокол позволяет использовать её осмысленно, не оставляя посетителей и редакторов зависеть от случайной длительности открытого соединения.