1. ACID. Основы
Введение: Почему ACID — это не просто аббревиатура
Когда вы пишете сервис на Go, который обрабатывает банковские платежи, списывает товары со склада или регистрирует сессии пользователей, вы неявно заключаете контракт с базой данных. Вы надеетесь, что в случае внезапного отключения питания в серверной, сбоя в сети или одновременного наплыва тысячи клиентов баланс кошелька не станет отрицательным, а деньги не растворятся в воздухе.
Эти фундаментальные гарантии надежности были сформулированы пионерами компьютерных наук Джимом Грэем (Jim Gray) и Андреасом Рейтером (Andreas Reuter) еще на заре 1980-х годов и упакованы в акроним ACID — Atomicity (Атомарность), Consistency (Согласованность), Isolation (Изоляция) и Durability (Долговечность).
Для бэкенд-инженера уровня Senior/Lead понимание ACID — это не заучивание словарных определений для галочки. Это способность:
- Предсказывать поведение системы при падении процесса ОС, сбоях питания, сетевых партициях и конкурентных запросах.
- Осознанно балансировать на грани между строгостью гарантий и пропускной способностью (throughput) системы.
- Проектировать надежный Go-код, который гармонично сосуществует с транзакционным ядром СУБД, избегая зависших транзакций, утечек пула соединений и потерянных обновлений (Lost Updates).
В этой лекции мы разберем каждую составляющую ACID с точки зрения «механического сочувствия» к СУБД (PostgreSQL, MySQL/InnoDB) и рассмотрим их прямое отражение в коде на Go.
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
subgraph Client["Go-сервис"]
TxBegin["db.BeginTx(ctx, opts)"]
TxExec["Выполнение DML: tx.ExecContext()"]
TxDecision{"Ошибка или паника?"}:::error
TxRollback["tx.Rollback()"]
TxCommit["tx.Commit()"]
end
subgraph DBEngine["Транзакционное ядро СУБД"]
A["Atomicity:<br>Журнал WAL + Undo/Redo"]:::data
C["Consistency:<br>Ограничения целостности и инварианты"]:::process
I["Isolation:<br>MVCC / Блокировки строк и страниц"]:::process
D["Durability:<br>Сброс WAL на диск через fsync()"]
end
TxBegin --> DBEngine
TxExec --> A
TxExec --> C
TxExec --> I
TxDecision -- "Да" --> TxRollback
TxDecision -- "Нет" --> TxCommit
TxCommit --> D
TxRollback --> A
Atomicity (Атомарность): Всё или ничего
Атомарность (Atomicity) декларирует неделимость транзакции как единого квантового скачка системы: либо все операции внутри транзакции успешно применяются к базе данных, либо ни одна из них не оставляет видимого следа. Промежуточные, «полусырые» состояния никогда не должны становиться видимыми для внешнего мира.
Как это работает под капотом
Если во время выполнения сложного перевода средств из пяти SQL-запросов на третьем шаге исчезло электричество, таблица на диске не может остаться в состоянии, где с отправителя деньги списались, а получателю не зачислились.
Для гарантии атомарности СУБД использует архитектурный паттерн Write-Ahead Log (WAL) (в MySQL InnoDB это комбинация Redo-лога и Undo-лога). Подробно физику этого процесса мы разберем в лекции 8. WAL. Write Ahead Log.
Кратко базовый конвейер выглядит так:
- Запись вперед данных: Прежде чем изменить хотя бы один байт в буферном кэше страниц таблицы, СУБД сериализует операцию в журнал упреждающей записи (WAL).
- Undo-информация: Запись содержит как новые значения (Redo для наката изменений вперед), так и дельту предыдущего состояния (Undo для отката назад).
- Точка невозврата (
COMMIT): При фиксации транзакции в WAL пишется специальная запись фиксации (Commit Record). До тех пор пока эта запись не сброшена на диск, транзакция считается незавершенной. - Откат (
ROLLBACK) или аварийное восстановление: Если транзакция прервана или сервер аварийно перезапустился, фоновый процесс Crash Recovery вычитывает лог и восстанавливает исходное состояние данных до начала транзакции.
Пример на Go: корректная обработка транзакции
В стандартной библиотеке Go пакет database/sql реализует транзакции через объект *sql.Tx. Типичная ошибка новичка — забыть вызвать откат при ошибках в середине функции или при возникновении паники (panic).
Ниже представлен канонический шаблон безопасной транзакции с гарантией атомарности:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/shopspring/decimal"
)
// TransferFunds выполняет перевод средств между счетами с гарантией атомарности
func TransferFunds(ctx context.Context, db *sql.DB, from, to int64, amount decimal.Decimal) (err error) {
// Начинаем транзакцию с контекстом
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// Идиоматичный defer: защищает от утечек транзакций при panic и ранних return
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // Пробрасываем панику дальше после освобождения ресурсов
} else if err != nil {
// Откат транзакции при возникновении любой ошибки бизнес-логики
_ = tx.Rollback()
}
}()
// 1. Списание средств с проверкой доступного баланса
res, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1",
amount, from,
)
if err != nil {
return fmt.Errorf("debit failed: %w", err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
return errors.New("insufficient funds or account not found")
}
// 2. Зачисление средств на целевой счет
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, to,
)
if err != nil {
return fmt.Errorf("credit failed: %w", err)
}
// 3. Фиксация: точка невозврата (Commit)
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
return nil
}
Consistency (Согласованность): Инварианты и ограничения
Буква C в слове ACID — пожалуй, самая неоднозначная и перегруженная концепция в Computer Science (в теореме CAP она означает совершенно другое — линеаризуемость чтений!).
В контексте транзакций Согласованность означает, что транзакция переводит базу данных из одного корректного, непротиворечивого состояния в другое корректное состояние, не нарушая ни одного декларированного правила:
- Ограничений схемы данных (
NOT NULL,FOREIGN KEY,UNIQUE,CHECK). - Системных триггеров (
BEFORE/AFTER). - Бизнес-инвариантов предметной области («баланс пользователя не может быть отрицательным», «сумма пополнений равна сумме остатков»).
Разделение зон ответственности
- Согласованность на уровне СУБД:
Обеспечивается декларативными средствами реляционной модели. Если вы объявилиFOREIGN KEY, движок базы данных физически не позволит удалить запись родительской таблицы, пока на нее ссылаются дочерние строки в заказах. - Согласованность на уровне приложения (Go-сервис):
СУБД ничего не знает о специфике вашего бизнеса. Например, СУБД не знает, что пользователь не может совершать более трех переводов в минуту. Эти инварианты обязан проверять и валидировать ваш Go-код, опираясь на блокировки или уровни изоляции.
Механика проверок под капотом
При выполнении операторов INSERT и UPDATE СУБД тратит драгоценные процессорные такты на валидацию инвариантов:
- Проверка типов и
NOT NULL, валидация выраженийCHECK. - Поиск дубликатов в уникальных индексах (B-Tree, Hash), требующий блокировки узлов индексного дерева.
- Проверка внешних ключей: движок выполняет поиск по индексу родительской таблицы.
- Вызов триггеров.
Isolation (Изоляция): Иллюзия одиночного выполнения
Если бы все транзакции выполнялись в базе данных строго последовательно (в один поток), жизнь инженеров была бы безоблачной, а пропускная способность баз данных упала бы до единиц запросов в секунду.
Изоляция гарантирует, что параллельно выполняющиеся транзакции изолированы друг от друга так, будто они выполняются последовательно (Serializability), создавая для каждой транзакции иллюзию единоличного владения базой данных.
Поскольку идеальная изоляция требует массивных блокировок и драматически снижает параллелизм, стандарт SQL-92 определил 4 классических уровня изоляции, балансирующих между строгостью и производительностью.
Классические аномалии конкурентного доступа
- Dirty Read (Грязное чтение): Транзакция читает данные, измененные другой незафиксированной транзакцией. Если та выполнит
ROLLBACK, прочитанные данные окажутся миражом. - Non-Repeatable Read (Неповторяющееся чтение): Повторный запрос одной и той же строки внутри транзакции возвращает изменившиеся значения, зафиксированные параллельной транзакцией.
- Phantom Read (Фантомное чтение): Повторный запрос по диапазону
WHEREвозвращает новые строки («фантомы»), добавленные или удаленные другой зафиксированной транзакцией.
Матрица уровней изоляции стандарта SQL и их реализация
| Уровень изоляции | Dirty Read | Non-Repeatable Read | Phantom Read | Реализация в PostgreSQL | Реализация в MySQL InnoDB |
|---|---|---|---|---|---|
| Read Uncommitted | Возможна | Возможна | Возможна | Работает как Read Committed |
Чтение без блокировок и без снимков |
| Read Committed | ❌ Защищен | Возможна | Возможна | Снимок на начало каждого запроса | Снимок на начало каждого запроса (Consistent Nonlocking Read) |
| Repeatable Read | ❌ Защищен | ❌ Защищен | Возможна* | Снимок на начало транзакции + предикатные блокировки | Снимок на начало транзакции + Next-Key Locking |
| Serializable | ❌ Защищен | ❌ Защищен | ❌ Защищен | SSI (Serializable Snapshot Isolation) | Предикатные блокировки + проверка конфликтов (rw-antidependency) |
* Примечание: В PostgreSQL на уровне Repeatable Read фантомные чтения также математически невозможны благодаря реализации снимка MVCC на уровне начала всей транзакции!
Подробный разбор механики снимков и блокировок ждет вас в лекциях 3. Read Committed, Repeatable Read, Serializable и 4. Dirty Read, Phantom Read, Lost Update.
Как управлять изоляцией в Go
В Go уровень изоляции задается через структуру sql.TxOptions:
// Задаем строгий уровень Repeatable Read для финансового отчета
opts := &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true, // Подсказка для оптимизатора СУБД
}
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return err
}
defer tx.Rollback()
Durability (Долговечность): Данные переживут сбой
Долговечность гарантирует, что как только клиент получил от СУБД подтверждение успешного COMMIT, внесенные изменения становятся персистентными. Ни внезапное отключение электропитания, ни крах ядра операционной системы (Kernel Panic), ни падение процесса СУБД не приведут к потере этих данных.
Механика: Путь байта из памяти на физический диск
Когда вы вызываете Commit(), происходит сложная последовательность аппаратных событий:
- Страницы данных в оперативной памяти (в
shared buffersPostgreSQL илиbuffer poolInnoDB) помечаются как «грязные» (Dirty Pages). Они не сбрасываются на диск немедленно, так как случайная запись (Random I/O) страниц по 8–16 КБ катастрофически замедлила бы систему! - Вместо этого в компактный кольцевой буфер упреждающей записи формируется последовательная WAL-запись.
- Движок СУБД выполняет системный вызов
fsync()(илиfdatasync()) для дескриптора WAL-файла. - Ядро Linux сбрасывает кэш страниц ОС на физический контроллер диска (NVMe SSD).
- Только после того, как физический накопитель подтвердил запись на энергонезависимый носитель, СУБД возвращает приложению
COMMIT OK.
sequenceDiagram
autonumber
participant App as Go-приложение
participant Engine as СУБД (Shared Buffers)
participant WALBuf as Буфер WAL (RAM)
participant Disk as Диск (WAL файл)
App->>Engine: tx.Commit()
Engine->>WALBuf: Сериализация Commit Record
WALBuf->>Disk: Системный вызов fsync()
Note over Disk: Запись в ячейки NVMe / SSD
Disk-->>Engine: Подтверждение сброса буфера
Engine-->>App: Успешный возврат Commit
Настройка durability в зависимости от требований
В высоконагруженных системах некоторые данные (аналитические счетчики, временные токены, кликстрим) не требуют абсолютной финансовой гарантии выживания при ядерном взрыве:
-- PostgreSQL: варианты синхронности коммита
ALTER SYSTEM SET synchronous_commit = remote_apply; -- Максимальная строгость: ждем fsync на реплике
ALTER SYSTEM SET synchronous_commit = on; -- Стандарт: ждем локальный fsync WAL
ALTER SYSTEM SET synchronous_commit = off; -- Асинхронный коммит: возврат без ожидания fsync!
В коде на Go вы можете локально ослаблять долговечность для некритичных вставок:
// Для некритичных логов и метрик отключаем ожидание fsync
_, err = tx.ExecContext(ctx, "SET LOCAL synchronous_commit TO off")
_, err = tx.ExecContext(ctx, "INSERT INTO click_metrics (event, ts) VALUES ($1, $2)", "click", time.Now())
Механическая симпатия: Как ACID влияет на производительность
Понимание аппаратной подоплеки ACID позволяет бэкенд-инженеру принимать взвешенные архитектурные решения:
- Атомарность через WAL: Чем шире транзакция (тысячи строк в одном блоке), тем больше объем сгенерированного WAL, создающего нагрузку на дисковый контроллер и репликацию.
- Изоляция через блокировки или MVCC:
- Пессимистичные блокировки (
SELECT ... FOR UPDATE) вызывают межгорутинное ожидание и чреваты дедлоками. - Оптимистичный MVCC сохраняет старые версии строк («мертвые кортежи» — dead tuples). Длинные транзакции не дают процессу автоочистки
VACUUMосвобождать дисковое пространство, порождая разрастание таблиц (table bloat).
- Пессимистичные блокировки (
- Долговечность через fsync: Никогда не выполняйте одиночные
INSERTв циклах без батчинга. Каждая отдельная транзакция заставит ядро ОС вызыватьfsync.
Пример: оптимизация пакетной вставки
// ❌ Антипаттерн: 1 000 транзакций = 1 000 вызовов fsync
for _, item := range items {
_, err := db.ExecContext(ctx, "INSERT INTO telemetry (val) VALUES ($1)", item)
}
// ✅ Оптимально: 1 транзакция на весь пакет = 1 вызов fsync
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, "INSERT INTO telemetry (val) VALUES ($1)")
if err != nil {
return err
}
defer stmt.Close()
for _, item := range items {
if _, err = stmt.ExecContext(ctx, item); err != nil {
return err
}
}
return tx.Commit() // Ровно один групповой сброс WAL на диск!
ACID в распределённых системах: границы применимости
Классические гарантии ACID проектировались в предположении, что вся база данных функционирует на одном физическом сервере с общей оперативной памятью и общим дисковым контроллером.
В эпоху микросервисной архитектуры и распределенных баз данных, где логическая операция затрагивает 3–5 независимых микросервисов и хранилищ, обеспечить классический ACID невозможно без чудовищной деградации задержки и доступности.
Для распределенных сред разработаны альтернативные подходы и компромиссы:
- Saga (Сага): Последовательность локальных ACID-транзакций в каждом сервисе. В случае сбоя вызываются компенсирующие транзакции (Compensating Transactions), откатывающие предыдущие шаги в обратном порядке.
- Two-Phase Commit (2PC): Протокол двухфазной фиксации между координатором и участниками. Дает атомарность, но чрезвычайно хрупок при сетевых партициях и удерживает распределенные блокировки.
- Event Sourcing + CQRS: Разделение контуров записи и чтения с моделью согласованности в конечном счете (Eventual Consistency).
Эти фундаментальные механизмы мы детально разберем в разделах 14. Распределенные системы на Go, 8. Distributed transactions и 9. Two Phase Commit.
Итог
ACID — это надежный фундамент, на котором покоится вся индустрия высоконагруженного бэкенда. Каждая буква представляет собой конкретный физический и программный механизм:
- Atomicity (Атомарность): Реализуется через журнал WAL и undo/redo информацию; на стороне Go обязательно защищается связкой
defer tx.Rollback()иrecover(). - Consistency (Согласованность): Совместная работа декларативных ограничений СУБД (
NOT NULL,CHECK,FOREIGN KEY) и строгой валидации бизнес-инвариантов в коде приложения. - Isolation (Изоляция): Баланс между скоростью и аномалиями (
Read Committed,Repeatable Read,Serializable), реализуемый через механизмы MVCC и блокировки строк. - Durability (Долговечность): Гарантируется аппаратным сбросом WAL на диск с помощью системного вызова
fsync(); может гибко настраиваться параметромsynchronous_commit.
В следующей лекции мы детально погрузимся в механику уровней изоляции: 2. Транзакции и уровни изоляции, где исследуем, как работают снимки данных, виртуальное время транзакций и видимость версий кортежей под капотом.