12. Snapshot isolation
Введение: Изоляция через снимки состояния
Snapshot Isolation (SI) — это выдающийся механизм обеспечения согласованности, при котором каждая транзакция видит строго фиксированный моментальный снимок (виртуальный срез) базы данных на момент своего старта. Все операции чтения в рамках транзакции возвращают данные именно из этого неизменного снимка, а изменения, параллельно вносимые другими транзакциями, становятся видимыми только после их фиксации (COMMIT) и в контексте новых снимков.
В отличие от классических блокировочных моделей двухфазной блокировки (2PL), Snapshot Isolation принципиально не требует разделяющих блокировок (Shared Lock) для операций чтения SELECT. Это позволяет читателям изучать таблицы без малейших конфликтов с пишущими транзакциями, что служит краеугольным камнем колоссальной пропускной способности современных высоконагруженных OLTP-систем.
Для инженера уровня Senior/Lead понимание SI — это не просто знание уровня REPEATABLE READ. Это способность:
- Предсказывать поведение аналитических и фоновых задач, которые работают с долгоживущими снимками.
- Осознанно выбирать между
Snapshot Isolationи строгой сериализацией, избегая аномалииWrite Skew. - Проектировать Go-приложения так, чтобы не вызывать «буксировку» мёртвых версий и не блокировать
autovacuum.
flowchart TD
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["Фиксация текущего TxID"]
B --> C["Копирование списка активных TxID<br>в структуру Snapshot"]
C --> D["Выполнение SELECT"]
D --> E{"Проверка видимости строки"}
E -->|"xmin < low_limit<br>и не в active_txids"| F["Возврат данных"]
E -->|"xmin в active_txids<br>или xmax <= high_limit"| G["Пропуск версии"]
F --> H["Продолжение работы"]
G --> H
Механика видимости: Как создаётся и применяется снимок
Когда транзакция запускается, СУБД не копирует таблицы в память. Вместо этого она создаёт компактную структуру — снимок (Snapshot), содержащую границы идентификаторов транзакций.
Видимость строки определяется сравнением её системных атрибутов xmin (ID транзакции-создателя) и xmax (ID транзакции-удалителя) с параметрами снимка.
Алгоритм проверки (упрощённо для PostgreSQL/InnoDB)
- Если
xmin >= snapshot.high_limit→ строка создана после начала снимка. Невидима. - Если
xmin < snapshot.low_limitиxminотсутствует в списке активных транзакций → строка создана до снимка и уже зафиксирована. Видима. - Если
xminнаходится в списке активных транзакций → создающая транзакция ещё не завершилась. Невидима. - Если
xmax != 0иxmax <= snapshot.high_limitиxmaxзафиксирован → строка удалена после начала снимка. Невидима.
Этот алгоритм выполняется на уровне движка СУБД за несколько тактов CPU, без обращения к менеджеру блокировок и без дисковых операций.
Под капотом: Управление памятью и структура ProcArray
Создание снимка требует доступа к глобальному состоянию кластера. В PostgreSQL эта информация хранится в разделяемой памяти в структуре ProcArray, которая содержит массив записей о каждом активном бэкенд-процессе.
- При
BEGINпроцесс берётProcArrayLock(в режимеLW_SHARED). - Копирует список активных
xidв локальную память процесса. - Освобождает блокировку.
- Создаёт
SnapshotDataв стеке или куче процесса.
В InnoDB (MySQL) аналогом выступает глобальная структура trx_sys->rw_trx_list, защищённая мьютексом. Создание снимка требует захвата этого мьютекса, что при тысячах параллельных BEGIN может создать точку сериализации. Для снижения конкуренции современные версии MySQL используют RCU-подобные оптимизации и атомарные счётчики.
Аномалия Write Skew: Граница изоляции снимков
Snapshot Isolation предотвращает Dirty Read, Non-Repeatable Read и Phantom Read, но не гарантирует сериализуемость. Классическая проблема — Write Skew.
Сценарий
Представьте строгое медицинское правило: «В больнице на дежурстве должен постоянно оставаться минимум 1 врач». В таблице doctors дежурят два врача: Alice и Bob. У обоих статус on_call = true.
-- Транзакция 1: Alice уходит с дежурства
BEGIN; -- уровень REPEATABLE READ (Snapshot Isolation)
SELECT count(*) FROM doctors WHERE on_call = true; -- вернёт 2
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- Транзакция 2: Bob уходит с дежурства (параллельно)
BEGIN; -- уровень REPEATABLE READ
SELECT count(*) FROM doctors WHERE on_call = true; -- вернёт 2 (снимок тот же)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
Обе транзакции увидели снимок, где врачей двое. Обе приняли решение, что могут уйти. После коммита в базе 0 врачей. Правило нарушено. Snapshot Isolation не видит конфликта, потому что транзакции изменяли разные строки и не пересекались по блокировкам.
Решение: использовать SERIALIZABLE (с SSI или предикатными блокировками) или явные SELECT ... FOR UPDATE на уровне приложения.
Механическая симпатия: Цена согласованности для железа
Изоляция через снимки напрямую влияет на утилизацию процессора, памяти и дисковой подсистемы.
Кэш-линии CPU и локальность данных
При сканировании таблицы в рамках снимка СУБД последовательно читает страницы, но каждая строка требует проверки видимости. Это приводит к:
- Частым проверкам
xip[]массива, который может не помещаться в кэшL1. - Промахам кэша при переключении между активной строкой и её «мертвой» версией, лежащей на другой странице.
- Нагрузке на ветвление (
branch prediction): результат проверки видимости непредсказуем при высокой фрагментации, что вызывает сброс конвейера (pipeline flush).
Влияние на пул соединений в Go
Когда горутина выполняет длительный SELECT в рамках REPEATABLE READ:
- Соединение остаётся активным (
state = activeвpg_stat_activity). - Память под структуру снимка удерживается в процессе СУБД.
- Если в Go используется
context.WithTimeout, но запрос блокируется на диске, соединение не возвращается в пул. - При достижении
SetMaxOpenConnsновые запросы будут ждать в очередиdb.connRequests, увеличиваяp99латентность сервиса.
Дисковые операции и VACUUM
Чем дольше живёт снимок, тем медленнее работает autovacuum. Задержка очистки приводит к тому, что Index Only Scan вынужден обращаться к куче для проверки Visibility Map, которая не обновлена. Это превращает быструю операцию (чтение только из индекса) в медленную (чтение индекс + куча), увеличивая случайные чтения (random I/O) и задержки await на SSD.
Практика в Go: Паттерны и антипаттерны
Паттерн 1: Изолированное чтение для отчётов
Используйте REPEATABLE READ для формирования консистентных отчётов, но строго ограничивайте время жизни транзакции.
func GenerateDailyReport(ctx context.Context, db *sql.DB, date time.Time) (Report, error) {
// Явное указание уровня изоляции
opts := &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true, // Оптимизация для планировщика СУБД
}
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return Report{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var totalSales decimal.Decimal
err = tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(amount), 0)
FROM orders
WHERE created_at >= $1 AND created_at < $2
`, date, date.Add(24*time.Hour)).Scan(&totalSales)
if err != nil {
return Report{}, fmt.Errorf("scan total: %w", err)
}
if err := tx.Commit(); err != nil {
return Report{}, fmt.Errorf("commit report: %w", err)
}
return Report{Total: totalSales}, nil
}
Паттерн 2: Разделение чтения и обработки
Никогда не держите транзакцию открытой во время тяжёлых вычислений или внешних вызовов.
// НЕВЕРНО: Транзакция висит 2 секунды пока обрабатывается JSON
func BadProcessing(ctx context.Context, db *sql.DB) error {
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
defer func() { _ = tx.Rollback() }()
var payload string
_ = tx.QueryRowContext(ctx, "SELECT data FROM queue WHERE id = $1", 1).Scan(&payload)
// Горизонт очистки заблокирован!
time.Sleep(2 * time.Second)
_, _ = tx.ExecContext(ctx, "DELETE FROM queue WHERE id = $1", 1)
return tx.Commit()
}
// ВЕРНО: Чтение -> Обработка -> Запись в новой короткой транзакции
func GoodProcessing(ctx context.Context, db *sql.DB) error {
var payload string
err := db.QueryRowContext(ctx, "SELECT data FROM queue WHERE id = $1 FOR UPDATE SKIP LOCKED", 1).Scan(&payload)
if err != nil {
return err
}
result := processPayload(payload) // Тяжёлая логика вне транзакции
_, err = db.ExecContext(ctx, "UPDATE queue SET status = 'done', result = $1 WHERE id = $2", result, 1)
return err
}
Итог
- Snapshot Isolation обеспечивает консистентное чтение без блокировок, используя сравнение
xmin/xmaxс границами снимка. - Под капотом: Снимок создаётся копированием списка активных транзакций из
ProcArrayилиtrx_sys. Проверка видимости — это целочисленные операции на CPU. - Write Skew: Главное ограничение
SI. Две транзакции могут принять конфликтующие решения на основе одинакового снимка, если изменяют разные строки. ТребуетSERIALIZABLEили явных локов. - Механическая симпатия: Долгие снимки увеличивают промахи кэша
L3, замедляютVACUUM, раздувают таблицы и блокируют освобождение ресурсов в пуле соединений. - В Go: Используйте
sql.LevelRepeatableReadтолько для коротких консистентных чтений. Разделяйте чтение и обработку, ограничивайте время жизни транзакции, используйтеSET idle_in_transaction_session_timeout.
Эта статья завершает подраздел «05. Транзакции и внутренности». Мы прошли путь от базовых гарантий ACID до механизмов управления версиями, восстановления и очистки мусора. Понимание этих процессов позволяет проектировать бэкенд, который не просто «работает», а предсказуемо ведёт себя под экстремальной нагрузкой и частичными отказами инфраструктуры.
Следующим логическим шагом после освоения транзакционной механики является переход к оптимизации доступа к данным и архитектуре конкретной СУБД. В следующей статье мы разберём, как устроена система хранения, процессы и планировщик запросов на примере самой популярной открытой базы: 1. Архитектура PostgreSQL.