В графике технической поддержки на смену назначены два инженера. Правило простое: хотя бы один должен остаться на дежурстве. Оба одновременно просят снять себя со смены. Приложение для каждого проверяет список, видит второго дежурного и разрешает изменение. Каждый изменил только свою запись, обе транзакции прошли успешно. Дежурить некому.
Это иллюстративный пример ошибки, которую не поймает обычная проверка «операция записана целиком». Никаких половинок записи нет. Нарушено правило, связывающее несколько записей. Именно такие случаи делают уровни изоляции предметом продуктового решения, а не только настройки базы.
Данные могут быть правильными, но недостаточными для решения
Слабая изоляция не обязательно означает, что приложение читает мусор или незавершённые записи. Оно может видеть только успешно сохранённые данные и всё равно принимать решение на основании состояния, которое соседняя операция меняет одновременно.
В примере со сменой каждая транзакция рассуждала правильно относительно увиденного списка. Проблема появилась в сочетании двух решений. Такое поведение называют write skew: операции меняют разные записи, но вместе нарушают общее ограничение. Проверка версии только собственной строки не обнаружит, что другая строка тоже участвовала в основании решения.
Поэтому начинать полезно с правила: «после любого изменения в смене остаётся хотя бы один дежурный». Требование «используем транзакции» слишком широкое. Оно ещё не сообщает, какая конкурентная ситуация должна быть запрещена.
Чем отличаются уровни на практике
У Read Committed приложение не должно видеть незавершённые изменения других транзакций. Но последовательные чтения внутри одной операции могут получить разные состояния. Например, отчёт прочитал состав смены, а затем отдельно посчитал доступных сотрудников уже после чужого изменения. Каждая выборка корректна сама по себе, но соединять их как единый снимок нельзя без дополнительных гарантий.
Поведение лучше обсуждать на конкретной СУБД. В PostgreSQL обычное чтение на Read Committed получает снимок для отдельного запроса. На Repeatable Read используется устойчивый снимок, выбранный при первом содержательном запросе транзакции. Это помогает получить согласованный отчёт, однако не устраняет все аномалии конкурентных решений — в частности, сценарий со сменой может сохраниться.
Названия уровней между продуктами недостаточно надёжны для переноса предположений. Например, Repeatable Read в PostgreSQL и InnoDB в MySQL различается деталями блокировок и чтений. Документация конкретной версии важнее таблицы из учебника. Отдельно нужно учитывать, читает ли приложение основную базу или отстающую реплику: это ещё один источник различий, который выбор изоляции на основном узле не отменяет.
Serializable не означает «всех запускаем по очереди»
Сериализуемое выполнение должно давать результат, который можно объяснить некоторой последовательностью тех же транзакций. Если бы инженеры снимались со смены действительно по очереди, первый запрос прошёл бы, а второй увидел бы, что больше уходить нельзя. Состояние «ушли оба» так получить невозможно.
База может обеспечивать это разными способами. В PostgreSQL Serializable, помимо обычных механизмов управления конкуренцией, отслеживает опасные зависимости и может отклонить транзакцию с ошибкой сериализации. Он не обязан держать все операции в общей очереди. Однако проверки, отмены и повторная работа имеют стоимость.
Нужно заново выполнить отклонённую транзакцию целиком: перечитать график, проверить наличие дежурного и только потом решать, можно ли сохранить изменение. Просто повторить последний UPDATE со старым выводом «второй человек ещё есть» означает повторить ошибку приложения.
Гарантия действует в пределах участвующих транзакций и выбранной системы. Если часть изменений обходит согласованные проверки, например административный скрипт напрямую меняет график, одно строгое место в приложении не защитит все остальные пути.
Иногда правило проще выразить иначе
Повышение изоляции — не единственный вариант. Для уникального номера заявки обычно разумно использовать уникальное ограничение, а не надеяться на предварительный запрос «такого номера ещё нет». Для счётчика доступной квоты может подойти одно атомарное условное обновление, которое уменьшает значение лишь при достаточном остатке.
В графике дежурств можно ввести общую запись смены и согласованный протокол: все изменения состава сначала защищают эту запись, затем проверяют текущий список и выполняют изменение в короткой транзакции. Но этот протокол должен соблюдать каждый путь записи. Заблокировать только собственную запись инженера недостаточно: у второго инженера другая запись.
И не любое сложное условие превращается в обычный CHECK. В PostgreSQL такой CHECK не предназначен для произвольной проверки остальных строк таблицы. Выбор между изменением модели данных, блокировкой и Serializable требует понимания конкретного ограничения, а не предпочтения самого строгого названия.
Проверка, которую стоит включить в приёмку
Одиночный тест, снимающий одного инженера со смены, здесь почти бесполезен. Нужны две независимые сессии: обе читают исходный список до того, как любая успеет записать результат, и затем пытаются завершить изменение. Проверять следует итоговое наличие дежурного, а не только коды ответов.
После исправления один запрос может ждать, быть отклонён или успешно повториться с новым решением. Интерфейс должен понятно объяснить отказ: «Вы остались единственным дежурным», а не бесконечно показывать общую техническую ошибку. Для автоматических операций повторы нужно ограничить временем и числом попыток.
Стоимость выбранной защиты видна по ожиданию блокировок, доле отмен, количеству повторов и времени завершения пользовательского действия. На практике настройку приходится оценивать вместе с этими показателями. Быстрый ответ, после которого в смене никого не осталось, не является приемлемой оптимизацией.