3. Read Committed, Repeatable Read, Serializable

Введение: Три кита изоляции транзакций

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

Чтобы примирить эти два взаимоисключающих требования, инженеры Bell Labs, IBM и сообщества СУБД разработали шкалу уровней изоляции. Три главных столпа этой шкалы — READ COMMITTED, REPEATABLE READ и SERIALIZABLE — представляют собой тщательно выверенные точки компромисса между чистотой данных и пропускной способностью аппаратных ресурсов.

Для бэкенд-инженера уровня Senior/Lead понимание различий между этими уровнями критично, потому что:

  • Неправильный выбор уровня изоляции — первопричина коварных и трудноуловимых багов в продакшене: потерянных обновлений (Lost Updates), расхождения финансовых отчетов и скрытых фантомов.
  • Каждый уровень изоляции имеет осязаемую физическую «цену» в ресурсах процессора, оперативной памяти, задержках системных вызовов и конкуренции за блокировки страниц.
  • В языке Go разработчик управляет транзакциями явно, и знание тонкостей изоляции необходимо для построения отказоустойчивых контуров с повторами (retry-петлями).

В этой лекции мы разберем механику каждого из трех уровней изоляции, заглянем во внутренние структуры данных ядра PostgreSQL и InnoDB, разберем поведение рантайма Go и оценим влияние изоляции на аппаратном уровне.

flowchart TD
    A["Уровни изоляции транзакций"] --> B["READ COMMITTED"]
    A --> C["REPEATABLE READ"]
    A --> D["SERIALIZABLE"]

    B --> E["Снимок на каждый SQL-запрос"]
    C --> F["Снимок на всю транзакцию"]
    D --> G["Серийная эквивалентность (SSI / Блокировки)"]

    E --> H["Допускает:<br>• Non-Repeatable Read<br>• Phantom Read<br>• Lost Update (без блокировок)"]
    F --> I["Гарантирует:<br>• Защиту от Non-Repeatable Read<br>• Защиту от Phantom Read*"]
    G --> J["Исключает все аномалии:<br>• Включая Write Skew"]

    style B fill:#10b981,color:#fff
    style C fill:#f59e0b,color:#fff
    style D fill:#6366f1,color:#fff
  

* В PostgreSQL REPEATABLE READ предотвращает и фантомные чтения благодаря предикатным блокировкам.


READ COMMITTED: Снимок на каждый запрос

READ COMMITTED — это рабочий стандарт современной индустрии и уровень изоляции по умолчанию в подавляющем большинстве СУБД (PostgreSQL, MySQL/InnoDB, Oracle).

Его фундаментальный принцип прост: транзакция никогда не видит незафиксированных изменений (грязных данных), но каждый отдельный SQL-запрос внутри транзакции получает свежий моментальный снимок базы данных.

Механика в PostgreSQL (MVCC)

Когда транзакция выполняет чтение в режиме READ COMMITTED:

  1. При начале каждого отдельного оператора (SELECT, UPDATE, DELETE) ядро СУБД формирует новый моментальный снимок (Snapshot), фиксируя список активных на данный момент транзакций.
  2. Видимость кортежа (строки таблицы) проверяется по правилу:

    TEXT
    строка видима, если:
      (xmin < snapshot_xmin AND (xmax = 0 OR xmax >= snapshot_xmin))
    

    где xmin — идентификатор транзакции, создавшей строку, а xmax — идентификатор транзакции, удалившей или обновившей ее.

  3. Следующий оператор в этой же транзакции строит новый снимок — и мгновенно видит все изменения, которые успели зафиксировать сторонние транзакции за время между запросами!

Пример аномалии: Non-Repeatable Read

Представим классическую ситуацию неповторяющегося чтения:

SQL
-- Транзакция 1 (Генерация финансовой выписки)
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- Возвращает 100

-- Транзакция 2 (Параллельное списание средств)
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT; -- Изменение зафиксировано в WAL и на страницах памяти

-- Транзакция 1 (Повторный запрос в той же транзакции)
SELECT balance FROM accounts WHERE id = 1; -- Вернёт 200! (Снимок обновился)
COMMIT;

Для транзакции 1 мир изменился прямо посреди ее выполнения: одна и та же строка вернула разные значения.

Пример в Go: когда READ COMMITTED достаточно

Если бизнес-логика не требует идеальной заморозки времени между запросами, а опирается на атомарные операции в СУБД, READ COMMITTED обеспечивает максимальную скорость:

Go
// Сценарий: инкремент счётчика, где точная консистентность между чтениями не критична
func IncrementViewCount(ctx context.Context, db *sql.DB, articleID int64) error {
    _, err := db.ExecContext(ctx,
        "UPDATE articles SET views = views + 1 WHERE id = $1",
        articleID,
    )
    // При READ COMMITTED (уровень по умолчанию) это безопасно:
    // каждая операция атомарна на уровне строки, потерянные обновления маловероятны
    // благодаря атомарному UPDATE на стороне СУБД
    return err
}
Go
// ОШИБОЧНО: гонка при любом уровне изоляции ниже SERIALIZABLE
var views int
err := db.QueryRowContext(ctx, "SELECT views FROM articles WHERE id = $1", id).Scan(&views)
// ... между SELECT и UPDATE другая транзакция может изменить views ...
_, err = db.ExecContext(ctx, "UPDATE articles SET views = $1 WHERE id = $2", views+1, id)
// Результат: одно из обновлений будет потеряно

Решение: использовать атомарные операции в SQL (views = views + 1) или явные блокировки (FOR UPDATE).


REPEATABLE READ: Снимок на всю транзакцию

Уровень REPEATABLE READ переводит систему в режим «замороженного времени». Он гарантирует, что все запросы внутри транзакции видят ровно один и тот же моментальный снимок данных, зафиксированный на момент выполнения первого оператора транзакции.

Механика в PostgreSQL

  1. Моментальный снимок создается единожды — при исполнении первого запроса (SELECT или DML).
  2. Все последующие запросы транзакции используют этот первоначальный снимок, игнорируя любые сторонние коммиты.
  3. Если транзакция пытается изменить (UPDATE/DELETE) строку, которая была модифицирована и зафиксирована другой параллельной транзакцией после момента создания нашего снимка, база данных немедленно прерывает операцию с ошибкой serialization_failure (could not serialize access due to concurrent update).

Пример: консистентный отчёт в одной транзакции

SQL
-- Транзакция 1: формирование отчёта
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- Запрос 1: общая сумма заказов
SELECT SUM(total) FROM orders WHERE status = 'completed'; -- 10000

-- Транзакция 2 (параллельно)
BEGIN;
INSERT INTO orders (total, status) VALUES (500, 'completed');
COMMIT; -- новая запись зафиксирована

-- Транзакция 1: повторный запрос
SELECT SUM(total) FROM orders WHERE status = 'completed'; -- всё ещё 10000!
-- Новый заказ не виден, потому что снимок создан в начале транзакции

COMMIT;

Пример в Go: расчёт с зависимыми запросами

Go
func CalculateOrderTotal(ctx context.Context, tx *sql.Tx, orderID int64) (decimal.Decimal, error) {
    // Гарантируем, что цены товаров не изменятся между запросами
    var subtotal decimal.Decimal
    err := tx.QueryRowContext(ctx, `
        SELECT SUM(price * quantity)
        FROM order_items
        WHERE order_id = $1
    `, orderID).Scan(&subtotal)
    if err != nil {
        return decimal.Zero, fmt.Errorf("calc subtotal: %w", err)
    }

    var discount decimal.Decimal
    err = tx.QueryRowContext(ctx, `
        SELECT COALESCE(SUM(amount), 0)
        FROM order_discounts
        WHERE order_id = $1 AND applied = true
    `, orderID).Scan(&discount)
    if err != nil {
        return decimal.Zero, fmt.Errorf("calc discount: %w", err)
    }

    return subtotal.Sub(discount), nil
}

// Использование
func ProcessOrder(ctx context.Context, db *sql.DB, orderID int64) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelRepeatableRead,
    })
    if err != nil {
        return err
    }
    defer func() { _ = tx.Rollback() }()

    total, err := CalculateOrderTotal(ctx, tx, orderID)
    if err != nil {
        return err
    }

    // ... дальнейшая логика с использованием total ...

    return tx.Commit()
}

SERIALIZABLE: Строгая серийная эквивалентность

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

Реализация в PostgreSQL: Serializable Snapshot Isolation (SSI)

Исторически сериализуемость достигалась через тяжелые предикатные блокировки таблиц и диапазонов индексов (2PL — Two-Phase Locking), что полностью парализовало систему. В PostgreSQL применен революционный алгоритм SSI (Serializable Snapshot Isolation):

  1. Транзакции читают данные без блокировок, используя обычные снимки MVCC (как в REPEATABLE READ).
  2. В процессе чтения ядро СУБД отслеживает зависимости типа чтение-запись (rw-antidependency):
    • Read-Write conflict: Транзакция T1 прочитала версию строки, а транзакция T2 позже перезаписала эту строку.
    • Write-Read conflict: Транзакция T1 изменила строку, которую затем прочитала T2.
  3. Движок строит направленный граф конфликтов. Если в графе образуется опасный цикл (T1 → T2 → T1), ядро немедленно объявляет одну из транзакций жертвой и прерывает ее с ошибкой сериализации serialization_failure (SQLSTATE 40001).

Пример: предотвращение lost update

SQL
-- Транзакция 1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1; -- 100

-- Транзакция 2 (параллельно)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1; -- 100 (снимок той же эпохи)

-- Транзакция 1
UPDATE accounts SET balance = 90 WHERE id = 1;
COMMIT; -- успешно

-- Транзакция 2
UPDATE accounts SET balance = 80 WHERE id = 1;
COMMIT; -- ERROR: could not serialize access due to read/write dependencies among transactions

Паттерн ретрая в Go для SERIALIZABLE

Для работы с SERIALIZABLE приложение обязано быть готово к ошибкам 40001 и уметь перезапускать операцию:

Go
func RunWithRetry(ctx context.Context, db *sql.DB, 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 fmt.Errorf("begin tx: %w", err)
        }

        if err := fn(tx); err != nil {
            _ = tx.Rollback()
            // Проверяем ошибку сериализации для PostgreSQL
            if pgErr, ok := err.(*pq.Error); ok && pgErr.Code == "40001" {
                // Экспоненциальная задержка перед повтором
                delay := time.Duration(1<<uint(attempt-1)) * 10 * time.Millisecond
                select {
                case <-time.After(delay):
                    continue
                case <-ctx.Done():
                    return ctx.Err()
                }
            }
            return err
        }

        if err := tx.Commit(); err != nil {
            if pgErr, ok := err.(*pq.Error); ok && pgErr.Code == "40001" && attempt < maxAttempts {
                delay := time.Duration(1<<uint(attempt-1)) * 10 * time.Millisecond
                time.Sleep(delay)
                continue
            }
            return fmt.Errorf("commit: %w", err)
        }
        return nil
    }
    return fmt.Errorf("max retry attempts exceeded")
}

Сравнительная таблица: что предотвращает каждый уровень

Аномалия READ COMMITTED REPEATABLE READ (PG) SERIALIZABLE
Dirty Read ✅ Предотвращает ✅ Предотвращает ✅ Предотвращает
Non-Repeatable Read ❌ Допускает ✅ Предотвращает ✅ Предотвращает
Phantom Read ❌ Допускает ✅ Предотвращает* ✅ Предотвращает
Lost Update ❌ Допускает** ✅ Предотвращает ✅ Предотвращает
Write Skew ❌ Допускает ❌ Допускает ✅ Предотвращает

* В стандартном SQL REPEATABLE READ не гарантирует защиту от фантомов, но PostgreSQL реализует это через предикатные блокировки.
** В READ COMMITTED lost update предотвращается только если вы используете атомарные UPDATE ... = ... + 1 или явные блокировки FOR UPDATE.


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

Выбор уровня изоляции влияет на утилизацию ресурсов не только в СУБД, но и в вашем Go-приложении.

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

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

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

  • Каждый COMMIT при synchronous_commit=on вызывает fsync() — системный вызов, который сбрасывает кэш ядра ОС на диск.
  • В SERIALIZABLE частые откаты из-за конфликтов означают, что вы платите стоимость fsync() за работу, которая не была зафиксирована.

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

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

Практические рекомендации: как выбрать уровень

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

  • Ваша логика допускает, что данные могут измениться между запросами.
  • Вы используете атомарные операции в SQL (UPDATE ... = ... + 1).
  • Вам важна максимальная пропускная способность.
  • Вы явно обрабатываете конфликты через оптимистический контроль версий.
Go
// Оптимистический контроль версий: альтернатива строгой изоляции
func UpdateWithVersion(ctx context.Context, db *sql.DB, id int64, newValue string, version int) error {
    res, err := db.ExecContext(ctx,
        "UPDATE items SET value = $1, version = version + 1 WHERE id = $2 AND version = $3",
        newValue, id, version,
    )
    if err != nil {
        return err
    }
    if rows, _ := res.RowsAffected(); rows == 0 {
        return fmt.Errorf("optimistic lock failed: version mismatch")
    }
    return nil
}

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

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

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

  • Вы не можете допустить ни одной аномалии параллелизма.
  • Конфликты транзакций редки (<5% запросов).
  • Вы готовы реализовать логику ретрая с экспоненциальной задержкой.

Итог

  1. READ COMMITTED: снимок на запрос, высокая производительность, допускает non-repeatable read и phantom read. Подходит для большинства OLTP-сценариев с атомарными операциями.
  2. REPEATABLE READ: снимок на транзакцию, предотвращает non-repeatable read и (в PostgreSQL) phantom read. Требует обработки ошибок при обновлении «устаревших» строк.
  3. SERIALIZABLE: строгая серийная эквивалентность через SSI, предотвращает все аномалии, но может часто откатывать транзакции при конкуренции. Требует паттерна ретрая.
  4. В Go: всегда обрабатывайте ошибки сериализации, используйте defer для отката, явно передавайте *sql.Tx, избегайте чтения-вычисления-записи без блокировок.
  5. Производительность: каждый уровень имеет цену в памяти, диске и CPU. Профилируйте с pprof и EXPLAIN ANALYZE, а не выбирайте уровень «на глаз».

Понимание уровней изоляции — это только половина картины. Чтобы писать надёжный код, нужно также знать, какие именно аномалии они предотвращают и как они проявляются в реальных сценариях.

В следующей статье мы детально разберём конкретные аномалии параллельного выполнения: 4. Dirty Read, Phantom Read, Lost Update, и покажем, как воспроизвести их в тестах и защитить своё приложение.