6.2Транзакции, ACID, уровни изоляции
Посмотри на транзакцию как на способ сохранить бизнес-инвариант при параллельной работе запросов. Разбери аномалии через порядок операций, сопоставь их с уровнями изоляции PostgreSQL и пойми, зачем приложению обрабатывать конфликты и дедлоки.
Транзакция - это не «BEGIN/COMMIT»
Транзакцией называют единицу работы, которая переводит базу из одного согласованного состояния в другое и для внешнего мира выглядит атомарной и одиночной. Вся соль в двух словах: «атомарной» (либо вся, либо никакой) и «одиночной» (как будто других транзакций в этот момент не было). Остальное относится к механике: как СУБД поддерживает эту иллюзию, хотя на деле транзакции идут не по одной.
Выполнять транзакции строго по одной было бы надёжно, но чудовищно медленно: параллельно работают сотни соединений. Поэтому база чередует их операции и создаёт иллюзию, будто каждая работала в одиночестве. Иллюзия неполная, и чем она полнее, тем дороже обходится. Уровни изоляции как раз и отмеряют, насколько тщательно база её поддерживает.
ACID на сквозном примере: перевод 100 рублей
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- списали у Ани
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- зачислили Боре
INSERT INTO transfers(from_id, to_id, amount) VALUES (1, 2, 100);
COMMIT;
«Спорнее всего в ACID буква C. Atomicity, Isolation и Durability обеспечивают конкретные механизмы
СУБД. А Consistency описывает прикладной инвариант: “сумма денег в системе не меняется при
переводе”. СУБД проверит только то, что ей объявили: типы, NOT NULL,
CHECK, FK. Если разработчик написал только первый
UPDATE, транзакция будет атомарной, изолированной и долговечной - и при этом
оставит базу в бизнес-несогласованном состоянии.» Дальше можно добавить, что в теореме CAP
буква C означает совсем другое (линеаризуемость), и путать их не надо.
Аномалии конкурентного доступа
Аномалией называют результат, который нельзя получить ни при каком последовательном выполнении тех же транзакций. Проверка простая: мысленно прогони две транзакции строго по очереди, сначала T1, потом T2, и наоборот; если фактический результат не совпал ни с одним из вариантов, это аномалия. Ниже пять классических аномалий, каждая на временной диаграмме двух транзакций. Читай диаграммы слева направо: по горизонтали время, две строки отданы параллельным транзакциям T1 и T2, прямоугольник обозначает операцию, которую та или другая выполняет в этот момент.
Уровни изоляции по стандарту SQL
| Уровень | Грязное чтение | Неповторяющееся чтение | Фантомы | Потерянное обновление | Write skew |
|---|---|---|---|---|---|
| READ UNCOMMITTED | возможно | возможно | возможно | возможно | возможно |
| READ COMMITTED | нет | возможно | возможно | возможно | возможно |
| REPEATABLE READ | нет | нет | по стандарту возможно, в PG нет | в PG нет (ошибка 40001) | возможно |
| SERIALIZABLE | нет | нет | нет | нет | нет |
PostgreSQL реализует изоляцию через MVCC-снапшоты, а не через блокировки чтения, поэтому таблица стандарта на него ложится иначе:
- READ UNCOMMITTED физически = READ COMMITTED. Уровень принимается синтаксически,
но грязное чтение невозможно в принципе: незакоммиченная версия строки не видна никому,
потому что её
xminпринадлежит активной транзакции. - READ COMMITTED берёт новый снапшот на каждый оператор. Отсюда неповторяющееся чтение и фантомы между операторами одной транзакции.
- На REPEATABLE READ снапшот один на всю транзакцию, его берут при первом операторе.
Отсюда бесплатно пропадают неповторяющееся чтение и даже фантомы, которые стандарт
разрешает: уровень выходит строже стандарта. Взамен появляется
could not serialize access due to concurrent update, если ты пытаешься обновить строку, изменённую после твоего снапшота. - SERIALIZABLE реализован через SSI (Serializable Snapshot Isolation): движок отслеживает зависимости чтение-запись между транзакциями и, обнаружив «опасную структуру» из двух подряд идущих rw-конфликтов, откатывает одну из транзакций с ошибкой сериализации. Блокировок чтения по-прежнему нет - платишь не ожиданием, а вероятностью отката.
На Read Committed UPDATE ... WHERE ведёт себя хитро. Если строку поменяла
другая транзакция и успела закоммититься, пока мы ждали блокировку, PostgreSQL
не откатывает нас, а перечитывает строку заново (EvalPlanQual) и повторно проверяет
на ней условие WHERE. То есть один оператор может работать с «более свежими»
данными, чем его собственный снапшот. Следствие: UPDATE t SET x = x + 1 на Read
Committed безопасен и не теряет обновления, а вот «прочитал SELECTом,
посчитал в Go, записал UPDATEом» - теряет. Здесь и проходит граница, за которой нужны
FOR UPDATE или version-колонка.
| СУБД | Уровень по умолчанию | Чем реализовано |
|---|---|---|
| PostgreSQL | READ COMMITTED | снапшот на каждый оператор, MVCC в heap |
| MySQL / InnoDB | REPEATABLE READ | consistent read из undo-лога + gap locks и next-key locks против фантомов |
| Oracle | READ COMMITTED | undo-сегменты; SERIALIZABLE есть, но это snapshot isolation, а не настоящая сериализуемость |
| SQL Server | READ COMMITTED (блокировочный) | по умолчанию блокировки, опционально READ_COMMITTED_SNAPSHOT |
Ошибка сериализации и обязательный ретрай
Как только ты вышел с Read Committed на Repeatable Read или Serializable, приложение получает
новый класс ошибок: SQLSTATE 40001 serialization_failure. Туда же обычно добавляют 40P01 deadlock_detected: дедлок бывает и на Read Committed, но лечится тем же повтором. Это не баги и не сбои. Так PostgreSQL штатно
сообщает: «я не смог выполнить твою транзакцию так, будто она была одна; повтори её».
Транзакция откачена целиком, база согласована, повтор корректен.
// Ретраим транзакцию с экспоненциальной паузой и джиттером.
// Обязателен для Repeatable Read и Serializable.
func WithRetry(ctx context.Context, db *pgxpool.Pool, fn func(pgx.Tx) error) error {
const maxAttempts = 5
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
tx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return err
}
err = fn(tx)
if err == nil {
if err = tx.Commit(ctx); err == nil {
return nil // получилось
}
}
_ = tx.Rollback(ctx) // после Commit это no-op, вызывать безопасно
if !isRetryable(err) {
return err // бизнес-ошибка или нарушение констрейнта: повтор не поможет
}
lastErr = err
// Пауза со случайным разбросом: без джиттера конкуренты
// синхронно повторят конфликт и снова столкнутся.
backoff := time.Duration(1<<attempt) * 10 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff)))
select {
case <-time.After(backoff + jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("транзакция не удалась за %d попыток: %w", maxAttempts, lastErr)
}
func isRetryable(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
switch pgErr.Code {
case "40001": // serialization_failure
return true
case "40P01": // deadlock_detected
return true
}
return false
}
1) Повторять надо всю транзакцию с самого начала, а не последний запрос: снапшот уже
мёртв. 2) Функция fn обязана быть идемпотентной по побочным эффектам.
Внутри неё нельзя отправлять письма, списывать деньги во внешнем API и писать в Kafka: при
повторе это произойдёт дважды. Побочные эффекты запускай только после успешного коммита (или через
outbox). 3) Джиттер обязателен: без него N конкурентов синхронно повторят конфликт,
снова его получат, и выйдет живой лок на ровном месте.
Явные блокировки: FOR UPDATE, FOR SHARE, SKIP LOCKED, NOWAIT
Блокировка (lock, «замок») работает как пометка «этот объект сейчас занят мной»: транзакция вешает её на строку или на таблицу и держит до своего конца. Другая транзакция, которой этот объект нужен в несовместимом режиме, встаёт и ждёт, пока первая не завершится. Блокировка нужна не для безопасности данных, а для очереди: параллельный доступ становится последовательным там, где параллельность даёт неверный результат.
SELECT ... FOR UPDATE берёт на прочитанные строки эксклюзивную блокировку
строки до конца транзакции: другие транзакции не смогут ни обновить, ни удалить, ни взять
на них FOR UPDATE. Так паттерн «прочитал - посчитал - записал» становится
безопасным: между чтением и записью строку никто не тронет. Обычные
SELECT при этом не блокируются - читатели в PostgreSQL никогда не ждут
писателей.
| Форма | Что блокирует | Типичный сценарий |
|---|---|---|
FOR UPDATE | полная эксклюзивная блокировка строки | read-modify-write: списание баланса, бронирование |
FOR NO KEY UPDATE | слабее: разрешает FOR KEY SHARE | то, что берётся неявно при обычном UPDATE неключевых колонок |
FOR SHARE | разделяемая: читать можно всем, менять нельзя | «эта строка не должна исчезнуть, пока я работаю» |
FOR KEY SHARE | слабейшая: запрещает только удаление и смену ключа | берётся неявно при проверке FK |
... NOWAIT | не ждать, сразу ошибка 55P03 | интерактивная операция: лучше сказать «занято», чем висеть |
... SKIP LOCKED | молча пропустить занятые строки | очередь на таблице: каждый воркер берёт свою пачку |
-- Очередь: воркер атомарно забирает пачку задач и помечает их своими
UPDATE tasks
SET status = 'processing', locked_by = $1, locked_at = now()
WHERE id IN (
SELECT id FROM tasks
WHERE status = 'new' AND run_after <= now()
ORDER BY priority DESC, id
LIMIT 10
FOR UPDATE SKIP LOCKED -- занятые строки пропускаем
)
RETURNING id, payload;
-- Read-modify-write под блокировкой: списание с проверкой остатка
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- ждём, если строка занята
-- проверили в приложении, что хватает
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- Не висеть на занятой строке в интерактивном сценарии
SELECT * FROM documents WHERE id = 42 FOR UPDATE NOWAIT; -- ошибка 55P03, если занято
Оптимистичные и пессимистичные блокировки
Пессимистичная - «конфликт будет, займу заранее». SELECT FOR UPDATE.
BEGIN;
SELECT qty FROM stock
WHERE id = 7 FOR UPDATE; -- конкурент ждёт
UPDATE stock SET qty = qty - 1
WHERE id = 7;
COMMIT;
Плюсы: операция гарантированно пройдёт с первого раза, логика простая. Минусы: конкуренты стоят в очереди, растёт время удержания блокировки, возможны дедлоки, плохо работает через «долгий» бизнес-процесс с участием пользователя.
Оптимистичная - «конфликт маловероятен, проверю при записи». Version-колонка.
-- прочитали: qty = 100, version = 7
UPDATE stock
SET qty = 99, version = version + 1
WHERE id = 7 AND version = 7;
-- RowsAffected = 0 → кто-то опередил,
-- перечитать и повторить бизнес-логику
Плюсы: никто никого не ждёт, работает через долгий диалог с пользователем и через stateless-сервисы. Минусы: при высокой конкуренции много повторов, и повтор надо уметь делать корректно.
Если конкуренция за одну и ту же строку высокая и короткая (списание с популярного счёта,
остаток товара на распродаже), бери пессимистичную: оптимистичная выродится в бесконечные ретраи.
Если конкуренция редкая или транзакция длинная (пользователь открыл форму редактирования и
думает 5 минут), бери оптимистичную: держать блокировку строки на время раздумий человека нельзя
никогда. Часто лучше обоих третий вариант: переписать в атомарный SQL, например
UPDATE stock SET qty = qty - 1 WHERE id = 7 AND qty >= 1. Тогда нет ни блокировок,
ни версий, а проверка RowsAffected == 0 заменяет всю логику конфликта.
Блокировки в PostgreSQL: уровни и конфликты
Блокировки в PG бывают трёх сортов. Табличные (8 режимов) берутся автоматически под
каждую команду. Строчные (4 режима) хранятся не в отдельной таблице замков в памяти,
а прямо в служебном заголовке самой строки на диске и в multixact, поэтому память под них не кончается, сколько строк ни заблокируй. Бесплатными они от этого не становятся: каждая блокировка пишется в заголовок строки, а значит, грязнит страницу и попадает в WAL. Замки
advisory пользователь вешает сам по произвольному числовому ключу, и ни к строке,
ни к таблице они не привязаны. Пригодятся, когда синхронизировать нужно не данные,
а действие («крон-задачу выполняет только один инстанс»).
| Режим таблицы | Кто берёт | С чем конфликтует |
|---|---|---|
ACCESS SHARE | SELECT | только с ACCESS EXCLUSIVE |
ROW SHARE | SELECT FOR UPDATE/SHARE | с EXCLUSIVE и выше |
ROW EXCLUSIVE | INSERT, UPDATE, DELETE | с SHARE и выше |
SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY | сам с собой и выше - DML не мешает |
SHARE | CREATE INDEX (без CONCURRENTLY) | с любой записью |
SHARE ROW EXCLUSIVE | CREATE TRIGGER, часть ALTER | с записью и сам с собой |
EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | со всем, кроме ACCESS SHARE |
ACCESS EXCLUSIVE | DROP, TRUNCATE, большинство ALTER TABLE, VACUUM FULL | со всем, включая SELECT |
ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ... хочет
ACCESS EXCLUSIVE. Он встаёт в очередь за уже идущим долгим
SELECT. Но очередь блокировок в PostgreSQL честная (FIFO): все новые
SELECT, пришедшие после ALTER, встают за ним. Итог: один аналитический
запрос на 10 минут + одна безобидная миграция = таблица недоступна на 10 минут, приложение
падает по таймаутам. Лечится это через SET lock_timeout = '3s' перед DDL и повтор в цикле:
миграция сдастся, а не заблокирует всех.
-- Кто кого ждёт прямо сейчас (PG 10+): самый полезный запрос при инциденте
SELECT a.pid, a.state, now() - a.xact_start AS tx_age,
a.wait_event_type, a.wait_event,
pg_blocking_pids(a.pid) AS "заблокирован кем",
left(a.query, 80) AS query
FROM pg_stat_activity a
WHERE a.backend_type = 'client backend'
AND (a.state <> 'idle' OR a.state IS NULL)
ORDER BY tx_age DESC NULLS LAST;
-- Детали блокировок: что именно и в каком режиме удерживается
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted OR l.relation IS NOT NULL
ORDER BY l.granted, l.pid;
-- Аварийные меры
SELECT pg_cancel_backend(pid); -- отменить запрос: соединение живо, транзакция ждёт ROLLBACK
SELECT pg_terminate_backend(pid); -- убить соединение целиком
Deadlock
deadlock_timeout: так процессор не тратится на граф при каждом ожидании.- Единый порядок захвата. Всегда обновляй строки в одном и том же порядке, например
по возрастанию
id. В переводе денег это буквальноORDER BY idперед блокировкой обоих счетов; без него переводы A→B и B→A под нагрузкой гарантированно поймают дедлок. - Короткие транзакции. Чем меньше окно удержания блокировки, тем меньше шанс пересечения.
- Никаких сетевых вызовов внутри транзакции. HTTP-запрос на 2 секунды внутри
BEGINдержит все блокировки те же 2 секунды. - Блокировать всё нужное сразу, одним
SELECT ... FOR UPDATEсIN (...) ORDER BY id, а не по одной строке за раз. - И всё равно ретраить 40P01 - полностью исключить дедлоки в сложной системе нельзя.
-- Перевод без дедлока: фиксированный порядок захвата строк
BEGIN;
SELECT id, balance FROM accounts
WHERE id IN (1, 2)
ORDER BY id -- всегда по возрастанию, куда бы ни шёл перевод
FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Чем опасны долгие транзакции
Открытая транзакция,
даже если она ничего не делает и висит в состоянии idle in transaction, держит
свой xmin, а значит:
- Блокирует VACUUM. Autovacuum не может очистить мёртвые версии строк новее самого старого активного снапшота, причём во всей базе, а не только в таблицах этой транзакции. Таблицы пухнут, индексы пухнут, планы деградируют.
- Держит блокировки, взятые в начале, до самого конца, в том числе табличные, из-за которых миграции встают в очередь.
- Тормозит реплики. При
hot_standby_feedback = onдолгий запрос на реплике удерживаетxminна мастере и блокирует VACUUM уже там. При выключенном фидбеке наоборот: запрос на реплике будет отменён сcanceling statement due to conflict with recovery. - Приближает transaction ID wraparound. Самая старая транзакция задаёт горизонт заморозки; если она живёт сутками, база может дойти до аварийного режима «остановить приём новых транзакций».
- Растёт вероятность дедлоков и конфликтов сериализации, ведь окно пересечения шире.
-- Долгие транзакции и «idle in transaction»: их смотрят первым делом
SELECT pid, state, now() - xact_start AS tx_age, now() - state_change AS idle_age,
left(query, 100)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 20;
idle_in_transaction_session_timeout убивает соединения, забывшие закоммитить
(ставь всегда, хоть 60 секунд: спасает от «разработчик открыл BEGIN в psql и ушёл на обед»).
statement_timeout ограничивает один запрос, lock_timeout - ожидание
блокировки (для миграций обязателен), а
transaction_timeout (PG 17) - всю транзакцию целиком.
В Go-приложении к этому добавляется context.WithTimeout на каждый запрос,
но помни: отмена контекста не всегда мгновенно останавливает работу на сервере.
Вопросы
11Перевод 100 рублей со счёта 1 на счёт 2 состоит из трёх операций: списание, зачисление, запись в журнал переводов.
A - Atomicity (атомарность)
Применяются либо все три операции, либо ни одной. Без атомарности сценарий «сервер упал
между UPDATEами» оставляет деньги списанными, но не зачисленными - они исчезают
из системы. Механизм: WAL плюс правило, что изменения становятся видимыми только после
записи о коммите. Откат в PostgreSQL почти бесплатен: новые версии строк просто никто
никогда не увидит, потому что их xmin принадлежит откаченной транзакции.
C - Consistency (согласованность)
Транзакция переводит базу из одного корректного состояния в другое: CHECK (balance
>= 0) не нарушен, FK на счета целы, сумма денег в системе не изменилась. Без неё
баланс уходит в минус. Но есть оговорка: СУБД проверяет только объявленные ей
инварианты. Инвариант «сумма денег постоянна» ей неизвестен, и если разработчик забыл второй
UPDATE, транзакция будет атомарной, изолированной, долговечной и при этом
бизнес-несогласованной. Поэтому C часто называют «половина ответственности приложения».
I - Isolation (изоляция)
Параллельный отчёт «сколько всего денег в банке» не должен увидеть момент, когда со счёта 1 уже списали, а на счёт 2 ещё не зачислили, иначе он покажет на 100 рублей меньше. Без изоляции получаем весь набор аномалий: грязное чтение, потерянные обновления, фантомы. В PostgreSQL для чтения работают MVCC-снапшоты, для записи блокировки строк и SSI. Изоляцию настраивают чаще других букв: у неё четыре уровня, и это сознательный размен строгости на пропускную способность. Своя ручка есть и у D - о ней ниже.
D - Durability (долговечность)
После того как клиент получил «перевод выполнен», выключение питания не должно его отменить.
Для этого перед подтверждением коммита запись WAL сбрасывается на диск через
fsync. Отсюда практические ручки: synchronous_commit = off даёт
кратный прирост скорости записи ценой потери транзакций за последние сотни миллисекунд при аварии (по умолчанию до ~600 мс)
(база при этом остаётся согласованной - теряются целые транзакции, а не половинки), а
synchronous_standby_names расширяет durability до «переживёт потерю всего
сервера».
«C в ACID и C в CAP надо различать: в ACID это “инварианты соблюдены”, в CAP - “все узлы видят одинаковые данные” (линеаризуемость). Совпадение буквы историческое, смысл разный.» И вторая добивка: «Durability не бинарна, это шкала от “записали в page cache ОС” до “подтвердили на трёх узлах в разных ЦОД”, и каждая ступень стоит задержки на коммите.»
1. Грязное чтение (dirty read)
T1 списала 100 рублей и ещё не закоммитила. T2 читает баланс и видит списание. T1 делает
ROLLBACK. T2 приняла решение по данным, которых никогда не существовало.
Запрещено начиная с Read Committed; в PostgreSQL невозможно вообще ни на каком уровне.
2. Неповторяющееся чтение (non-repeatable read)
T1 читает баланс - 500. T2 обновляет его на 400 и коммитит. T1 читает тот же баланс ещё раз - 400. Один и тот же запрос в одной транзакции дал разные ответы. Ломает любой отчёт из нескольких запросов: итоговая строка не сходится с детализацией. Запрещено с Repeatable Read.
3. Фантомное чтение (phantom read)
T1 считает count(*) WHERE dept = 5 - 10. T2 вставляет новую строку с
dept = 5 и коммитит. T1 повторяет запрос - 11. Отличие от предыдущей аномалии:
изменилась не строка, а множество строк, попадающих под предикат. По стандарту
разрешено на Repeatable Read, но в PostgreSQL на Repeatable Read фантомов нет -
снапшот берётся один на транзакцию. В MySQL/InnoDB для этого используются gap locks.
4. Потерянное обновление (lost update)
T1 читает qty = 100, T2 читает qty = 100, T1 пишет 150 и коммитит,
T2 пишет 130 и коммитит. Прибавка T1 бесследно исчезла, при этом обе транзакции
завершились успешно и ошибок не было. Это самая частая аномалия в реальном коде, потому
что паттерн «прочитал в приложении → посчитал → записал» пишут все. Лечится не уровнем
изоляции, а одним из трёх: атомарным UPDATE x = x + n, SELECT FOR
UPDATE или version-колонкой.
5. Аномалия записи (write skew)
Инвариант: на дежурстве должен остаться хотя бы один врач; сейчас дежурят двое. T1 читает
«дежурных 2», решает, что можно уйти, и снимает с дежурства Аню. T2 параллельно читает
«дежурных 2» и снимает Борю. Обе транзакции изменили разные строки, поэтому конфликта
записи нет и обе коммитятся. Дежурных ноль - инвариант нарушен. Snapshot isolation
(Repeatable Read) это не ловит принципиально; нужен SERIALIZABLE с SSI, либо
явная блокировка того, что читали, либо материализация инварианта в одну строку-счётчик.
Частный случай неповторяющегося чтения на нескольких строках: T1 читает баланс счёта A
(500), T2 переводит 100 с A на B и коммитит, T1 читает баланс B и видит его уже с
переводом. Сумма по двум чтениям не сходится, хотя каждое чтение по отдельности корректно.
Ради этого в PostgreSQL отчёты и гоняют на REPEATABLE READ:
один снапшот на всю транзакцию гарантирует «согласованный срез базы».
| Уровень | Dirty read | Non-repeatable read | Phantom read | Lost update | Write skew |
|---|---|---|---|---|---|
| READ UNCOMMITTED | да | да | да | да | да |
| READ COMMITTED | нет | да | да | да | да |
| REPEATABLE READ | нет | нет | по стандарту да, в PG нет | в PG нет (ошибка 40001) | да |
| SERIALIZABLE | нет | нет | нет | нет | нет |
В стандарте классическая таблица содержит только первые три колонки: потерянное обновление и write skew в ANSI SQL-92 вообще не упомянуты, их добавили позже в статье «A Critique of ANSI SQL Isolation Levels» (Berenson et al., 1995). Там же появились термины snapshot isolation и write skew, поэтому «уровень стандарта» и «уровень PostgreSQL» не одно и то же.
Как это выглядит в коде
-- на транзакцию
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- или уже внутри, до первого запроса
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- посмотреть текущий
SHOW transaction_isolation;
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: true, // read-only на Serializable дешевле: SSI почти не отслеживает такие транзакции
})
Дефолтного Read Committed хватает для 95 % OLTP-операций, если добавить явные FOR UPDATE и
атомарные UPDATE там, где есть read-modify-write. Repeatable Read берут для
отчётов и выгрузок, которым нужен согласованный срез базы из нескольких запросов.
Serializable нужен операциям со сложным инвариантом через несколько таблиц (бронирование,
лимиты, расписание), и только вместе с готовым механизмом ретрая. Уровень задают
на транзакцию, а не глобально: глобальный Serializable почти всегда оказывается преждевременной
пессимизацией.
Почему READ UNCOMMITTED = READ COMMITTED
Грязное чтение возможно только там, где читатель может увидеть данные, изменённые
незавершённой транзакцией. В PostgreSQL каждая версия строки помечена xmin,
номером создавшей её транзакции, и правило видимости требует, чтобы эта транзакция была
закоммичена. Незакоммиченная версия невидима технически, показать её просто нечем.
Поэтому SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED принимается без ошибки
(для совместимости со стандартом), но ведёт себя как Read Committed. Уровней у PostgreSQL
фактически три.
Почему на REPEATABLE READ нет фантомов
RC и RR в PostgreSQL различаются моментом, когда берётся снапшот. На Read Committed снапшот
берётся заново на каждый оператор: следующий SELECT увидит всё, что
закоммитили соседи. На Repeatable Read снапшот берётся один раз, при первом операторе
транзакции, и живёт до конца. У строки, вставленной и закоммиченной после этого момента,
xmin больше границы снапшота, и она невидима, сколько раз запрос ни
повторяй. Фантом не может появиться просто потому, что база для этой транзакции
«застыла во времени». Стандарт фантомы на RR разрешает, PostgreSQL их запрещает:
реализация здесь строже спецификации.
Что взамен появляется на RR
Запись. Если транзакция на Repeatable Read пытается обновить строку, изменённую и
закоммиченную другой транзакцией после её снапшота, PostgreSQL не может корректно применить
изменение (её снапшот описывает устаревший мир) и откатывает транзакцию с
ERROR: could not serialize access due to concurrent update, SQLSTATE
40001. На Read Committed в этой же ситуации сработал бы EvalPlanQual:
перечитал бы строку и заново проверил WHERE. Поэтому переход на RR всегда
идёт в комплекте с ретраями.
SERIALIZABLE = SSI
С версии 9.1 PostgreSQL реализует настоящую сериализуемость через Serializable Snapshot
Isolation: поверх обычных снапшотов движок отслеживает предикатные блокировки и ищет в графе зависимостей опасную структуру из двух
подряд идущих rw-конфликтов. Найдя её, откатывает одну из участниц с
could not serialize access due to read/write dependencies among transactions.
Читатели по-прежнему никого не блокируют: платишь не ожиданием, а вероятностью отката и
памятью под отслеживание предикатов (max_pred_locks_per_transaction).
Многие уверены, что один оператор на Read Committed видит один согласованный срез. Это
так для SELECT, но для UPDATE/DELETE есть
EvalPlanQual: если строка изменилась, пока мы ждали блокировку, PG перечитает её
новую версию и заново применит к ней WHERE. Отсюда неинтуитивное
поведение: UPDATE t SET x = x + 1 корректен и не теряет обновлений, а
UPDATE t SET x = 5 WHERE x = 3 может внезапно не сработать, если конкурент
успел поменять x на 4.
В PostgreSQL по умолчанию стоит READ COMMITTED: снапшот на каждый оператор.
Компромисс «достаточно строго для большинства задач, минимум откатов». Глобально его
почти никогда не меняют, уровень поднимают точечно на конкретную транзакцию.
В MySQL/InnoDB по умолчанию REPEATABLE READ, и дело не в заботе о строгости.
Исторически MySQL использовал statement-based репликацию: на реплику передавался текст
запроса, который там выполнялся заново. При Read Committed порядок применения операторов
мог дать на реплике другой результат, поэтому потребовался более строгий уровень плюс
gap locks - блокировки «промежутков» между значениями индекса, которые не дают
вставить строку в диапазон, прочитанный чужой транзакцией. Gap locks и закрывают
фантомы в InnoDB, и они же порождают знаменитые дедлоки MySQL на, казалось бы, невинных
вставках.
| PostgreSQL | MySQL / InnoDB | |
|---|---|---|
| Дефолт | READ COMMITTED | REPEATABLE READ |
| Как убираются фантомы на RR | снапшот на всю транзакцию | gap locks / next-key locks |
| Цена | ошибка 40001, нужен ретрай | ожидание и дедлоки на вставках |
| Read Uncommitted | не отличим от RC | реально работает, грязное чтение возможно |
| Serializable | SSI, настоящая сериализуемость | обычные SELECT внутри транзакции превращаются в FOR SHARE |
Код, написанный под MySQL, часто неявно полагается на RR: «внутри транзакции данные не
меняются». После переезда на PostgreSQL с его Read Committed этот код внезапно ловит
неповторяющиеся чтения. И наоборот: код, написанный под PG с расчётом на явные
FOR UPDATE, в MySQL начинает ловить дедлоки из-за gap locks там, где их не
ждали. Хороший пример к тезису «уровень изоляции - часть контракта приложения, а не
деталь инфраструктуры».
SQLSTATE 40001 - не сбой, а штатный ответ «я не смог
сделать вид, что твоя транзакция была одна, повтори её». На Repeatable Read и Serializable
отсутствие ретрая - это баг, который проявится под нагрузкой.Откуда берётся
- Repeatable Read: транзакция пытается обновить строку, изменённую и закоммиченную
после её снапшота, и получает
could not serialize access due to concurrent update. - Serializable: SSI находит в графе зависимостей опасную структуру и отвечает
could not serialize access due to read/write dependencies among transactions. - Рядом стоит
40P01 deadlock_detected- причина другая, обработка та же.
Откат при этом полный: база осталась согласованной, ничего наполовину не применилось, повтор той же транзакции корректен по определению. Поэтому SSI и работает: движок обещает «либо результат будет как при последовательном выполнении, либо я откачу». За вторую половину обещания отвечает приложение.
Как это выглядит правильно
// Оборачиваем всю бизнес-операцию, а не отдельный запрос
err := WithRetry(ctx, pool, func(tx pgx.Tx) error {
var seats int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM bookings WHERE flight_id = $1`, flightID).Scan(&seats); err != nil {
return err
}
if seats >= capacity {
return ErrNoSeats // бизнес-ошибка, не ретраим
}
_, err := tx.Exec(ctx,
`INSERT INTO bookings(flight_id, user_id) VALUES ($1, $2)`, flightID, userID)
return err
})
// Письмо отправляем только здесь, после успешного коммита:
// внутри fn оно ушло бы столько раз, сколько было попыток.
Что должно быть в реализации ретрая
- Повторять всю транзакцию с нуля - старый снапшот невалиден.
- Экспоненциальная пауза с джиттером. Без разброса конкуренты синхронно повторят конфликт и снова столкнутся.
- Ограничение числа попыток и осмысленная финальная ошибка.
- Никаких внешних побочных эффектов внутри: ни писем, ни платежей, ни сообщений в Kafka. Только чистая работа с БД; всё остальное после коммита или через outbox.
- Различать ретраибельные и нет.
23505 unique_violationили23514 check_violationповторять бессмысленно, они просто повторятся. - Метрика на число ретраев. Растёт доля конфликтов, значит, горячая точка разрастается и пора менять схему (шардировать счётчик, перейти на атомарный UPDATE).
Самый частый инцидент с ретраями звучит так: «клиенту трижды списали деньги». Причина всегда одна - внутри ретраимой функции был вызов внешнего API. Правило абсолютное: внутри транзакции - только SQL. Если внешний вызов обязателен, используй transactional outbox: записываешь намерение в таблицу в той же транзакции, а отдельный воркер отправляет его наружу с ключом идемпотентности.
FOR UPDATE резервирует строки под будущую запись и
делает безопасным паттерн read-modify-write. SKIP LOCKED превращает таблицу в
рабочую очередь, NOWAIT - способ не висеть в интерактивной операции.FOR UPDATE и его слабые формы
SELECT ... FOR UPDATE берёт эксклюзивную блокировку на каждую возвращённую
строку до конца транзакции. Другие транзакции не смогут её обновить, удалить или тоже
заблокировать; обычные SELECT при этом работают свободно - в PostgreSQL
читатели никогда не ждут писателей. Есть более слабые режимы:
FOR NO KEY UPDATE (берётся неявно обычным UPDATE неключевых
колонок), FOR SHARE («пусть читают, но менять нельзя») и
FOR KEY SHARE (запрещает только удаление и смену ключа; его и берёт
проверка внешнего ключа).
SKIP LOCKED - очередь на таблице
UPDATE tasks SET status = 'processing', locked_at = now()
WHERE id IN (
SELECT id FROM tasks
WHERE status = 'new' AND run_after <= now()
ORDER BY priority DESC, id
LIMIT 10
FOR UPDATE SKIP LOCKED
)
RETURNING id, payload;
Без SKIP LOCKED десять воркеров выстроились бы в очередь за одной и той же
первой строкой - параллелизм нулевой. С ним каждый пропускает занятое и забирает свою
пачку. Это стандартный способ сделать очередь без брокера: плюс - задачи ставятся
транзакционно вместе с бизнес-данными (никаких «в базе есть, в очереди нет»), минус -
нагрузка на БД и bloat от постоянных UPDATE статуса, поэтому на десятках
тысяч задач в секунду нужен настоящий брокер.
NOWAIT
FOR UPDATE NOWAIT вместо ожидания сразу отдаёт ошибку 55P03
lock_not_available. Нужен там, где ждать хуже, чем отказать: интерактивное
редактирование документа («сейчас редактирует другой пользователь»), административная
операция, которую можно повторить. Родственная настройка - SET LOCAL lock_timeout =
'2s': ждать, но не бесконечно.
1) Блокируются только строки, реально возвращённые запросом. Отсутствующую строку
заблокировать нельзя, и от гонки «два потока вставляют одну и ту же запись»
FOR UPDATE не спасает: там нужен UNIQUE-индекс или
INSERT ... ON CONFLICT. 2) Нельзя использовать с агрегатами,
GROUP BY, DISTINCT, UNION - будет ошибка.
3) С LIMIT без ORDER BY на конкурентной нагрузке легко
поймать дедлок: два воркера возьмут пересекающиеся наборы в разном порядке. Порядок
захвата всегда должен быть детерминированным.
FOR UPDATE). Оптимистичная - «проверю при записи, что никто не опередил»
(WHERE version = N). Выбор определяется вероятностью конфликта и длиной операции.Пессимистичная
Блокировка берётся до начала работы и держится до конца транзакции. Плюс: операция гарантированно проходит с первого раза, логика линейная, ретраи не нужны. Минусы: конкуренты стоят в очереди (пропускная способность по горячей строке падает до «одна транзакция за раз»), растёт риск дедлоков, и она принципиально не работает через «длинный» бизнес-процесс - блокировку нельзя держать, пока пользователь заполняет форму.
Оптимистичная через version-колонку
CREATE TABLE documents (
id bigint PRIMARY KEY,
body text NOT NULL,
version int NOT NULL DEFAULT 1
);
-- 1) читаем без блокировки, отдаём пользователю body и version = 7
-- 2) через 5 минут он присылает изменения; пишем условно:
UPDATE documents
SET body = $1, version = version + 1
WHERE id = $2 AND version = $3; -- $3 = 7
res, err := db.ExecContext(ctx,
`UPDATE documents SET body=$1, version=version+1 WHERE id=$2 AND version=$3`,
body, id, version)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
// Ноль строк = либо документа нет, либо version изменилась.
// Это не ошибка БД, конфликт разбирает бизнес-логика:
// перечитать, показать пользователю расхождение или смержить.
return ErrConcurrentModification
}
Вместо целочисленной версии можно использовать updated_at (хуже: разрешение
времени и переводы часов) или хеш содержимого (годится для HTTP ETag / If-Match: по сути
та же оптимистичная блокировка, только вынесенная в протокол).
| Пессимистичная | Оптимистичная | |
|---|---|---|
| Когда проверяется конфликт | до работы | при записи |
| Конкурент | ждёт | работает и, возможно, проиграет |
| Стоимость конфликта | ожидание | повтор всей работы |
| Длинные операции с участием человека | непригодна | единственный вариант |
| Высокая конкуренция за одну строку | подходит | вырождается в ретраи |
| Риск дедлока | есть | нет |
Убрать read-modify-write вообще: UPDATE stock SET qty = qty - 1 WHERE id = $1 AND
qty >= 1. Один атомарный оператор, никаких блокировок в коде, никаких версий, а
RowsAffected() == 0 означает «не хватило остатка». Так же работает
INSERT ... ON CONFLICT DO UPDATE вместо «проверил, есть ли строка, потом
вставил». Хороший ответ на собесе всегда включает этот вариант: лучшая блокировка та,
которая не понадобилась.
pg_locks + pg_stat_activity, а быстрее всего - через
pg_blocking_pids().Табличные
Берутся автоматически под каждую команду. Запомни минимум:
SELECT берёт ACCESS SHARE и конфликтует только с
ACCESS EXCLUSIVE; INSERT/UPDATE/DELETE берут
ROW EXCLUSIVE и не мешают друг другу на уровне таблицы (конфликты
разрешаются построчно); VACUUM, ANALYZE и
CREATE INDEX CONCURRENTLY берут SHARE UPDATE EXCLUSIVE и не мешают
DML; ACCESS EXCLUSIVE (DROP, TRUNCATE, большинство
ALTER TABLE, VACUUM FULL) блокирует вообще всё, включая
SELECT.
Строчные
Их четыре: FOR KEY SHARE, FOR SHARE, FOR NO KEY UPDATE,
FOR UPDATE. Хранятся они не в общей памяти, а прямо в заголовке версии
кортежа (плюс структура multixact, когда строку держат несколько транзакций). Выходит,
число строчных блокировок не ограничено: можно заблокировать миллион строк и не
бояться исчерпать память блокировок. А вот табличных блокировок конечное число
(max_locks_per_transaction).
Advisory
pg_advisory_lock(key) и pg_try_advisory_xact_lock(key) вешают замок по
произвольному числу, ни с какой строкой не связанный. Удобен как распределённый мьютекс:
«только один инстанс сервиса выполняет эту фоновую задачу». Транзакционная форма
(_xact_) снимается автоматически при завершении транзакции, её
и используй; обычная требует явного unlock и легко теряется.
-- Кто кого блокирует прямо сейчас: первый запрос при инциденте
SELECT pid, now() - xact_start AS tx_age, state, wait_event_type, wait_event,
pg_blocking_pids(pid) AS blocked_by, left(query, 90)
FROM pg_stat_activity
WHERE backend_type = 'client backend' AND state <> 'idle'
ORDER BY tx_age DESC;
-- Что именно удерживается и в каком режиме
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM pg_locks l LEFT JOIN pg_class c ON c.oid = l.relation
ORDER BY l.granted, l.pid;
SELECT pg_cancel_backend(12345); -- мягко: отменить запрос
SELECT pg_terminate_backend(12345); -- жёстко: закрыть соединение
Если ALTER TABLE ждёт ACCESS EXCLUSIVE за долгим
SELECT, то все новые SELECT встают в очередь за
ALTER, а не проходят мимо. Один десятиминутный аналитический запрос плюс
одна миграция = таблица недоступна десять минут. Поэтому перед любым DDL на проде ставь
SET lock_timeout = '3s' и повторяй в цикле - миграция сдастся, а не положит
сервис.
40P01. Лечится единым порядком захвата ресурсов.Как возникает
T1 обновляет строку A и получает на неё блокировку, затем хочет строку B. T2 в это же время
обновила B и хочет A. Каждая ждёт ресурс, который держит другая, и получается цикл. Классический
источник: перевод денег «со счёта X на счёт Y» и одновременно «с Y на X»; или два процесса,
обновляющие набор строк в разном порядке (например, потому что один шёл
ORDER BY id, а второй - ORDER BY created_at).
Как разрешает PostgreSQL
Транзакция, прождавшая блокировку дольше deadlock_timeout (по умолчанию 1 с),
запускает детектор: тот строит граф «кто кого ждёт» и ищет в нём цикл. Если цикл нашёлся,
одна из транзакций (обычно та, что запустила проверку) откатывается с
ERROR: deadlock detected, SQLSTATE 40P01, а в лог пишется, какие
процессы и запросы участвовали. Остальные продолжают работу. Проверка запускается
по таймауту, а не при каждом ожидании, чтобы не жечь процессор - обычное ожидание
блокировки ещё не дедлок.
Как проектировать
- Единый порядок захвата. Главное правило: если операция трогает несколько строк, блокируй их в детерминированном порядке (по возрастанию первичного ключа), независимо от бизнес-направления операции.
- Захватывать всё сразу одним
SELECT ... WHERE id IN (...) ORDER BY id FOR UPDATE, а не последовательно по одной строке. - Короткие транзакции и никаких сетевых вызовов внутри - окно пересечения меньше.
- Осторожно с
ON CONFLICTи FK: параллельные вставки в таблицу с уникальным индексом и разным порядком значений тоже дают дедлоки. - Всё равно ретраить. Полностью исключить дедлоки в системе со сложными
транзакциями нельзя, поэтому обработка
40P01обязательна.
-- Плохо: порядок зависит от направления перевода
UPDATE accounts SET balance = balance - 100 WHERE id = $from;
UPDATE accounts SET balance = balance + 100 WHERE id = $to;
-- Хорошо: сначала захватываем оба счёта в фиксированном порядке
SELECT id FROM accounts WHERE id IN ($from, $to) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = $from;
UPDATE accounts SET balance = balance + 100 WHERE id = $to;
Дедлок разрешается автоматически за секунду и виден в логе как 40P01.
А «всё висит, но дедлока нет» означает обычную блокировку: кто-то держит транзакцию
открытой. Диагностика разная: для первого смотрят лог и порядок обновлений в коде, для
второго - pg_stat_activity и pg_blocking_pids(). На собесе эти два
случая часто путают.
xmin, а значит
блокирует VACUUM во всей базе, удерживает захваченные блокировки, тормозит реплики и
приближает transaction ID wraparound. Причём вредит даже транзакция, которая ничего не делает.Пять последствий
- Блокировка очистки. Autovacuum не имеет права удалить мёртвую версию строки, если она может быть нужна самому старому активному снапшоту. Одна транзакция, висящая часами, останавливает уборку по всей базе. Отсюда table bloat: таблица на 10 ГБ данных занимает 60 ГБ, индексы распухают, всё сканируется медленнее, кэш забит мусором.
- Удержание блокировок. Все блокировки живут до конца транзакции. Если в начале был
ALTERилиFOR UPDATE, соседи будут ждать до самогоCOMMIT- а из-за честной FIFO-очереди за ними встанут и невинныеSELECT. - Проблемы с репликами. При
hot_standby_feedback = onдолгий запрос на реплике удерживаетxminна мастере и мешает VACUUM уже там. При выключенном фидбеке запрос на реплике убьют сcanceling statement due to conflict with recovery. Выбор между двумя неприятностями, и об этом полезно сказать вслух. - Transaction ID wraparound. Счётчик транзакций 32-битный; чтобы старые данные не «стали будущими», VACUUM замораживает старые кортежи. Самая старая транзакция задаёт горизонт заморозки, и если она живёт сутками, база приближается к аварийному режиму, когда PostgreSQL перестаёт принимать новые транзакции.
- Больше конфликтов. Чем шире окно, тем выше вероятность дедлока и ошибки сериализации.
Особый случай: idle in transaction
Чаще всего транзакцию затягивает не тяжёлый запрос, а BEGIN, после которого
приложение ушло делать HTTP-запрос, писать в Kafka или просто забыло закоммитить из-за
пропущенного defer tx.Rollback(). Транзакция ничего не выполняет, но вредит
точно так же. Это и есть главный аргумент против «сетевых вызовов внутри транзакции».
-- Диагностика
SELECT pid, state, now() - xact_start AS tx_age, now() - state_change AS in_state,
left(query, 100)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 20;
-- Профилактика. SET - на сессию; на роль - ALTER ROLE … SET, на сервер - ALTER SYSTEM
SET idle_in_transaction_session_timeout = '60s';
SET statement_timeout = '30s';
SET lock_timeout = '3s';
«Транзакция должна открываться как можно позже и закрываться как можно раньше, и внутри
неё не должно быть ничего, кроме работы с БД. На уровне инфраструктуры обязательно
выставлены statement_timeout и
idle_in_transaction_session_timeout, а в мониторинге есть алерт на возраст
самой старой транзакции - на него первым делом смотрят при жалобе на распухшую базу
или на массовые таймауты.»
Что стоит запомнить
- ACID описывает свойства выполнения, но прикладной инвариант нужно выразить запросами и ограничениями.
- Уровень изоляции задаёт, какие эффекты параллельных транзакций допустимы; название уровня само по себе не объясняет конкретную аномалию.
- Сериализационный конфликт или дедлок требует повторить всю транзакцию, если операция безопасна для повтора.
Частые ошибки
- Не делай внешние вызовы и долгую работу внутри транзакции: блокировки удерживаются дольше.
- Не ретраить только последний UPDATE после serialization failure: решение транзакции нужно пересчитать целиком.
- Не полагайся на проверку баланса в приложении без защиты от конкурентного изменения.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проверь инвариант перевода
Две транзакции одновременно выполняют этот код для одного счёта с балансом 100. Объясни возможный результат при READ COMMITTED.
SELECT balance FROM accounts WHERE id = 1;
-- приложение проверило balance >= 80
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
Ответ и разбор
Обе транзакции могут прочитать 100 и обе решить, что списание допустимо. Последовательные UPDATE одной строки будут ждать друг друга, но решение о достаточности средств уже принято по старому чтению; итог способен нарушить бизнес-ограничение. Перенеси проверку в атомарный UPDATE, например UPDATE accounts SET balance = balance - 80 WHERE id = 1 AND balance >= 80 RETURNING balance, и считай отсутствие строки отказом. Для перевода двух счетов обновления всё равно должны быть в одной транзакции.
Код-ревью
2. Разбери обработку ошибки транзакции
Функция владеет транзакцией перевода. Получатель с id=99 отсутствует, но UPDATE без совпавших строк не возвращает SQL-ошибку. Найди, как код может закоммитить списание без зачисления, и исправь проверку.
func transfer(ctx context.Context, db *pgxpool.Pool, from, to int64, amount int64) error {
tx, err := db.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from); err != nil { return err }
if _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to); err != nil { return err }
return tx.Commit(ctx)
}
Ответ и разбор
Функция сама открывает и коммитит транзакцию, но считает успешным UPDATE, затронувший ноль строк. Если from существует, а to=99 отсутствует, списание коммитится без зачисления. Проверь RowsAffected() обоих UPDATE и требуй ровно одну строку; при несовпадении верни доменную ошибку до Commit, а отложенный Rollback откатит списание. Не запускай слепой повтор после ошибки Commit: исход commit может быть неизвестен, поэтому повторяемому запросу нужен idempotency key/запись операции.
Применение
3. Разложи дедлок по шагам
Транзакция A блокирует строку account 1, затем просит account 2. Транзакция B блокирует account 2, затем просит account 1. Что произойдёт и как уменьшить вероятность повтора?
Ответ и разбор
Возникает цикл ожидания, и PostgreSQL прервёт одну транзакцию с ошибкой deadlock detected. Обновляй счета в едином порядке, например по возрастанию id, чтобы обе транзакции запрашивали блокировки одинаково. Всё равно обрабатывай ошибку и повторяй всю транзакцию с ограниченным числом попыток и задержкой, если операция идемпотентна.
Применение
4. Выбери защиту инварианта
В приложении действует правило: у пользователя может быть не более одного активного бронирования на один слот. Сравни проверку в Go перед INSERT и ограничение в БД.
Ответ и разбор
Проверка только в Go подвержена гонке: два запроса одновременно не найдут бронирование и оба вставят его. Вырази правило уникальным ограничением или частичным уникальным индексом в БД, затем преобразуй конфликт в понятную доменную ошибку. Проверка перед записью может остаться для удобного ответа, но ограничение должно быть окончательной защитой.