При работе с транзакциями в SQLite часто проверяют откат, ожидая, что после него не останется никаких следов. Однако на практике встречается ситуация, когда проверка отката видит ноль записей версии v2 и на этом основании допускает старого читателя к данным. Затем неожиданно появляется запоздавший INSERT, который ломает следующее чтение. В этой статье разберём выполненный стенд на Python/SQLite, покажем оба порядка commit и условия, при которых старая версия всё-таки допускается. Полный код стенда и тесты приведены в раскрывающихся блоках.

Как устроен стенд и что проверяем

Стенд эмулирует типичный сценарий: есть таблица с версиями записей (v1 и v2), политика допускает чтение старой версии при определённых условиях, а транзакция должна гарантировать атомарность. Мы проверяем, что после отката не остаётся «фантомных» записей, которые могут повлиять на решение о допуске. В основе — Python-скрипт с использованием модуля sqlite3. Код доступен в раскрывающихся блоках статьи, здесь же разберём ключевые моменты.

Сначала создаётся таблица items с полями id, version и data. Затем в рамках транзакции вставляется запись v2, после чего выполняется откат. Проверка отката запрашивает количество записей с version=2 и ожидает ноль. Если ноль — политика разрешает старому читателю (version=1) продолжить работу. Однако в нашем случае после отката в таблице действительно ноль записей v2, и старый читатель допускается. Но затем происходит запоздавший INSERT — вставка v2 вне транзакции или в другой транзакции, которая не была отменена. В результате следующее чтение старого читателя видит уже две версии или нарушает целостность.

Порядок commit и условия допуска

Ключевой момент — порядок фиксации изменений. Мы рассмотрели два варианта: когда commit выполняется до проверки отката и когда после. В первом случае, если транзакция с вставкой v2 зафиксирована, то откат уже не поможет — запись останется. Во втором случае откат действительно удаляет вставку, но если после отката другая транзакция вставляет v2, то проверка, выполненная до этой вставки, уже не актуальна. Это классическая проблема гонки условий.

Условия допуска старой версии обычно формулируются так: «если нет более новой версии, разрешить чтение старой». Но проверка «нет более новой версии» должна быть атомарной с последующим чтением. В противном случае между проверкой и чтением может появиться новая версия. В нашем стенде мы явно воспроизводим эту ситуацию, чтобы показать, как запоздавший INSERT ломает следующее чтение.

Типичные ошибки и как их избежать

Ошибка 1: проверка отката без учёта параллельных транзакций. Если вы проверяете отсутствие записей v2 после отката, но не блокируете таблицу на время проверки и последующего чтения, другая транзакция может вставить v2. Решение — использовать транзакцию с уровнем изоляции SERIALIZABLE или явные блокировки.

Ошибка 2: неправильный порядок commit. Если вы фиксируете изменения до проверки условий, то откат уже не имеет смысла. Убедитесь, что все проверки выполняются внутри той же транзакции, что и изменения, и что commit происходит только после успешной проверки.

Ошибка 3: игнорирование запоздавших INSERT. Даже если вы откатили свою транзакцию, другие транзакции могут вставить данные. Поэтому политика допуска старой версии должна учитывать возможность появления новых версий в любой момент. Лучше использовать механизмы блокировки или версионирования на уровне базы данных.

  • Всегда проверяйте, что проверка и последующее действие выполняются в одной транзакции.
  • Используйте блокировки (например, BEGIN IMMEDIATE в SQLite) для предотвращения гонок.
  • Тестируйте сценарии с параллельными транзакциями, особенно с запоздавшими вставками.
  • Не полагайтесь на проверку количества записей после отката как на гарантию отсутствия новых данных.

Пример из стенда и выводы

В нашем стенде мы воспроизвели ситуацию: транзакция A вставляет v2, затем откатывается. Проверка видит ноль записей v2 и разрешает читателю R прочитать v1. Но сразу после проверки транзакция B вставляет v2 и фиксируется. Читатель R, выполняя следующее чтение, ожидает только v1, но получает и v2, что нарушает логику. Если бы проверка и чтение были атомарны, этого бы не произошло.

Вывод: при работе с откатами и проверками условий в SQLite (и не только) необходимо обеспечивать атомарность проверки и действия. Запоздавший INSERT — лишь один из примеров гонки. Используйте транзакции с блокировками, избегайте разделения проверки и чтения, и тестируйте параллельные сценарии. Полный код стенда и все тесты доступны в раскрывающихся блоках статьи — изучите их, чтобы увидеть, как именно воспроизводится сбой и какие есть способы его предотвратить.