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
С точки зрения теории баз данных транзакция представляет собой строгий конечный автомат, проходящий через дискретные фазы:
- Active (Активное): Начальная фаза (
BEGIN). Клиент отправляет SQL-команды (SELECT,INSERT,UPDATE), база данных модифицирует страницы в буферном пуле и генерирует записи упреждающего журнала. - Partially Committed (Частично зафиксированное): Последний SQL-запрос приложения выполнен успешно. Сервер готовится к записи маркера фиксации, но сам коммит еще не подтвержден дисковым накопителем.
- Committed (Зафиксированное): Запись фиксации физически записана на диск. Изменения становятся персистентными и видимыми другим транзакциям в соответствии с выбранным уровнем изоляции.
- Failed (Сбойное): Произошла логическая ошибка (нарушение ограничения целостности, конфликт сериализации или отмена контекста). Дальнейшее выполнение невозможно.
- Aborted (Прерванное): Сервер выполнил
ROLLBACK. Все модификации отменены, старые версии данных восстановлены.
Состояния на уровне ядра СУБД
Когда ваше приложение на Go начинает транзакцию через db.BeginTx(ctx, opts):
- Захват физического сокета: Драйвер Go извлекает свободное TCP-соединение из пула
*sql.DBи монопольно закрепляет его за объектом*sql.Tx. - Аллокация структур в памяти: На стороне СУБД бэкенд-процесс выделяет управляющую структуру транзакции в общей памяти (
shared memoryв PostgreSQL или буферном пуле InnoDB). - Фиксация горизонта видимости: Инициализируется снимок видимости данных (для MVCC) либо запрашиваются предварительные блокировки разделяемого доступа.
- Передача управления: Драйвер возвращает управление горутине.
Когда приложение инициирует фиксацию через tx.Commit():
- В буфер журнала упреждающей записи помещается
COMMIT record(запись фиксации WAL). - Вызывается системный вызов
fsync()(если параметрsynchronous_commit = on), принудительно вытесняющий кэш контроллера накопителя. - Снимаются все эксклюзивные блокировки строк и страниц, освобождая ожидающие транзакции.
- Снимок помечается закрытым, открывая возможность фоновому сборщику мусора
VACUUMвычищать устаревшие кортежи.
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:
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колонку). - Вам важна максимальная пропускная способность при умеренных требованиях к консистентности.
Пример: счётчик просмотров статьи.
// Допустимо, что два запроса увидят разное значение
_, err := tx.ExecContext(ctx,
"UPDATE articles SET views = views + 1 WHERE id = $1",
articleID,
)
Используйте REPEATABLE READ, если:
- Вы выполняете несколько зависимых запросов, и результат второго зависит от первого.
- Вам критично избежать lost update без явных блокировок.
- Вы работаете с финансовыми операциями, где важна согласованность снимка данных.
Пример: расчёт итога заказа по нескольким таблицам.
// Гарантируем, что цены товаров не изменятся между запросами
var total decimal.Decimal
err := tx.QueryRowContext(ctx,
"SELECT SUM(price * quantity) FROM order_items WHERE order_id = $1",
orderID,
).Scan(&total)
// ... используем total для дальнейших расчётов ...
Используйте SERIALIZABLE только если:
- Вы не можете допустить ни одной аномалии параллелизма.
- Конфликты транзакций редки, и вы готовы обрабатывать ошибки сериализации повтором.
- Бизнес-логика слишком сложна для ручного управления блокировками.
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: Транзакция с контекстом и таймаутом
В распределенной системе любая транзакция обязана иметь жесткий предел жизни, чтобы предотвратить накопление зависших соединений:
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):
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, когда горутина ждёт ответа от БД, она паркуется, и тред ОС может выполнять другие горутины. Но если транзакция держит соединение и блокировки в БД, это не помогает параллелизму на стороне СУБД.
Итог
- Транзакция — это строгий конечный автомат:
Active→ (Committed|Aborted). Управляйте её жизненным циклом явно черезCommit/Rollbackи защитныйdefer. - Уровни изоляции — спектр компромиссов: от максимальной скорости (
READ COMMITTED) до абсолютной консистентности (SERIALIZABLE). - Фундаментальные механизмы: Пессимистические блокировки (
Lock-Based) предотвращают конфликты через очереди ожидания; оптимистическийMVCCобеспечивает молниеносные чтения через снимок версий строк. - В коде на Go: Ограничивайте время транзакций таймаутами контекста, передавайте
*sql.Txявно и обязательно реализуйте логику повторов (retry) при ошибках сериализации. - Механическое сочувствие: Блокировки и версии строк нагружают кэш-линии L3 CPU, порождают вызовы
futexи требуют сбросовfsync(). Измеряйте и профилируйте поведение под реальной нагрузкой.
В следующей лекции мы в деталях препарируем фундаментальное трио: 3. Read Committed, Repeatable Read, Serializable и выясним, как работают снимки данных, виртуальное время и SSI под капотом.