4. Dirty Read, Phantom Read, Lost Update
Введение: Аномалии конкурентного доступа к данным
Когда десятки и сотни параллельных горутин обращаются к одним и тем же таблицам базы данных, иллюзия упорядоченного мира начинает трещать по швам. Без надежных барьеров изоляции и осознанно расставленных блокировок многопоточная среда неминуемо сталкивается с аномалиями параллелизма.
В отличие от синтаксических ошибок, которые компилятор Go отлавливает на этапе сборки, транзакционные аномалии коварны тем, что они абсолютно беззвучны. Запросы завершаются со статусом OK, транзакции успешно коммитятся, логи чисты — но на счетах клиентов испаряются деньги, отчеты за прошлый месяц не сходятся с остатками на складе, а один покупатель перезаписывает заказ другого.
Для инженера уровня Senior/Lead умение распознавать и предотвращать эти аномалии — обязательный навык. В этой статье мы разберём три классические проблемы: Dirty Read, Lost Update и Phantom Read. Мы покажем, как они возникают на уровне страниц данных и версий строк, как их воспроизвести в тестах, и какие идиоматичные паттерны в Go гарантируют безопасность.
flowchart TD
A["Параллельные транзакции"] --> B{"Какая аномалия возникает?"}
B -->|"Чтение незафиксированного"| C["Dirty Read"]
B -->|"Перезапись результатов"| D["Lost Update"]
B -->|"Изменение набора строк"| E["Phantom Read"]
C --> F["Уровень изоляции<br>READ COMMITTED+"]
D --> G["FOR UPDATE / Оптимистичный лок"]
E --> H["Gap Locks / Предикатные блокировки"]
style C fill:#7f1d1d,color:#fff,stroke:#f87171
style D fill:#f59e0b,color:#fff
style E fill:#6366f1,color:#fff
1. Dirty Read: Чтение «грязных» данных
Определение: Транзакция читает данные, которые были изменены другой транзакцией, но ещё не зафиксированы. Если вторая транзакция выполнит ROLLBACK, первая транзакция окажется с данными, которых официально никогда не существовало.
Механика возникновения
В системах, основанных на блокировках, Dirty Read возможен, если читатель не запрашивает разделяющую блокировку (Shared Lock), а писатель не ожидает её освобождения. В MVCC-системах это означало бы чтение «черновика» версии строки до того, как транзакция-автор получила commit lsn.
Как это выглядит в запросах
-- Транзакция 1
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1; -- было 1000
-- Коммит ещё не выполнен!
-- Транзакция 2 (в режиме READ UNCOMMITTED, если бы он работал)
SELECT balance FROM accounts WHERE id = 1; -- вернёт 500
-- Транзакция 1
ROLLBACK; -- баланс вернулся к 1000
-- Транзакция 2 приняла решение на основе 500, которое больше не валидно.
2. Lost Update: Потерянное обновление
Определение: Две транзакции читают одно и то же значение, независимо вычисляют новое и записывают его. Результат второй транзакции полностью перезаписывает изменения первой. Первая транзакция «не замечает», что её работа была утеряна.
Механика на уровне версий строк
Это самая коварная аномалия, потому что она не нарушает стандарт SQL для READ COMMITTED или REPEATABLE READ, если разработчик явно не просит защиту.
- Транзакция A читает строку
X(версияv1, значение100). - Транзакция B читает строку
X(версияv1, значение100). - Транзакция A вычисляет
100 + 10 = 110, создаёт версиюv2, фиксирует. - Транзакция B вычисляет
100 - 20 = 80, создаёт версиюv3, фиксирует. - Строка
Xтеперь содержит80. Обновление+10от транзакции A безвозвратно потеряно.
СУБД не видит конфликта, потому что обе транзакции работали с валидными снимками и применяли изменения к разным версиям. Это классическая проблема логики приложения, а не бага СУБД.
Идиоматичное предотвращение в Go
Существует два промышленно принятых подхода:
1. Пессимистическая блокировка (SELECT ... FOR UPDATE)
func IncrementBalance(ctx context.Context, db *sql.DB, accountID int64, delta int) error {
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 currentBalance int
// Блокировка строки на запись до конца транзакции
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", accountID,
).Scan(¤tBalance)
if err != nil {
return fmt.Errorf("select for update: %w", err)
}
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = $1 WHERE id = $2",
currentBalance + delta, accountID,
)
if err != nil {
return fmt.Errorf("update balance: %w", err)
}
return tx.Commit()
}
FOR UPDATE устанавливает эксклюзивную блокировку строки. Любая вторая транзакция, пытающаяся выполнить SELECT ... FOR UPDATE или UPDATE на эту строку, будет поставлена в очередь ядра ОС до COMMIT/ROLLBACK первой.
2. Оптимистическая блокировка (Версионирование)
Идеально для сценариев с высокой читаемостью и редкими конфликтами записи.
type Account struct {
ID int64
Balance int
Version int
}
func UpdateBalanceOptimistic(ctx context.Context, db *sql.DB, acc Account, newBalance int) error {
res, err := db.ExecContext(ctx, `
UPDATE accounts
SET balance = $1, version = version + 1
WHERE id = $2 AND version = $3
`, newBalance, acc.ID, acc.Version)
if err != nil {
return fmt.Errorf("update: %w", err)
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("optimistic lock conflict: version mismatch")
}
return nil
}
СУБД проверяет условие WHERE version = $3. Если строка уже обновлена, RowsAffected() вернёт 0. Приложение обрабатывает это как конфликт и решает: повторить транзакцию, вернуть ошибку клиенту или применить стратегию «последний победитель».
3. Phantom Read: Фантомное чтение
Определение: Транзакция дважды выполняет запрос с одним и тем же предикатом WHERE, но получает разный набор строк, потому что другая параллельная транзакция вставила или удалила строки, удовлетворяющие условию.
Механика: Почему блокировки строк не помогают
Блокировка строк (FOR UPDATE) защищает только существующие строки. Она не блокирует вставку новых строк в диапазон, который покрывает ваш WHERE.
-- Транзакция 1: подсчёт активных заказов
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT COUNT(*) FROM orders WHERE status = 'new'; -- вернёт 10
-- Транзакция 2 (параллельно)
INSERT INTO orders (status, amount) VALUES ('new', 5000);
COMMIT;
-- Транзакция 1: повторный подсчёт
SELECT COUNT(*) FROM orders WHERE status = 'new'; -- вернёт 11!
-- Строка "появилась из ниоткуда" — фантом.
Как СУБД предотвращают фантомов
| СУБД | Механизм в REPEATABLE READ | Механизм в SERIALIZABLE |
|---|---|---|
| PostgreSQL | Snapshot Isolation: новые строки имеют xmin > snapshot_xmin, поэтому невидимы. Фантомы невозможны. |
Predicate Locks: СУБД запоминает предикат запроса. Если другая транзакция вставляет строку, удовлетворяющую предикату, возникает конфликт сериализации. |
| MySQL/InnoDB | Next-Key Locking: блокировка записи + блокировка промежутка (Gap Lock) между индексами. Вставка в заблокированный диапазон ставится в очередь. | Аналогично Next-Key, но с более строгими проверками конфликтов. |
Практика в Go: Отчёты и агрегации
Если вы формируете отчёт в несколько шагов, READ COMMITTED даст несогласованные цифры. Используйте REPEATABLE READ:
func GenerateFinancialReport(ctx context.Context, db *sql.DB, date time.Time) (Report, error) {
// Гарантируем неизменность снимка на время всего отчёта
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
return Report{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var income, expenses decimal.Decimal
_ = tx.QueryRowContext(ctx, "SELECT SUM(amount) FROM transactions WHERE type='in' AND date=$1", date).Scan(&income)
_ = tx.QueryRowContext(ctx, "SELECT SUM(amount) FROM transactions WHERE type='out' AND date=$1", date).Scan(&expenses)
if err := tx.Commit(); err != nil {
return Report{}, fmt.Errorf("commit report: %w", err)
}
return Report{Income: income, Expenses: expenses}, nil
}
4. Механическая симпатия: Цена блокировок и версий на уровне железа
Понимание аномалий бесполезно без оценки их стоимости для инфраструктуры.
Кэш-линии CPU и False Sharing
При использовании FOR UPDATE СУБД часто хранит состояния блокировок в общих структурах памяти. Если два ядра CPU одновременно пытаются захватить или проверить блокировки для строк, находящихся на одной странице данных, происходит invalidation cache lines через протокол MESI. Это приводит к «шторму» когерентности шины, и ядра простаивают в ожидании обновления кэша.
Влияние на пул соединений в Go
Когда горутина выполняет SELECT ... FOR UPDATE и попадает в очередь блокировки:
- Драйвер
database/sqlблокирует вызов в тред ОС. - Планировщик Go переводит горутину в состояние
waiting. - Соединение из пула остаётся занятым. Оно не возвращается в
idleпул. - Если у вас
SetMaxOpenConns(20), и 20 горутин ждут блокировки в БД, любые новые запросы к этой БД получатsql: connection pool timeout, даже если CPU базы простаивает.
Решение: Ограничивайте время ожидания блокировки на уровне драйвера или приложения, используйте таймауты контекста:
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// ... QueryRowContext(ctx, "... FOR UPDATE")
MVCC и локальность данных
В системах с MVCC Lost Update предотвращается хранением множества версий строк. Это размазывает логически связанные данные по разным страницам в shared_buffers. При чтении «горячей» строки СУБД может загрузить в кэш L3 CPU страницу, содержащую 5 старых версий и 1 новую. Это снижает эффективность предвыборки (prefetching) и увеличивает cache miss rate при сканировании диапазонов.
5. Итог и стратегия выбора
- Dirty Read — решается автоматически переходом на
READ COMMITTED. Не требует внимания в современных СУБД. - Lost Update — самая опасная аномалия для бизнес-логики. Решается:
- Атомарными
UPDATE col = col + val(предпочтительно). SELECT ... FOR UPDATE(для сложных вычислений в Go).- Оптимистическим версионированием (
versionстолбец).
- Атомарными
- Phantom Read — критичен для отчётов и агрегаций. Решается
REPEATABLE READ(snapshot isolation) илиSERIALIZABLE(predicate locks). - Архитектурный совет: Начинайте с
READ COMMITTED. Добавляйте явные блокировки только там, где доказана гонка. ИзбегайтеSERIALIZABLEв высоконагруженных OLTP-путях, используйте его для критичных финансовых расчётов с редкими транзакциями.
Понимание того, как блокировки реализованы внутри СУБД, — следующий шаг к проектированию стабильных систем. В следующей статье мы детально разберём типы блокировок, их гранулярность и алгоритмы обнаружения взаимных блокировок: 5. Блокировки. Locking.