Как СУБД реализуют уровни изоляции транзакций и как с этим работать в Go?

Ответ

Уровни изоляции транзакций — это набор правил, которые определяют, как транзакции видят изменения, сделанные параллельно идущими транзакциями. В Go уровень изоляции задается при создании транзакции через database/sql.

Как это выглядит в Go:

// Контекст для управления временем жизни транзакции
ctx := context.Background()

// Указываем уровень изоляции в опциях
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelRepeatableRead, // Например, Repeatable Read
    ReadOnly:  false,
})
if err != nil {
    log.Fatal(err)
}
// ... работа с транзакцией ...
tx.Commit() // или tx.Rollback()

Стандартные уровни изоляции и проблемы, которые они решают:

  1. Read Uncommitted: Самый слабый уровень. Предотвращает только потерянные обновления. Позволяет читать незафиксированные («грязные») данные (Dirty Read).
  2. Read Committed: Транзакция видит только зафиксированные данные. Предотвращает Dirty Read. Однако, если одна и та же строка читается дважды в одной транзакции, результат может отличаться (Non-Repeatable Read). Это уровень по умолчанию в PostgreSQL.
  3. Repeatable Read: Гарантирует, что при повторных чтениях одной и той же строки в рамках одной транзакции результат будет одинаковым. Предотвращает Non-Repeatable Read. Однако возможна проблема Phantom Read (фантомное чтение), когда другая транзакция добавляет новые строки, удовлетворяющие условию выборки.
  4. Serializable: Самый строгий уровень. Гарантирует полную изоляцию транзакций, как если бы они выполнялись последовательно. Предотвращает все аномалии, включая Phantom Read.

Как это реализовано «под капотом» в СУБД:

  • MVCC (Multi-Version Concurrency Control): Основной механизм в PostgreSQL, Oracle и MySQL (InnoDB). Вместо блокировки данных при чтении, СУБД создает «снимок» (snapshot) данных на момент начала транзакции. При изменении строки создается ее новая версия, а старая сохраняется для транзакций, которые начались раньше. Это позволяет читающим транзакциям не блокировать пишущие.
  • Блокировки (Locking): Более традиционный механизм. Бывают общие (shared, для чтения) и эксклюзивные (exclusive, для записи) блокировки. Этот подход может приводить к простоям и взаимоблокировкам (deadlocks), но он проще и используется во многих системах, часто в комбинации с MVCC.

Ответ 18+ 🔞

А, ну вот, классика! Подходишь ты к базе, как к мирной библиотеке, а там, блядь, целый театр с изоляциями, снимками и блокировками. Чистая политика, ёпта! Сейчас разложу, как есть, без соплей.

Смотри, представь: у тебя в приложении несколько горутин одновременно лезут в базу — одна деньги снимает, другая читает баланс, третья, сука, историю транзакций пишет. И вот эта вся весёлая компания должна как-то не насрать друг другу в тарелку. Для этого и придумали уровни изоляции — правила дорожного движения для транзакций, чтобы они не въебались друг в друга на перекрёстке.

В Go это выглядит просто, как палка:

ctx := context.Background()

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelRepeatableRead, // Вот тут и выбираешь, насколько параноиком быть
    ReadOnly:  false,
})
if err != nil {
    log.Fatal(err)
}
// ... делаешь свои делишки ...
tx.Commit() // или, если всё пошло по пизде, tx.Rollback()

А теперь про сами уровни, от «похуй» до «я вас всех пересажаю»:

  1. Read Uncommitted (Читай что попало): Это уровень полного распиздяйства. Транзакция видит даже те данные, которые другая транзакция ещё не закоммитила, а только накатала в черновик. Представь, что ты смотришь в экран коллеги, который печатает отчёт, но ещё даже не сохранил. Увидел цифру — обрадовался, а он её стёр и переписал. Это и есть Dirty Read — чтение грязного белья. От аномалий спасает только от потерянных обновлений (чтоб два дурака не перезаписали одно и то же). Использовать — всё равно что пить из общей помойки.

  2. Read Committed (Читай только утверждённое): Уровень здравого смысла, часто стоит по умолчанию (например, в PostgreSQL). Тут уже нельзя читать чужие черновики — только зафиксированные данные. Dirty Read отсекается. Но! Есть подвох. Если в одной транзакции ты дважды прочитаешь одну и ту же строку, между этими чтениями другая транзакция может её изменить и закоммитить. И ты, такой довольный, получишь два разных значения. Это Non-Repeatable Read (неповторяемое чтение). Как будто дважды спросил у начальника про отпуск, а он тебе дал разные ответы.

  3. Repeatable Read (Повторяй как попугай): Вот тут уже серьёзнее. Транзакция делает снимок данных на момент своего первого запроса и потом, как упоротый, видит только эту картинку. Сколько раз ни читай одну строку — результат будет одинаковый. Non-Repeatable Read побеждён. Но, блядь, есть призраки! Phantom Read (фантомное чтение). Другая транзакция может добавить НОВЫЕ строки, которые подходят под твои условия выборки. Ты первый раз выбрал 10 записей, второй раз — а их уже 12, потому что кто-то влез и добавил. Как будто в твоём холодильнике появилась еда, которую ты не покупал. Жуть.

  4. Serializable (Полный порядок): Уровень максимального параноика. Транзакции выполняются так, будто они идут строго одна за другой, даже если на самом деле работают параллельно. Все аномалии, включая фантомов, нахуй отправляются. Цена? Скорость. Всё может тормозить, потому что СУБД приходится жёстко координировать всех.

А как это всё работает под капотом, спросишь? Два основных подхода:

  • MVCC (Управление параллелизмом через многоверсионность): Это как машина времени, ёбта! В PostgreSQL, Oracle и InnoDB так и делают. Когда ты начинаешь транзакцию, тебе выдают «снимок» данных. Если кто-то в это время меняет строку, СУБД не перезаписывает её, а создаёт новую версию. Твоя старая транзакция продолжает видеть старую версию из своего снимка. Читающие не мешают пишущим, пишущие — читающим. Красота! Но за всё надо платить — старые версии надо потом подчищать (vacuum в PostgreSQL).

  • Блокировки (Locking): Более тупой и силовой метод. Хочешь читать — повесь общую блокировку (shared lock). Хочешь писать — эксклюзивную (exclusive lock), и пока ты её держишь, все остальные ждут. Может приводить к очередям и, самое весёлое, к взаимоблокировкам (deadlock), когда две транзакции ждут друг от друга ресурсы и стоят, как два барана. СУБД потом одну из них убивает, чтобы остальные могли жить. Часто используется в миксте с MVCC.

Короче, выбирай уровень изоляции, как костюм: для отчёта — один, для перевода денег — другой. А если начнёшь везде пихать Serializable — готовься к тому, что твоё приложение будет работать со скоростью заросшей улитки, блядь. Всё есть компромисс.