Слово «транзакция» звучит так, будто это одна понятная вещь: набор операций, которые применяются целиком или не применяются вовсе. На практике за этим словом скрывается целый спектр гарантий, и они сильно разнятся не только между классами хранилищ, но и между уровнями изоляции одной и той же СУБД. Прежде чем разбирать, как транзакции устроены в PostgreSQL, Redis, MongoDB, ScyllaDB, RabbitMQ и Kafka, нужен общий словарь: какие бывают аномалии, что обещает каждый уровень изоляции и какой ценой.
Это первая, обзорная статья серии «Транзакции и изоляция». Дальше мы будем постоянно ссылаться на здешние определения: аномалии из этой статьи — те самые, что мы будем воспроизводить на нагрузочных стендах в статьях 2–5.
В статье
- Транзакция и ACID: что на самом деле обещано
- BASE и почему не всем нужен полный ACID
- Каталог аномалий
- ANSI-уровни изоляции и их критика
- Механизмы: блокировки против MVCC
- Snapshot isolation и его предел
- Минимальные примеры: как задаётся уровень
- Карта серии
- Источники
Транзакция и ACID: что на самом деле обещано
ACID — не единый монолитный контракт, а четыре независимых обещания, и полезно разбирать их по отдельности, потому что цена и механизм реализации у каждого свои.
Atomicity — «всё или ничего»: набор операций внутри транзакции либо применяется целиком, либо не применяется вовсе, независимо от того, на каком шаге произошёл сбой. Реализуется через журналирование (write-ahead log) и откат при ошибке — если процесс упал посреди транзакции, при восстановлении незавершённые изменения откатываются. Atomicity ничего не говорит о видимости для других транзакций, пока первая не закоммичена, — это уже вопрос изоляции.
Consistency — здесь и начинается путаница, потому что слово «consistency» в этом контексте означает нечто узкое и прикладное: транзакция переводит базу из одного состояния, удовлетворяющего инвариантам, в другое состояние, тоже им удовлетворяющее. Инварианты — это то, что задаёт схема (внешние ключи, UNIQUE, CHECK) и, что важнее, то, что задаёт сама прикладная логика (сумма дебета и кредита равна нулю, баланс не уходит в минус). СУБД гарантирует только свою часть — ограничения схемы; за прикладные инварианты отвечает код транзакции, и именно неудачно выбранный уровень изоляции — самый частый способ эти инварианты незаметно нарушить, даже когда каждая транзакция в отдельности выглядит корректной.
Здесь важно явно развести два омонима. Consistency в ACID — про инварианты одной базы на момент коммита одной транзакции. Consistency в CAP (и в моделях согласованности реплик вообще) — про то, видят ли разные узлы распределённой системы одно и то же состояние данных в один момент времени. Это две разные оси: можно иметь строгую ACID-консистентность на одном узле и слабую consistency между репликами того же кластера — противоречия здесь нет, потому что вопросы разные. Смешивать эти два значения — источник немалой части путаницы вокруг слова «консистентность» в индустрии.
Isolation — насколько промежуточное состояние одной незакоммиченной транзакции видно другим конкурентным транзакциям. Это единственная из четырёх букв, которая на практике является настраиваемым параметром (SET TRANSACTION ISOLATION LEVEL ...), а не бинарным свойством «есть/нет». Она же — самая дорогая: полная изоляция (как будто транзакции выполняются строго одна за другой) требует либо блокировок, либо отслеживания зависимостей между чтениями и записями, и то и другое стоит пропускной способности и увеличивает вероятность конфликтов под нагрузкой. И она же — самая недопонятая: большинство разработчиков считают уровень изоляции базы данных «достаточно строгим по умолчанию» просто потому, что не сталкивались с аномалией на своей нагрузке, а не потому, что проверили это осознанно. Вся оставшаяся статья и вся серия — по сути, разбор именно этой буквы.
Durability — если транзакция подтвердила коммит клиенту, изменения переживут падение процесса, перезагрузку сервера, отключение питания. Обеспечивается записью в WAL до подтверждения коммита (fsync на диск или синхронная репликация на реплику) — и здесь тоже есть шкала: synchronous_commit = off в PostgreSQL, к примеру, обменивает часть durability на latency, и это осознанный выбор конфигурации, а не баг.
BASE и почему не всем нужен полный ACID
BASE — Basically Available, Soft state, Eventual consistency — формулировался не как теоретическая альтернатива ACID, а как постфактум-описание того, как уже были устроены первые по-настоящему крупные распределённые системы (Amazon Dynamo, ранний eBay). Идея: вместо строгих гарантий на каждой операции — доступность почти всегда, допущение, что состояние системы временно может быть противоречивым между узлами, и обещание, что оно сойдётся к согласованному виду, если прекратить писать.
Basically Available — система отвечает почти на любой запрос, даже если часть узлов недоступна или отстаёт; это прямое следствие выбора в пользу доступности при сетевых разделениях (та же ось, что в CAP). Soft state — состояние системы может меняться со временем даже без новых внешних запросов, просто за счёт фоновой синхронизации между репликами. Eventual consistency — если прекратить запись, через какое-то (не гарантированное заранее) время все реплики придут к одному и тому же значению.
Честный вопрос — где выбор BASE осознан, а где под видом BASE прячется просто отсутствие гарантий. Осознанный выбор — это когда прикладная область терпит временную рассинхронизацию (счётчик лайков, витрина каталога, кеш профиля пользователя), а взамен система получает горизонтальное масштабирование и доступность при сетевых разделениях, которых строгий ACID на таком масштабе физически не даёт бесплатно. Неосознанный — это когда «eventual consistency» становится маркетинговым эвфемизмом для «мы не гарантируем вообще ничего, просто обычно успевает разъехаться быстро», без формальной модели схождения, без ограничений на то, насколько «temporarily» может растянуться. Разница проверяется одним вопросом: может ли команда, разрабатывающая систему, сформулировать, при каких условиях и через какое время согласованность гарантированно восстановится. Если ответ — «как повезёт», это не BASE, это отсутствие модели.
Каталог аномалий
Все аномалии ниже — это то, что может случиться, если конкурентные транзакции работают друг с другом без должной изоляции. Дальше каждая пара шагов условно синхронизирована по времени: сначала строка T1, затем строка T2, если не указано иное.
Dirty read — чтение данных, записанных другой транзакцией, которая ещё не закоммитилась (и, возможно, откатится).
| Шаг | T1 | T2 |
|---|---|---|
| 1 | UPDATE accounts SET balance = 0 WHERE id = 1 (не закоммичено) |
|
| 2 | SELECT balance FROM accounts WHERE id = 1 → читает 0 |
|
| 3 | ROLLBACK |
|
| 4 | уже принял решение на основании значения, которого никогда не существовало |
Non-repeatable read — в рамках одной транзакции один и тот же SELECT по одному и тому же условию возвращает разные значения, потому что между двумя чтениями другая транзакция закоммитила изменение этой же строки.
| Шаг | T1 | T2 |
|---|---|---|
| 1 | SELECT balance FROM accounts WHERE id = 1 → 100 |
|
| 2 | UPDATE accounts SET balance = 50 WHERE id = 1; COMMIT |
|
| 3 | SELECT balance FROM accounts WHERE id = 1 → 50 (в той же транзакции) |
Phantom read — тот же эффект, но не на конкретной строке, а на диапазоне: повторный запрос по предикату (WHERE amount > 100) возвращает другой набор строк, потому что другая транзакция вставила или удалила строку, попадающую под условие.
| Шаг | T1 | T2 |
|---|---|---|
| 1 | SELECT count(*) FROM orders WHERE amount > 100 → 5 |
|
| 2 | INSERT INTO orders (amount) VALUES (150); COMMIT |
|
| 3 | SELECT count(*) FROM orders WHERE amount > 100 → 6 (в той же транзакции) |
Lost update — классика конкурентного инкремента. Обе транзакции читают одно и то же значение, каждая вычисляет новое на основе прочитанного и записывает — но вторая запись безусловно перезаписывает первую, и одно из изменений исчезает бесследно, без ошибки и без предупреждения.
| Шаг | T1 | T2 |
|---|---|---|
| 1 | SELECT balance FROM accounts WHERE id = 1 → 100 |
|
| 2 | SELECT balance FROM accounts WHERE id = 1 → 100 |
|
| 3 | вычисляет 100 + 10 = 110, UPDATE ... SET balance = 110 |
|
| 4 | вычисляет 100 + 20 = 120, UPDATE ... SET balance = 120 |
|
| 5 | оба COMMIT | итог: 120, прибавка T1 потеряна — должно было получиться 130 |
Read skew — частный случай нарушения согласованности между двумя связанными строками: транзакция читает их в разное время, а между чтениями другая транзакция меняет обе согласованно, и первая транзакция видит несогласованную комбинацию значений (одну «старую», другую «новую»).
| Шаг | T1 | T2 |
|---|---|---|
| 1 | SELECT balance FROM accounts WHERE id = 1 → 100 |
|
| 2 | перевод 50 со счёта 1 на счёт 2, оба UPDATE, COMMIT |
|
| 3 | SELECT balance FROM accounts WHERE id = 2 → уже увеличенный на 50 |
|
| 4 | T1 видит: счёт 1 = 100 (старое), счёт 2 = уже +50 (новое) — сумма по счетам «не сходится», хотя ни один инвариант в реальности не нарушен |
Write skew — самая коварная аномалия из каталога: каждая из двух транзакций в отдельности абсолютно корректна и не нарушает ни одного ограничения, если проверять её изолированно, но вместе они нарушают инвариант, который зависит от обеих строк сразу. Классический пример — два дежурных врача, у каждого есть право снять себя с дежурства, если остаётся хотя бы один дежурный.
| Шаг | T1 (дежурный A снимается) | T2 (дежурный B снимается) |
|---|---|---|
| 1 | SELECT count(*) FROM on_call WHERE date = today → 2 (A и B оба на дежурстве) |
|
| 2 | SELECT count(*) FROM on_call WHERE date = today → 2 (то же самое) |
|
| 3 | условие «остаётся ≥ 1» выполнено → DELETE ... WHERE doctor = 'A' |
|
| 4 | условие «остаётся ≥ 1» тоже выполнено (T2 ещё не видела удаления A) → DELETE ... WHERE doctor = 'B' |
|
| 5 | оба COMMIT | итог: дежурных ноль, хотя каждая транзакция была уверена, что оставляет как минимум одного |
Lost update и write skew часто путают, потому что оба — про конкурентную запись, но механика разная. В lost update обе транзакции пишут в одну и ту же строку, и одна запись физически затирает другую. В write skew транзакции пишут в разные строки, каждая запись сама по себе корректна и ничего не перезаписывает — ломается не строка, а инвариант, связывающий несколько строк между собой. Именно поэтому write skew нельзя починить простой блокировкой одной строки (SELECT ... FOR UPDATE на свою же запись не спасает — конфликт лежит между строками, а не внутри одной), и именно этой аномалии посвящена отдельная часть раздела про snapshot isolation ниже.
ANSI-уровни изоляции и их критика
Стандарт SQL-92 определяет четыре уровня изоляции через то, какие из трёх аномалий — dirty read, non-repeatable read, phantom read — уровень обязан предотвращать. Заметьте: lost update, read skew и write skew в исходном стандарте вообще не упоминаются как отдельные категории — это первая проблема, к которой мы вернёмся.
- Read Uncommitted — не гарантирует ничего из трёх; на практике почти никто из современных СУБД не реализует его буквально (даже когда его можно «выставить», поведение обычно совпадает с Read Committed).
- Read Committed — запрещает dirty read; non-repeatable read и phantom всё ещё возможны.
- Repeatable Read — запрещает dirty read и non-repeatable read; phantom read по букве стандарта всё ещё разрешён (хотя многие движки, включая PostgreSQL, на практике запрещают и его на этом уровне за счёт snapshot-семантики, о чём ниже).
- Serializable — запрещает все три; поведение эквивалентно тому, как если бы транзакции выполнялись строго последовательно, одна за другой, в каком-то порядке.
Ключевая критика пришла из статьи Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil, «A Critique of ANSI SQL Isolation Levels» (1995). Авторы показали три независимые проблемы стандарта. Во-первых, формальные определения аномалий в стандарте сформулированы через блокировки (терминология «read lock»/«write lock» просачивается прямо в текст стандарта), что неявно предполагает конкретный механизм реализации — блокировочный протокол — и становится двусмысленным или вовсе неприменимым для движков, построенных на других механизмах, в первую очередь MVCC. Во-вторых, каталог из трёх аномалий неполон: он не описывает lost update как отдельную категорию (хотя интуитивно это одна из самых опасных аномалий) и вообще не знает о write skew. В-третьих и самое важное для этой серии — авторы вводят и формально описывают snapshot isolation как отдельный, практически значимый уровень, который не вписывается в таблицу ANSI ни в одну из четырёх строк: он предотвращает dirty read, non-repeatable read и phantom read (то есть по формальным критериям стандарта выглядит как Serializable), но допускает write skew — а значит, полной сериализуемости не даёт. Именно эта работа задала словарь, которым индустрия пользуется до сих пор, и именно поэтому snapshot isolation заслуживает отдельного раздела ниже, а не просто строки в таблице ANSI-уровней.
Механизмы: блокировки против MVCC
Один и тот же заявленный уровень изоляции можно получить принципиально разными путями, и это не вопрос вкуса — цена и наблюдаемое поведение отличаются.
2PL (two-phase locking) — классический пессимистичный подход: перед чтением или записью строки транзакция берёт блокировку (разделяемую для чтения, эксклюзивную для записи), удерживает её до конца транзакции (фаза роста числа блокировок), и снимает только при коммите или откате (фаза освобождения) — отсюда «двухфазная». Строгий 2PL даёт настоящую сериализуемость, но платит за неё тем, что читатели блокируют писателей и наоборот, а при пересекающемся порядке захвата блокировок между транзакциями возникает deadlock — взаимное ожидание, которое разрешается только принудительным откатом одной из транзакций (детектор deadlock строит граф ожиданий и убивает одну из сторон цикла; а вот когда именно он запускается — зависит от движка: в PostgreSQL проверку делает сам ждущий backend, простояв deadlock_timeout, тогда как в некоторых СУБД этим занят отдельный периодический монитор).
MVCC (multi-version concurrency control) — вместо блокировки строки на чтение движок хранит несколько версий строки одновременно, и каждая транзакция видит тот снимок данных, который был согласован с моментом её старта (или момента последнего запроса, в зависимости от уровня). Ключевое следствие: читатели не блокируют писателей, и наоборот — SELECT не встаёт в очередь за UPDATE той же строки, потому что читает уже существующую более старую версию, пока писатель создаёт новую. Это резко снижает конкуренцию за блокировки на типичной OLTP-нагрузке ценой хранения нескольких версий строки и необходимости их периодически убирать (vacuum в PostgreSQL, аналоги в других движках).
Важное следствие для всей серии: название уровня изоляции — это контракт о поведении, а не о механизме, и один и тот же названный уровень в разных СУБД реализован по-разному и потому ловит разный набор аномалий. «Repeatable Read» в PostgreSQL фактически реализован через snapshot isolation (транзакция видит согласованный снимок на момент старта, и это ловит phantom read тоже, хотя стандарт этого не требует). «REPEATABLE READ» в MySQL/InnoDB — отдельная реализация, где уживаются два пути: обычный неблокирующий SELECT читает consistent snapshot (MVCC, без блокировок — как в PostgreSQL), а locking reads (SELECT ... FOR UPDATE/FOR SHARE) и DML берут next-key locks — построчная блокировка плюс gap lock на диапазон. Phantom read формально исключён и там, и там, но разными механизмами, и побочные эффекты на конкуренцию и deadlock у gap locks свои. Одинаковое название — разное поведение под нагрузкой; в статье 2 серии это будет видно на конкретных цифрах.
Snapshot isolation и его предел
Snapshot isolation (SI) — транзакция при старте (или при первом обращении к данным, в зависимости от движка) фиксирует согласованный снимок базы, и все последующие чтения внутри этой транзакции идут строго против этого снимка, независимо от того, что коммитят другие транзакции параллельно. Запись при коммите проверяется на конфликт с другими транзакциями, писавшими в ту же строку после начала снимка (first-committer-wins или аналог) — но не более того.
Популярность SI объясняется тем, что он даёт очень много практической изоляции почти бесплатно: dirty read, non-repeatable read и phantom read исключены самим фактом чтения из фиксированного снимка, читатели не блокируют писателей, и производительность на типичной нагрузке заметно выше, чем у полной сериализации через 2PL. Поэтому SI (в том или ином варианте) лежит в основе изоляции по умолчанию или «повышенного» уровня сразу в нескольких массовых движках: REPEATABLE READ в PostgreSQL, REPEATABLE READ в MySQL/InnoDB (хотя механизм иной, см. выше), snapshot-чтения в MongoDB, механизм согласованного чтения в Oracle.
Но у SI есть чёткий предел — write skew, разобранный в каталоге аномалий выше. Обе транзакции читают согласованный снимок, где инвариант ещё выполнен, каждая пишет в свою строку, ни одна запись не конфликтует с другой на уровне отдельной строки — и конфликт-чекер snapshot isolation просто не видит проблемы, потому что она не в конфликте записей, а в нарушенной связи между прочитанным и записанным в разных транзакциях. Отсюда практическое следствие уже сейчас: если в бизнес-логике есть инвариант, зависящий больше чем от одной строки («не меньше одного дежурного», «сумма долей равна 100%», «остаток на складе не уходит в минус при двух параллельных списаниях с разных позиций одного заказа»), snapshot isolation этот инвариант не защищает автоматически.
Ответ на этот предел — Serializable Snapshot Isolation (SSI), метод, тоже описанный в академической традиции, восходящей к работе Berenson et al., и реализованный в PostgreSQL как уровень SERIALIZABLE начиная с версии 9.1 (2011). SSI работает поверх обычного MVCC-снимка, но дополнительно отслеживает опасные rw-зависимости между транзакциями (когда одна транзакция читает то, что другая позже — или уже — записала, и наоборот) и ищет в графе этих зависимостей паттерн, который теоретически доказан как необходимое условие нарушения сериализуемости. При обнаружении такого паттерна PostgreSQL откатывает одну из вовлечённых транзакций с ошибкой SQLSTATE 40001 (serialization_failure) — это не баг и не редкий edge case, а штатный сигнал «повтори транзакцию заново», и приложение обязано быть готово к retry на этом коде. Цена SSI — дополнительные накладные расходы на отслеживание зависимостей и некоторая доля ложноположительных откатов (транзакции, которые в реальности не привели бы к нарушению, но паттерн зависимостей выглядел опасным) — но взамен вы получаете настоящую сериализуемость поверх MVCC, без блокировок на чтение. Подробный разбор поведения SERIALIZABLE под нагрузкой и паттерн retry — в статье 2 серии.
Минимальные примеры: как задаётся уровень
Ниже — не runnable-стенды (они будут в статье 2 и в digital-cookbook), а минимальный синтаксис: как именно уровень изоляции задаётся на уровне API клиента.
Go, драйвер pgx — уровень изоляции передаётся при открытии транзакции через pgx.TxOptions:
tx, err := pool.BeginTx(ctx, pgx.TxOptions{
IsoLevel: pgx.Serializable,
})
if err != nil {
return err
}
defer tx.Rollback(ctx)
// ... операции внутри транзакции ...
return tx.Commit(ctx)Java, стандартный JDBC — уровень изоляции задаётся на соединении константой из java.sql.Connection, до начала транзакции:
conn.setAutoCommit(false);
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
try {
// ... операции внутри транзакции ...
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
}Оба сниппета показывают только точку выбора уровня — ни retry-обёртки на 40001, ни пула соединений, ни обработки конкретного сценария (перевод средств, инкремент счётчика) здесь нет: это предмет статьи 2, где те же вызовы обрастают нагрузочным стендом, воспроизводящим lost update и write skew вживую.
Карта серии
Первая таблица — какие аномалии допускает каждый уровень изоляции. «Возможна» значит, что уровень не даёт формальной гарантии против аномалии (хотя конкретный движок иногда предотвращает больше, чем требует стандарт, — см. пример PostgreSQL/Repeatable Read выше). Отметка «зависит от реализации» у Repeatable Read для lost update и read skew — не уклончивость: сам ANSI-стандарт этих аномалий на уровне RR формально не адресует, и поведение расходится между движками. Реализация на блокировках (классический lock-based RR) их исключает; MVCC-реализация вроде MySQL/InnoDB может допустить lost update при read-modify-write без блокирующего чтения; а PostgreSQL под именем Repeatable Read даёт фактически snapshot isolation — строже стандарта — и предотвращает оба. Поэтому единого «нет» для колонки RR тут быть не может.
| Аномалия | Read Uncommitted | Read Committed | Repeatable Read | Snapshot Isolation | Serializable |
|---|---|---|---|---|---|
| Dirty read | возможна | нет | нет | нет | нет |
| Non-repeatable read | возможна | возможна | нет | нет | нет |
| Phantom read | возможна | возможна | возможна по стандарту | нет | нет |
| Lost update | возможна | возможна | зависит от реализации | нет | нет |
| Read skew | возможна | возможна | зависит от реализации | нет | нет |
| Write skew | возможна | возможна | возможна | возможна | нет |
Вторая таблица — что вообще значит «транзакция» за пределами классической реляционной модели, и где в серии про это подробно.
| Класс хранилища | Что там транзакция | Подробно |
|---|---|---|
| Реляционные (PostgreSQL, MySQL) | Полноценная ACID-транзакция с выбираемым уровнем изоляции; MVCC или блокировки под капотом | Статья 2 |
| KV (Redis) | MULTI/EXEC — атомарный батч: во время EXEC команды исполняются без вклинивания чужих, но rollback при ошибке команды нет; read-modify-write (GET до транзакции) им не защищён — для этого WATCH даёт оптимистичный CAS |
Статья 3 |
| Документные (MongoDB) | Атомарность гарантирована на уровне одного документа всегда; multi-document транзакции — поверх snapshot isolation, отдельная опция | Статья 3 |
| Wide-column (ScyllaDB, Cassandra) | logged BATCH — гарантия доставки через batchlog (eventual completion, реплей недоприменённого), не изоляция и не кросс-партиционная транзакция; настоящая линеаризуемость только через LWT на Paxos в пределах одного раздела |
Статья 3 |
| Брокеры (RabbitMQ, Kafka) | Не про изоляцию чтения, а про атомарность публикации и границы доставки; Kafka даёт транзакционный producer и exactly-once semantics в своих пределах | Статья 4 |
Пятая, финальная статья серии сводит все классы хранилищ в один прикладной сценарий на общем нагрузочном стенде и строит карту выбора — статья 5.
Источники
- Berenson, H., Bernstein, P., Gray, J., Melton, J., O’Neil, E., O’Neil, P. «A Critique of ANSI SQL Isolation Levels» (1995) — PDF
- Martin Kleppmann, «Designing Data-Intensive Applications», глава 7 «Transactions»
- PostgreSQL Documentation: Transaction Isolation
- Jepsen: Consistency Models — карта моделей согласованности
- Рабочие стенды серии (воспроизводятся начиная со статьи 2, эта статья — обзорная, без запуска):
digital-cookbook/databases/transactions
Комментарии