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

Введение: Что такое транзакция и зачем нужна изоляция

Когда вы создаете веб-сервис на Go, вы оперируете логическими концепциями бизнес-логики: «пользователь перевел деньги», «заказ оформлен», «товар зарезервирован». Но с точки зрения базы данных каждая такая операция распадается на россыпь физических манипуляций с дисковыми страницами и индексами: прочитать строку, проверить баланс, изменить число в оперативной памяти, добавить запись в лог.

Транзакция — это гениальная абстракция, превращающая цепочку хрупких и разнородных шагов в неделимую, квантовую единицу работы. Но за обманчиво лаконичным фасадом операторов BEGIN и COMMIT скрывается сложнейшая инженерная машинерия: координация сотен параллельных соединений, многоверсионное управление памятью, планирование блокировок строк и обеспечение устойчивости к внезапному обесточиванию сервера.

Для бэкенд-инженера на Go понимание транзакций — это не просто умение механически вызвать db.Begin(). Это способность:

  • Предсказывать, как поведет себя сервис при пиковом наплыве сотен параллельных горутин, конкурирующих за одну горячую строку.
  • Осознанно выбирать уровень изоляции, находя идеальный баланс между математической строгостью данных и пропускной способностью (throughput) системы.
  • Проектировать чистый Go-код, который элегантно обрабатывает тайм-ауты контекста, дедлоки и паники, не допуская утечек сетевых соединений.

В этой лекции мы разберем весь жизненный цикл транзакции от первого байта в сокете до сброса журнала на диск, сопоставим пессимистические блокировки с архитектурой MVCC и изучим, как эти механизмы отражаются на тактах CPU и дисковом вводе-выводе.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    A["Начало: BEGIN"]:::entry --> B["Выполнение операций"]
    B --> C{"Успех?"}
    C -->|"Да"| D["COMMIT: фиксация"]
    C -->|"Нет"| E["ROLLBACK: откат"]
    D --> F["Освобождение блокировок"]
    E --> F
    F --> G["Возврат соединения в пул"]
  

Жизненный цикл транзакции: от BEGIN до COMMIT

С точки зрения теории баз данных транзакция представляет собой строгий конечный автомат, проходящий через дискретные фазы:

  1. Active (Активное): Начальная фаза (BEGIN). Клиент отправляет SQL-команды (SELECT, INSERT, UPDATE), база данных модифицирует страницы в буферном пуле и генерирует записи упреждающего журнала.
  2. Partially Committed (Частично зафиксированное): Последний SQL-запрос приложения выполнен успешно. Сервер готовится к записи маркера фиксации, но сам коммит еще не подтвержден дисковым накопителем.
  3. Committed (Зафиксированное): Запись фиксации физически записана на диск. Изменения становятся персистентными и видимыми другим транзакциям в соответствии с выбранным уровнем изоляции.
  4. Failed (Сбойное): Произошла логическая ошибка (нарушение ограничения целостности, конфликт сериализации или отмена контекста). Дальнейшее выполнение невозможно.
  5. Aborted (Прерванное): Сервер выполнил ROLLBACK. Все модификации отменены, старые версии данных восстановлены.

Состояния на уровне ядра СУБД

Когда ваше приложение на Go начинает транзакцию через db.BeginTx(ctx, opts):

  1. Захват физического сокета: Драйвер Go извлекает свободное TCP-соединение из пула *sql.DB и монопольно закрепляет его за объектом *sql.Tx.
  2. Аллокация структур в памяти: На стороне СУБД бэкенд-процесс выделяет управляющую структуру транзакции в общей памяти (shared memory в PostgreSQL или буферном пуле InnoDB).
  3. Фиксация горизонта видимости: Инициализируется снимок видимости данных (для MVCC) либо запрашиваются предварительные блокировки разделяемого доступа.
  4. Передача управления: Драйвер возвращает управление горутине.

Когда приложение инициирует фиксацию через tx.Commit():

  1. В буфер журнала упреждающей записи помещается COMMIT record (запись фиксации WAL).
  2. Вызывается системный вызов fsync() (если параметр synchronous_commit = on), принудительно вытесняющий кэш контроллера накопителя.
  3. Снимаются все эксклюзивные блокировки строк и страниц, освобождая ожидающие транзакции.
  4. Снимок помечается закрытым, открывая возможность фоновому сборщику мусора VACUUM вычищать устаревшие кортежи.
Go
func ProcessOrder(ctx context.Context, db *sql.DB, orderID int64) (err error) {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer func() {
        if err != nil {
            _ = tx.Rollback() // Игнорируем ошибку отката, если уже есть ошибка
        }
    }()

    // ... бизнес-логика ...

    return tx.Commit() // Если здесь ошибка, defer выполнит Rollback
}

Уровни изоляции: спектр компромиссов

Изоляция защищает конкурентные транзакции от взаимного влияния. Если бы каждая транзакция монопольно блокировала всю базу данных, мы получили бы абсолютную надежность ценой превращения сервера в черепаху. Уровни изоляции — это шкала инженерных компромиссов между консистентностью и параллелизмом.

flowchart LR
    A["Read Uncommitted"] -->|"слабее"| B["Read Committed"]
    B -->|"строже"| C["Repeatable Read"]
    C -->|"максимум"| D["Serializable"]

    style A fill:#7f1d1d,stroke:#f87171,color:#f8fafc
    style B fill:#78350f,stroke:#f59e0b,color:#f8fafc
    style C fill:#065f46,stroke:#34d399,color:#f8fafc
    style D fill:#1e3a5f,stroke:#38bdf8,color:#f8fafc
  

Краткая характеристика уровней

Уровень Видит незафиксированное Повторное чтение Фантомы Реальная стоимость
Read Uncommitted Да Да Да Минимальная
Read Committed Нет Да Да Низкая
Repeatable Read Нет Нет Зависит от СУБД Средняя
Serializable Нет Нет Нет Высокая

Важно: в PostgreSQL REPEATABLE READ предотвращает и фантомные чтения благодаря механизму предикатных блокировок, тогда как в стандарте это не гарантируется.


Механизмы реализации: блокировки против MVCC

Индустрия реляционных СУБД выработала два фундаментальных механизма реализации изоляции:

1. Пессимистическая блокировка (Lock-Based)

В классической пессимистической модели читатели блокируют писателей, а писатели блокируют читателей. Для модификации или чтения строки транзакция обязана захватить разделяемый (Shared, S) или эксклюзивный (Exclusive, X) замок:

  • Преимущества: Предельная предсказуемость, отсутствие накладных расходов на хранение версий.
  • Недостатки: Высокий риск взаимных блокировок (Deadlocks), резкая деградация пропускной способности при росте числа читателей.

Пример в SQL:

SQL
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- эксклюзивная блокировка
-- ... другие операции ...
COMMIT;

2. Оптимистический контроль через версии (MVCC)

Архитектура Multi-Version Concurrency Control исходит из оптимистичной предпосылки: конфликты между чтением и записью редки. Поэтому читатели никогда не блокируют писателей, а писатели никогда не блокируют читателей:

  • Преимущества: Ошеломляющая скорость чтения — аналитические запросы могут часами сканировать таблицы, не мешая оперативным UPDATE.
  • Недостатки: Физическое разрастание таблиц из-за устаревших версий (Bloat), необходимость периодической фоновой сборки мусора (VACUUM).

Сравнение подходов в таблицах

Критерий Lock-Based (MySQL/InnoDB*) MVCC (PostgreSQL)
Чтение Может блокироваться Никогда не блокирует запись
Запись Блокирует строку Создаёт новую версию, старая остаётся
Фантомы Next-key locking предотвращает Предикатные блокировки в RR
Сборка мусора Не требуется Требуется VACUUM
Хранение Одна версия строки Несколько версий (до VACUUM)

* InnoDB использует гибрид: MVCC для чтений, блокировки для записей.


Как выбрать уровень изоляции в продакшене

Выбор уровня изоляции — это классический инженерный компромисс между вычислительными затратами и строгостью бизнес-правил:

Используйте READ COMMITTED, если:

  • Ваша логика допускает, что данные могут измениться между запросами внутри одной транзакции.
  • Вы явно обрабатываете конфликты на уровне приложения (optimistic locking через version колонку).
  • Вам важна максимальная пропускная способность при умеренных требованиях к консистентности.

Пример: счётчик просмотров статьи.

Go
// Допустимо, что два запроса увидят разное значение
_, err := tx.ExecContext(ctx, 
    "UPDATE articles SET views = views + 1 WHERE id = $1", 
    articleID,
)

Используйте REPEATABLE READ, если:

  • Вы выполняете несколько зависимых запросов, и результат второго зависит от первого.
  • Вам критично избежать lost update без явных блокировок.
  • Вы работаете с финансовыми операциями, где важна согласованность снимка данных.

Пример: расчёт итога заказа по нескольким таблицам.

Go
// Гарантируем, что цены товаров не изменятся между запросами
var total decimal.Decimal
err := tx.QueryRowContext(ctx, 
    "SELECT SUM(price * quantity) FROM order_items WHERE order_id = $1",
    orderID,
).Scan(&total)
// ... используем total для дальнейших расчётов ...

Используйте SERIALIZABLE только если:

  • Вы не можете допустить ни одной аномалии параллелизма.
  • Конфликты транзакций редки, и вы готовы обрабатывать ошибки сериализации повтором.
  • Бизнес-логика слишком сложна для ручного управления блокировками.
Go
func WithRetry(ctx context.Context, maxAttempts int, fn func(*sql.Tx) error) error {
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
        if err != nil {
            return err
        }
        if err := fn(tx); err != nil {
            _ = tx.Rollback()
            // Проверяем, можно ли повторить
            if pgErr, ok := err.(*pq.Error); ok && pgErr.Code == "40001" {
                continue // retry
            }
            return err
        }
        return tx.Commit()
    }
    return fmt.Errorf("max retry attempts exceeded")
}

Транзакции в Go: практические паттерны

Паттерн 1: Транзакция с контекстом и таймаутом

В распределенной системе любая транзакция обязана иметь жесткий предел жизни, чтобы предотвратить накопление зависших соединений:

Go
func UpdateUserBalance(ctx context.Context, db *sql.DB, userID int64, delta decimal.Decimal) error {
    // Ограничиваем общее время транзакции
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelReadCommitted,
    })
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer func() { _ = tx.Rollback() }()

    var balance decimal.Decimal
    err = tx.QueryRowContext(ctx, 
        "SELECT balance FROM users WHERE id = $1 FOR UPDATE", 
        userID,
    ).Scan(&balance)
    if err != nil {
        return fmt.Errorf("select balance: %w", err)
    }

    newBalance := balance.Add(delta)
    if newBalance.LessThan(decimal.Zero) {
        return fmt.Errorf("insufficient funds")
    }

    _, err = tx.ExecContext(ctx,
        "UPDATE users SET balance = $1, updated_at = NOW() WHERE id = $2",
        newBalance, userID,
    )
    if err != nil {
        return fmt.Errorf("update balance: %w", err)
    }

    return tx.Commit()
}

Паттерн 2: Вложенные транзакции (savepoints)

Хотя реляционные базы данных не поддерживают настоящую вложенность операторов BEGIN, стандарт SQL определяет механизм точек сохранения (Savepoints):

Go
func WithSavepoint(ctx context.Context, tx *sql.Tx, name string, fn func() error) error {
    _, err := tx.ExecContext(ctx, fmt.Sprintf("SAVEPOINT %s", name))
    if err != nil {
        return err
    }
    if err := fn(); err != nil {
        _, _ = tx.ExecContext(ctx, fmt.Sprintf("ROLLBACK TO SAVEPOINT %s", name))
        return err
    }
    _, err = tx.ExecContext(ctx, fmt.Sprintf("RELEASE SAVEPOINT %s", name))
    return err
}

// Использование
err := WithSavepoint(ctx, tx, "sp1", func() error {
    // операции, которые можно откатить независимо
    return nil
})

Механическая симпатия: цена изоляции на уровне железа

Гарантии транзакционной изоляции оплачиваются реальными аппаратными ресурсами вычислительного узла:

Память и кэш-линии

  • В MVCC-системах старые версии строк занимают место в буферном пуле и могут вытеснять «горячие» данные из кэша L3 CPU.
  • Долгие транзакции в REPEATABLE READ удерживают снимок, предотвращая очистку старых версий — это приводит к росту размера базы на диске и в памяти.

Дисковые операции

  • Каждый COMMIT при synchronous_commit=on вызывает fsync() — это сбрасывает кэш ядра ОС на диск, что может занимать 1-10 мс на SSD.
  • Групповой коммит (group commit) позволяет нескольким транзакциям «разделить» стоимость одного fsync(), но только если они завершаются почти одновременно.

CPU и планировщик

  • Управление блокировками требует атомарных операций (CAS — Compare-And-Swap), которые могут вызывать инвалидацию кэш-линий между ядрами CPU (cache coherency traffic).
  • В Go, когда горутина ждёт ответа от БД, она паркуется, и тред ОС может выполнять другие горутины. Но если транзакция держит соединение и блокировки в БД, это не помогает параллелизму на стороне СУБД.

Итог

  1. Транзакция — это строгий конечный автомат: Active → (Committed | Aborted). Управляйте её жизненным циклом явно через Commit/Rollback и защитный defer.
  2. Уровни изоляции — спектр компромиссов: от максимальной скорости (READ COMMITTED) до абсолютной консистентности (SERIALIZABLE).
  3. Фундаментальные механизмы: Пессимистические блокировки (Lock-Based) предотвращают конфликты через очереди ожидания; оптимистический MVCC обеспечивает молниеносные чтения через снимок версий строк.
  4. В коде на Go: Ограничивайте время транзакций таймаутами контекста, передавайте *sql.Tx явно и обязательно реализуйте логику повторов (retry) при ошибках сериализации.
  5. Механическое сочувствие: Блокировки и версии строк нагружают кэш-линии L3 CPU, порождают вызовы futex и требуют сбросов fsync(). Измеряйте и профилируйте поведение под реальной нагрузкой.

В следующей лекции мы в деталях препарируем фундаментальное трио: 3. Read Committed, Repeatable Read, Serializable и выясним, как работают снимки данных, виртуальное время и SSI под капотом.