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:
- При начале каждого отдельного оператора (
SELECT,UPDATE,DELETE) ядро СУБД формирует новый моментальный снимок (Snapshot), фиксируя список активных на данный момент транзакций. -
Видимость кортежа (строки таблицы) проверяется по правилу:
TEXT строка видима, если: (xmin < snapshot_xmin AND (xmax = 0 OR xmax >= snapshot_xmin))где
xmin— идентификатор транзакции, создавшей строку, аxmax— идентификатор транзакции, удалившей или обновившей ее. -
Следующий оператор в этой же транзакции строит новый снимок — и мгновенно видит все изменения, которые успели зафиксировать сторонние транзакции за время между запросами!
Пример аномалии: Non-Repeatable Read
Представим классическую ситуацию неповторяющегося чтения:
-- Транзакция 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 обеспечивает максимальную скорость:
// Сценарий: инкремент счётчика, где точная консистентность между чтениями не критична
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
}
// ОШИБОЧНО: гонка при любом уровне изоляции ниже 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
- Моментальный снимок создается единожды — при исполнении первого запроса (
SELECTили DML). - Все последующие запросы транзакции используют этот первоначальный снимок, игнорируя любые сторонние коммиты.
- Если транзакция пытается изменить (
UPDATE/DELETE) строку, которая была модифицирована и зафиксирована другой параллельной транзакцией после момента создания нашего снимка, база данных немедленно прерывает операцию с ошибкойserialization_failure(could not serialize access due to concurrent update).
Пример: консистентный отчёт в одной транзакции
-- Транзакция 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: расчёт с зависимыми запросами
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):
- Транзакции читают данные без блокировок, используя обычные снимки MVCC (как в
REPEATABLE READ). - В процессе чтения ядро СУБД отслеживает зависимости типа чтение-запись (rw-antidependency):
- Read-Write conflict: Транзакция T1 прочитала версию строки, а транзакция T2 позже перезаписала эту строку.
- Write-Read conflict: Транзакция T1 изменила строку, которую затем прочитала T2.
- Движок строит направленный граф конфликтов. Если в графе образуется опасный цикл (T1 → T2 → T1), ядро немедленно объявляет одну из транзакций жертвой и прерывает ее с ошибкой сериализации
serialization_failure(SQLSTATE40001).
Пример: предотвращение lost update
-- Транзакция 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 и уметь перезапускать операцию:
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). - Вам важна максимальная пропускная способность.
- Вы явно обрабатываете конфликты через оптимистический контроль версий.
// Оптимистический контроль версий: альтернатива строгой изоляции
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% запросов).
- Вы готовы реализовать логику ретрая с экспоненциальной задержкой.
Итог
- READ COMMITTED: снимок на запрос, высокая производительность, допускает non-repeatable read и phantom read. Подходит для большинства OLTP-сценариев с атомарными операциями.
- REPEATABLE READ: снимок на транзакцию, предотвращает non-repeatable read и (в PostgreSQL) phantom read. Требует обработки ошибок при обновлении «устаревших» строк.
- SERIALIZABLE: строгая серийная эквивалентность через SSI, предотвращает все аномалии, но может часто откатывать транзакции при конкуренции. Требует паттерна ретрая.
- В Go: всегда обрабатывайте ошибки сериализации, используйте
deferдля отката, явно передавайте*sql.Tx, избегайте чтения-вычисления-записи без блокировок. - Производительность: каждый уровень имеет цену в памяти, диске и CPU. Профилируйте с
pprofиEXPLAIN ANALYZE, а не выбирайте уровень «на глаз».
Понимание уровней изоляции — это только половина картины. Чтобы писать надёжный код, нужно также знать, какие именно аномалии они предотвращают и как они проявляются в реальных сценариях.
В следующей статье мы детально разберём конкретные аномалии параллельного выполнения: 4. Dirty Read, Phantom Read, Lost Update, и покажем, как воспроизвести их в тестах и защитить своё приложение.