Оптимистическое обновление записи
Форма редактирования курса может быть открыта несколько минут. Пока человек думает, другой редактор изменяет тот же объект. Держать строковую блокировку всё это время неудобно, но без проверки сохранение старой формы способно затереть новое решение. Оптимистическое обновление сравнивает версию объекта при записи и сообщает конфликт, если состояние уже изменилось.
Используем PostgreSQL 17.11 и исходную колонку version, которая начинается с единицы. Самостоятельный снимок catalog-db-advanced/lesson-20 находится в архиве продолжения. SQL и клиентский код не запускались. Два редактора рассматриваются как две сессии, а будущие ручные шаги имеют явный порядок.
Версия прочитанного объекта
Клиент A читает цену JavaScript-курса и получает 1990 вместе с версией один. Клиент B читает ту же строку до изменения A и получает те же значения. Никакие транзакции не держатся открытыми на время формы: это обычные независимые чтения.
SELECT id, price, version
FROM catalog.courses
WHERE id = 2;
При отправке формы клиент передаёт не только новую цену, но и ожидаемую версию, на которой основано решение. Версия — часть договора редактирования. Она не является номером урока или числом подтверждённых читателей. Смысл поля определяется тем, какие изменения обязаны его увеличивать.
A сохраняет цену 2090 при ожидании версии один:
BEGIN;
UPDATE catalog.courses
SET price = 2090, version = version + 1
WHERE id = 2 AND version = 1
RETURNING id, price, version;
COMMIT;
Ожидается одна строка: цена 2090, версия два. Проверка и изменение находятся в одной команде. Между отдельным сравнением версии в Python и последующей безусловной записью другой клиент мог бы успеть изменить объект. Поэтому условие версии должно участвовать в самом UPDATE, а не только в логике интерфейса.
Устаревшая форма
Теперь B пытается сохранить цену 1890, по-прежнему передавая старую ожидаемую единицу:
BEGIN;
UPDATE catalog.courses
SET price = 1890, version = version + 1
WHERE id = 2 AND version = 1
RETURNING id, price, version;
COMMIT;
Ожидается пустой результат. Строка с ключом два существует, но её актуальная версия уже не совпадает с условием. Цена 2090 не перезаписывается. SQL-команда может успешно завершиться, затронув ноль строк, поэтому клиент должен прочитать этот факт и преобразовать его в понятный конфликт формы.
Механизм использует обычный условный UPDATE и его возвращаемые строки. Правила описаны в справочнике UPDATE PostgreSQL. Нельзя считать отсутствие исключения подтверждением сохранения, если предметный договор требует изменить ровно один актуальный объект.
После B новое чтение должно показать 2090 и два. Интерфейс может предложить открыть свежую версию, сравнить введённое значение с текущим и осознанно повторить действие. Автоматический повтор с подставленной новой версией уничтожил бы смысл защиты: старое намерение без участия человека снова перезаписало бы чужое изменение.
Нулевой результат требует контекста
Условие не совпадает и тогда, когда объект удалён. Если в запрос включены проверки владельца или допустимого статуса, их несовпадение также может дать ноль строк. Поэтому пустой RETURNING не всегда доказывает исключительно увеличение версии. Клиент должен определить, какие сведения разрешено уточнить, и выбрать безопасный понятный ответ.
В нашем лабораторном наборе удаление и авторизация не происходят, поэтому причина заранее известна. Это упрощение помогает увидеть механизм версии, но не должно превращаться в универсальную production-диагностику. Приложение не обязано раскрывать существование чужого объекта только ради подробного сообщения об ошибке.
Положительное ограничение колонки предотвращает бессмысленную нулевую версию, но не увеличивает её автоматически. Все обычные редакторские пути, меняющие защищаемое представление, должны соблюдать тот же договор. Если один импорт обновляет цену без version + 1, старый клиент не заметит изменения по этому полю. Протокол работает при согласованности всех писателей.
Клиентская граница операции
В архиве находится небольшой вариант функции для Psycopg 3.3.6. Он принимает уже открытое соединение в режиме autocommit и создаёт явную транзакцию для изменения. Значения передаются параметрами:
class EditConflict(Exception):
pass
def change_price(conn, course_id, new_price, expected_version):
with conn.transaction():
row = conn.execute(
"""UPDATE catalog.courses
SET price = %s, version = version + 1
WHERE id = %s AND version = %s
RETURNING id, price, version""",
(new_price, course_id, expected_version),
).fetchone()
if row is None:
raise EditConflict('Состояние курса изменилось или недоступно')
return row
Функция не создаёт сервер HTTP и не выбирает права редактора. Её предпосылки записаны явно. Исключение внутри блока приводит к завершению изменения без сохранения, а успешный возврат строки происходит после выхода из транзакционного контекста. Поведение управления транзакцией описано в руководстве Psycopg.
Для цены клиент передаёт подходящее точное значение, например Decimal, а не пытается исправить денежный смысл после сохранения. Ограничения базы продолжают проверять неотрицательность и исключение NaN. Версия не отменяет проверку содержимого: свежая форма с недопустимой ценой остаётся ошибочной.
Смысл конфликта и повтор
Два редактора могли независимо изменить разные поля. Защита одной общей версией всё равно сообщит конфликт, поскольку объект рассматривается целиком. Иногда продукт может безопасно объединить независимые изменения, но это отдельный предметный алгоритм, который должен знать исходные и текущие значения. Простое игнорирование несовпадения не является слиянием.
Не используйте внутреннее системное поле PostgreSQL как вечную замену версии без разбора его жизненного цикла. Явная колонка удобна тем, что её смысл читается в модели приложения и не зависит от интерпретации служебной информации хранения. Вместе с тем для неё нужен дисциплинированный протокол изменения.
Оптимистический подход не означает отсутствие блокировок внутри самого UPDATE. База всё равно согласует конкурентные изменения строк. Преимущество здесь в том, что приложение не удерживает ресурс, пока пользователь просматривает форму. Короткая запись проверяет известное намерение и либо сохраняет его, либо возвращает конфликт.
Какие изменения увеличивают версию
Версия становится полезной только при общем правиле всех путей записи. Если форма увеличивает version, а импорт заголовков меняет ту же карточку без увеличения, открытая форма не заметит работу импорта. Поэтому правило относится к предметному объекту, а не к одному экрану. Административный скрипт и фоновое обновление тоже должны учитывать договор, когда меняют видимое редактору состояние.
При этом версия не обязана расти из-за любого внутреннего технического действия. В дальнейшей миграции копия уровня из JSON в отдельную колонку не меняет видимую сложность курса. Такой перенос может иметь отдельное правило, явно отличающееся от обычного редактирования уровня. Главное — объявить смысл version заранее, иначе разные клиенты начнут трактовать её несовместимо.
Рассмотрим форму с полями title и price. Другая редакция изменила только описание, которое пользователь не видел. При общей версии всей карточки сохранение формы может получить конфликт. Это консервативное поведение: приложение не знает автоматически, независимы ли намерения. Можно проектировать более подробный договор с отдельными областями версий или осознанным слиянием, но случайно игнорировать конфликт только потому, что он неудобен, нельзя.
Ответ конфликта должен дать пользователю возможность увидеть актуальное состояние, сравнить его со своим и принять решение. Автоматическая подстановка новой version при сохранении старых значений превращает проверку в пустую формальность. В нашей двухсессионной последовательности ноль строк B — ожидаемая полезная защита, а не ошибка, которую следует убрать ради зелёного сообщения интерфейса.
Ручное наблюдение
Будущий порядок файлов таков: A читает A1, B читает B1, A сохраняет A2, B пробует B2, затем читает B3. Первые чтения должны произойти до фиксации A. Не запускайте файлы без этого порядка, иначе B получит новую версию уже при открытии формы и опыт будет другим.
Для повторения сначала завершите все блоки, затем отдельно восстановите reset. Начальные 1990 и один относятся к восстановленному набору, а не к любому состоянию базы. По ожидаемому итогу видно, что A сохранила изменение, B получила пустой ответ и прежняя форма не затёрла цену. Теперь редакторское приложение умеет связывать запись с прочитанным состоянием без долгого удержания строковой блокировки.