0 / 63 (0%)

Ретрай на 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:

go
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 и транзакция Алисы как замыкание. Петля проста:

go
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:

sh
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.)

Вывод:

plaintext
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, прикладной лок, которым дедлоки предотвращают.

·Модуль 06

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Транзакции / Ретрай на 40001