Базы данных

Транзакции и уровни изоляции

2014 год. Bitcoin-биржа Mt. Gox объявляет о банкротстве, теряя 850 000 BTC. Аудит показал: ошибка transaction malleability + race condition в обработке снятий. Когда пользователи нажимали 'withdraw', система записывала транзакцию, но при определённой конкурентной последовательности тот же баланс списывался дважды. Это не баг блокчейна - это classical write skew на уровне приложения. Уровень изоляции базы данных стоит миллиарды, и большинство разработчиков узнают об этом, когда читают post-mortem.

  • **Stripe и финтех**: транзакции платежей идут на Serializable - retry до 5 раз с jitter; стоимость 'could not serialize' ошибки <<<<< стоимость потерянного $0.01 на счёте клиента
  • **Slack, GitHub и системы заявок**: рассмотрение конкурентных операций над одной issue/comment - типичный write skew; решается через SELECT FOR UPDATE по issue_id или SERIALIZABLE
  • **Airbnb и системы бронирования**: проблема двойного бронирования решена через row-level locks на calendar entries + Repeatable Read; до перехода на этот подход - несколько громких post-mortems

Уровни изоляции

Стандарт SQL-92 определяет 4 уровня изоляции в порядке нарастания строгости: Read Uncommitted, Read Committed, Repeatable Read, Serializable. Каждый уровень разрешает или запрещает определённые аномалии: dirty read (чтение незакоммиченных данных), non-repeatable read (то же чтение даёт разный результат внутри транзакции), phantom read (новые строки появляются в повторном диапазонном запросе). Serializable запрещает все три - результат конкурентного выполнения эквивалентен какому-то последовательному порядку транзакций.

Важный нюанс: реальные СУБД отклоняются от стандарта. PostgreSQL не имеет Read Uncommitted (он трактуется как Read Committed). PostgreSQL Repeatable Read на самом деле даёт snapshot isolation - это сильнее, чем требует SQL-92, но всё ещё не Serializable из-за write skew. Oracle вообще не реализует ни Read Uncommitted, ни Repeatable Read - только Read Committed и Serializable (которая по сути snapshot). MySQL InnoDB Repeatable Read имеет особенность: использует gap locks и блокирует фантомы, но не предотвращает write skew.

Команда запускает PostgreSQL с дефолтным уровнем изоляции. Какие аномалии возможны?

Грязные и фантомные чтения

**Dirty read** - транзакция T1 читает строку, изменённую транзакцией T2, ещё не закоммиченной. Если T2 откатится, T1 работала с фантомными данными. Большинство современных СУБД (PostgreSQL, Oracle) запрещают dirty read даже на самом слабом уровне. **Phantom read** более коварен: T1 делает 'SELECT COUNT(*) WHERE x > 100', между запросами T2 вставляет новую строку с x=150, второй COUNT возвращает другое число. Read Committed и Repeatable Read SQL-92 этого не блокируют.

Внутри СУБД фантомы решаются разными механизмами: Lock-based (2PL + range locks): MySQL InnoDB ставит gap locks на диапазон чтения - INSERT в этот диапазон блокируется до COMMIT. MVCC (snapshot isolation): PostgreSQL даёт транзакции консистентный снимок данных на момент BEGIN - новые INSERT не видны. Predicate locking: Serializable в PostgreSQL отслеживает предикаты запросов и обнаруживает потенциальные конфликты при COMMIT (SSI - Serializable Snapshot Isolation, изобретено Cahill в 2008).

В отчётном запросе считается общий доход за день, выполняется 3 SELECT подряд. Какой уровень изоляции гарантирует, что все 3 запроса увидят одинаковые данные?

Snapshot Isolation

PostgreSQL Repeatable Read - это **snapshot isolation**. На BEGIN транзакция получает виртуальный snapshot базы: видит только данные, закоммиченные до этого момента. Это реализуется через MVCC (Multi-Version Concurrency Control): каждое UPDATE/DELETE не перезаписывает строку, а создаёт новую версию с двумя метками - xmin (кто создал) и xmax (кто удалил). Транзакция видит версии с xmin < snapshot_id и (xmax = 0 OR xmax > snapshot_id). Старые версии удаляются VACUUM.

Достоинства snapshot isolation: (1) readers не блокируют writers и vice versa - идеально для аналитических запросов поверх OLTP-нагрузки; (2) согласованность чтений в рамках одной транзакции; (3) высокая пропускная способность. Недостатки: (1) накладные расходы на хранение версий до VACUUM (table bloat); (2) write skew - см. следующий раздел - не предотвращается; (3) при конкурентных UPDATE одной строки одна транзакция получает 'could not serialize' ошибку и должна повторить.

Как PostgreSQL обеспечивает snapshot isolation на уровне Repeatable Read?

Serializable и SSI

Уровень Serializable - самый строгий: результат любого набора одновременных транзакций должен быть эквивалентен какому-то последовательному порядку их выполнения. Классическая реализация через 2PL (Two-Phase Locking) с блокировками на чтение и запись имеет огромный оверхед. В 2008 Cahill et al. предложили **SSI (Serializable Snapshot Isolation)**: расширение snapshot isolation, которое отслеживает rw-conflicts между транзакциями (когда одна читает то, что другая пишет) и абортит транзакции на COMMIT, если обнаружен 'dangerous cycle'.

Разница между подходами: 2PL Serializable (DB2, SQL Server lock-based) - readers блокируют writers, низкая пропускная способность; SSI Serializable (PostgreSQL с 9.1) - оптимистичный подход поверх snapshot isolation, минимум блокировок, но возможны абортится транзакции с 'could not serialize access due to read/write dependencies'. Приложение обязано повторить транзакцию при ошибке 40001. CockroachDB и FoundationDB используют похожие на SSI подходы.

Приложение использует Serializable в PostgreSQL. При COMMIT получает 'could not serialize access'. Что нужно сделать?

Write Skew

**Write skew** - аномалия, при которой две транзакции читают пересекающийся набор строк, принимают решения на основе прочитанного и пишут в разные строки, нарушая бизнес-инвариант. Классический пример: больница требует наличия минимум одного дежурного врача. T1 и T2 читают, что дежурят Alice и Bob (двое - инвариант соблюдён), и параллельно отпрашиваются. Каждая транзакция думает 'останется один врач', обе коммитятся - и никого не остаётся. Snapshot isolation этого не обнаружит, потому что обе транзакции писали в разные строки.

Write skew - самая трудно отлавливаемая аномалия в production: типичный код 'check then act' выглядит корректно в тесте на одном потоке, но ломает инвариант под нагрузкой. Известные случаи: банковские системы (двойное списание), системы бронирования (двойное бронирование одного слота при разделении на places + reservations), счётчики rate limit. Если business logic основана на 'я только что прочитал и сейчас запишу' - подумайте о write skew. Решения: SERIALIZABLE, SELECT FOR UPDATE на читаемых строках, или materialize conflict.

Если СУБД поддерживает ACID, мои транзакции автоматически serializable

ACID гарантирует атомарность каждой транзакции, но не сериализуемость их совместного выполнения. Уровень изоляции выбирается явно

Большинство СУБД по умолчанию используют Read Committed или Snapshot Isolation - оба допускают write skew и/или phantom reads. Serializable есть везде, но платит производительностью и требует retry-логики в приложении. ACID - это не autopilot, а контракт, который надо знать.

В системе бронирования comma-separated списка timeslots на slot 14:00. Два пользователя видят 'свободно' и одновременно бронируют - оба получают slot. Это:

Ключевые идеи

  • **Уровни изоляции SQL-92** (Read Uncommitted -> Serializable) задают спектр компромиссов между производительностью и корректностью; реальные СУБД отклоняются от стандарта
  • **MVCC и snapshot isolation** (PostgreSQL Repeatable Read) дают согласованные чтения без блокировок через версионирование строк - идеально для read-heavy нагрузки
  • **SSI (Serializable Snapshot Isolation)** оптимистично детектит rw-конфликты на COMMIT и абортит транзакции с 40001 - приложение обязано иметь retry-логику
  • **Write skew** - неочевидная аномалия, не предотвращаемая snapshot isolation: транзакции читают пересечение, пишут в разные строки, нарушают бизнес-инвариант. Решается SERIALIZABLE или явными блокировками

Связанные темы

Mt. Gox из вступления потерял миллиарды на write skew - и эта аномалия стала каноническим примером того, как теория уровней изоляции встречается с production. Транзакционная модель - фундамент, на котором строятся индексы, журналы и распределённые СУБД:

  • MVCC и индексы — Snapshot isolation возможна только при наличии MVCC; индексы должны поддерживать видимость версий по xmin/xmax - это влияет на дизайн index entries в PostgreSQL
  • Distributed Transactions — В распределённой среде Serializable требует консенсусных протоколов (Spanner, FoundationDB) или 2PC; стоимость кратно выше, чем в одной СУБД
  • Query Optimization — Уровень изоляции влияет на план запроса: при RR/Serializable планировщик может выбрать другой индекс из-за необходимости видеть только версии до snapshot

Вопросы для размышления

  • PostgreSQL по умолчанию Read Committed, MySQL - Repeatable Read. Какие исторические и архитектурные причины привели к разным default'ам, и как это влияет на portability приложения?
  • Mt. Gox потерял миллиарды на write skew. Если бы они использовали SERIALIZABLE с retry-логикой, чем бы они заплатили за это? Стоила бы цена сохранения денег?
  • Snapshot isolation требует периодического VACUUM для очистки устаревших версий. Что произойдёт, если VACUUM не успевает за UPDATE-нагрузкой? Какой failure mode стоит ожидать?

Связанные уроки

  • db-03-acid — ACID - теоретическая база для понимания изоляции
  • db-14-mvcc — MVCC - механизм, реализующий Snapshot Isolation
  • db-15-locks — Блокировки реализуют Serializable без MVCC
  • dist-07-transactions — Распределённые транзакции - 2PC поверх тех же уровней изоляции
Транзакции и уровни изоляции

0

1

Войти