Ретрай на 40001
В прошлом юните SERIALIZABLE спас инвариант Brew ценой ошибки: транзакция Алисы (она и Борис — бариста смены из 05-04, однофамильцы клиентов Алисы Ивановой и Бориса Петрова, это другие люди) упала с 40001 (serialization_failure), потому что закоммитила второй и закрыла «опасную пару» зависимостей. Подсказка в ошибке гласила: The transaction might succeed if retried — «повтори, и, возможно, пройдёт». Вот тут многие совершают роковую ошибку: показывают пользователю «500 Internal Error» и идут отлаживать «случайный сбой базы».
40001 — не сбой. Это штатный ответ SERIALIZABLE: «я не смог выстроить твою транзакцию в согласованную очередь с другими, начни заново». Контракт уровня двусторонний — база даёт тебе сериализуемость, ты обязан повторять транзакции при 40001. Без ретрай-петли SERIALIZABLE использовать нельзя. Этот юнит — про саму петлю: как её написать на Go и почему повтор почти всегда проходит.
Это Go-центричный escape-hatch (raw-pgx, без sqlc): урок про управляющую логику приложения — ретрай-цикл и разбор кода ошибки сервера, — а не про SQL.
Почему повтор работает
Ключ — в том, что ретрай выполняется в новой транзакции, а значит, с новым снимком (см. 05-02). Первая попытка Алисы читала «на полу двое» по снимку, взятому до того, как ушёл Борис. К моменту повтора Борис уже закоммитил — и свежий снимок показывает «на полу один». Алиса принимает уже другое, верное решение: остаться. Конфликта больше нет, COMMIT проходит.
Это общий принцип: 40001 означает «твоё решение основано на устаревших данных». Повтор перечитывает свежие данные и либо приходит к другому исходу, либо, если конфликт был случайным совпадением во времени, просто проходит. Поэтому почти все ретраи укладываются в 1–2 попытки.
Как поймать именно 40001
Не любую ошибку стоит повторять — повторять синтаксическую ошибку или нарушение CHECK бессмысленно (результат не изменится) и опасно. Нужен ровно код 40001. В pgx серверная ошибка достаётся через errors.As до *pgconn.PgError, а её поле Code — это SQLSTATE:
func isSerializationFailure(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "40001"
}Не всякую ошибку повторяют — короткая таблица решений:
| SQLSTATE | Что значит | Ретраить? |
|---|---|---|
40001 (serialization_failure) | конфликт сериализации под SERIALIZABLE/REPEATABLE READ | да — новая транзакция, свежий снимок |
40P01 (deadlock_detected) | взаимная блокировка, жертву откатили (05-06) | да — та же петля |
23505 (unique_violation) | дубль ключа | нет — на повторе результат тот же |
23514 (check_violation) | нарушен CHECK | нет — данные не пройдут и повторно |
42601 / 42P01 … | синтаксис или нет объекта — баг запроса | нет — повтор не поможет |
Что показывает наш код
cmd/demo/main.go — это ретрай-петля withRetry и транзакция Алисы как замыкание. Петля проста:
for attempt := 1; attempt <= maxAttempts; attempt++ {
tx, _ := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
err := txFn(ctx, tx, attempt) // работа транзакции
if err == nil { err = tx.Commit(ctx) } // 40001 часто прилетает на COMMIT
if err == nil { return attempt, nil } // успех
tx.Rollback(ctx)
if isSerializationFailure(err) { continue } // ↻ ретрай: новая tx, новый снимок
return attempt, err // другая ошибка — пробрасываем
}Чтобы конфликт был детерминированным (а не зависел от гонки), демо на первой попытке синхронно вклинивает уход Бориса через отдельную транзакцию — в проде это сделал бы другой инстанс приложения в тот же миг. Дальше всё честно: первая попытка Алисы ловит 40001, петля повторяет, вторая попытка на свежем снимке решает остаться и коммитит.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-05-retry-on-40001 T=db-reset
make lecture L=05-transactions-and-mvcc/05-05-retry-on-40001(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) shift_lab: на полу 2 бариста (Алиса #1, Борис #2). Правило: на полу всегда ≥1.
2) Алиса решает, может ли уйти — транзакция SERIALIZABLE с ретраями:
(параллельно: Борис ушёл с пола и закоммитил — конфликт назревает)
попытка 1: на полу 2 (на момент чтения) → можно уйти, снимаю свой флаг
↻ транзакция упала: 40001 (serialization_failure) — повторяю на свежем снимке
попытка 2: на полу 1 → уходить нельзя (на полу ≤1), остаюсь
✓ COMMIT успешен (заняло попыток: 2)
3) Итог: на полу 1 бариста — инвариант сохранён.
Ретрай прочитал свежий снимок и принял верное решение (Алиса осталась):
#1 Алиса на полу: да
#2 Борис на полу: нетПопытка 1: Алиса видит «на полу 2», решает уйти и снимает флаг — но Борис уже закоммитил свой уход, и Postgres валит транзакцию с 40001 (здесь — прямо на UPDATE; могло бы и на COMMIT, как в 05-04). Петля повторяет. Попытка 2 берёт свежий снимок: «на полу 1» — уходить нельзя, Алиса остаётся, COMMIT проходит. Инвариант сохранён, и заметь — повтор принял содержательно другое решение, потому что увидел свежие данные.
Заборчик
Наша петля упрощена до сути. В проде к ней добавляют:
- Потолок попыток. У нас он есть (
maxAttempts) — иначе бесконечный цикл при затяжном конфликте. - Backoff с джиттером между попытками. Без паузы N ретраящихся транзакций будут синхронно лупить друг по другу — «громовое стадо».
- Переигрываемость транзакции.
txFnобязана запускаться с нуля: никаких побочных эффектов вне БД (отправленный email, списание в платёжке) внутри ретраящейся транзакции, иначе на второй попытке они случатся дважды. Сетевые операции выносят за петлю. - Тот же приём — для
40P01. Deadlock (см. 05-06) тоже транзиентен и лечится той же петлёй. - Ретрай — не замена нормальной схеме. Если транзакция падает с
40001постоянно, проблема не в петле, а в том, что слишком много транзакций дерутся за одни данные; чинить надо схему/логику, а не крутить число попыток.
Что забрать с собой
40001(serialization_failure) — штатный ответSERIALIZABLE, а не сбой; контракт уровня требует повторить транзакцию.- Повтор работает, потому что идёт в новой транзакции с новым снимком: свежие данные → другое (верное) решение. Обычно хватает 1–2 попыток.
- Ловить нужно именно код
40001: в pgx —errors.Asдо*pgconn.PgError, полеCode. Не-транзиентные ошибки повторять нельзя. - Транзакция под ретраем обязана быть идемпотентной/переигрываемой: никаких внешних побочных эффектов внутри неё.
- В петлю кладут потолок попыток и backoff; той же петлёй обычно лечат и
40P01(deadlock).
Дальше — юнит 05-06 «дедлоки и advisory-локи»: разберём 40P01 — взаимную блокировку, которую Postgres обнаруживает и разрывает сам, — и pg_advisory_lock, прикладной лок, которым дедлоки предотвращают.