open atlas
↑ К треку
Go с нуля до senior GO · 08 · 02

Транзакции пиннят соединения: уровни изоляции из Go и повтор 40001 и 40P01

Tx пиннит одно соединение от Begin до Commit — медленная работа внутри транзакции душит пул. BeginTx просит изоляцию; Postgres даёт read committed по умолчанию, снапшоты и serializable, обязывающий к циклу повторов на 40001. Дедлоки возвращают 40P01 — повтори жертву.

GO Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Чёрная пятница, 14:02. Платёжный провайдер деградирует — p99 ползёт с 300 мс до 9 секунд. Через минуту таймаутят все эндпоинты сервиса checkout, включая те, что к платежам не имеют отношения. Дашборд базы показывает 2% CPU; дежурный смотрит на здоровый Postgres, пока сервис тонет целиком. Причина — одна функция, написанная год назад: BeginTx, INSERT заказа, HTTP-вызов платёжного API, UPDATE статуса заказа, Commit. Аккуратно, на вид атомарно — и смертельно: каждая транзакция пиннит одно соединение пула на все 9 секунд задержки провайдера. MaxOpenConns равен 20, поэтому 20 идущих чекаутов держат все соединения процесса, и поиск сессии на главной странице встаёт в очередь у ворот пула. База никогда не была узким местом. Транзакция держала её в заложниках чужой аварии.

Одна транзакция — одно пришпиленное соединение

Весь урок висит на одном механическом факте: BeginTx забирает из пула одно соединение и пиннит его до Commit или Rollback. Каждый стейтмент на *sql.Tx едет по этому соединению — именно поэтому транзакция видит свои незакоммиченные записи, и именно поэтому пул беднее на единицу всю её жизнь. Арифметика из крючка следует напрямую: пул на 20 соединений, где каждая транзакция оборачивает 9 секунд HTTP, даёт жёсткий потолок около 2,2 транзакции в секунду на весь процесс, и все посторонние запросы стоят за ними в очереди. Правило, которое это предотвращает: между Begin и Commit — только операторы базы и дешёвый CPU; никаких HTTP-вызовов, публикаций в Kafka, файлового I/O. Если процессу нужны внешние вызовы плюс атомарность — это машина состояний: закоммитить строку pending, сделать вызов, затем вторая короткая транзакция для финализации.

Каноническая форма:

func transfer(ctx context.Context, db *sql.DB, from, to int64, amt int64) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback() // после успешного Commit вернёт sql.ErrTxDone — сознательный no-op

	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amt, from); err != nil {
		return err // любой ранний return: defer откатит и освободит пришпиленное соединение
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amt, to); err != nil {
		return err
	}
	return tx.Commit()
}

defer tx.Rollback() сразу после BeginTx — паттерн, который стоит усвоить навсегда. Rollback после успешного Commit — не баг: Tx уже разрешён, поэтому вызов возвращает sql.ErrTxDone и не делает ничего. Эта идемпотентность и есть замысел: одна отложенная строка покрывает каждый ранний return и каждую панику между Begin и Commit, а счастливый путь не платит за неё ничем. Альтернатива — флаг committed или ручной откат в каждой ветке ошибки — это ровно тот код, который забывает одну ветку и утекает пришпиленное соединение плюс открытую серверную транзакцию с её локами.

Викторина

Функция делает defer tx.Rollback() сразу после BeginTx, а в конце успешно выполняет tx.Commit(). Что сделает отложенный Rollback?

Изоляция: что вы просите и что даёт Postgres

Прежде чем выбрать уровень изоляции, спроси себя: от какой именно аномалии конкурентного доступа ты защищаешься? Ответ подскажет нужный уровень — и обязательство по повтору, которое с ним придёт. database/sql позволяет запросить уровень изоляции переносимо; драйвер транслирует или отказывает:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelSerializable, // pgx выполнит: BEGIN ISOLATION LEVEL SERIALIZABLE
	ReadOnly:  true,                  // Postgres пропустит механику записи; бесплатная подсказка планировщику
})

На другой стороне приезжает семантика Postgres, и её стоит знать точно:

  • Read committed (по умолчанию). Каждый стейтмент видит свежий снапшот. Два SELECT в одной транзакции могут разойтись; read-modify-write в виде SELECT-потом-UPDATE молча теряет конкурентные обновления. Лекарства живут в SQL: один атомарный UPDATE ... SET x = x + 1 или SELECT ... FOR UPDATE, чтобы сперва залочить строку.
  • Repeatable read. Один снапшот на всю транзакцию — чтения стабильны. Но если конкурентная транзакция закоммитила запись в строку, которую вы затем обновляете, ваша падает со SQLSTATE 40001 (serialization_failure). Уже на этом уровне повтор перестаёт быть опциональным.
  • Serializable. Postgres гоняет SSI (Serializable Snapshot Isolation — сериализуемая изоляция на снапшотах) поверх снапшотов, отслеживая зависимости чтения/записи между конкурентными транзакциями. Он даёт настоящую сериализуемость — и оставляет за собой право обрывать транзакции, которые по отдельности не сделали ничего плохого, тем же 40001. Документация прямолинейна насчёт контракта: приложения на этом уровне обязаны быть готовы повторять транзакции. Serializable без цикла повторов — латентный генератор 500-х, который срабатывает только под конкуренцией, то есть только в проде.

Ожидания локов, дедлоки и цикл повторов

Две разные патологии медленных транзакций постоянно путают. Ожидание лока — тихая очередь: ваш UPDATE блокируется, пока транзакция, держащая лок строки, не закоммитится — без ошибки, просто задержка (ограничивайте через lock_timeout; вытаскивайте на свет через log_lock_waits). Дедлок — цикл: транзакция A держит строку 1 и хочет строку 2, B держит строку 2 и хочет строку 1. Postgres обнаруживает цикл спустя deadlock_timeout (по умолчанию 1 с), выбирает жертву и убивает её со SQLSTATE 40P01 — выжившая продолжает нормально. И 40001, и 40P01 повторяемы по замыслу: база говорит «это чередование проиграло, попробуй ещё раз», а не «эта операция неверна». Что делает retry-хелпер несущим паттерном этого юнита:

func withTx(ctx context.Context, db *sql.DB, opts *sql.TxOptions, fn func(tx *sql.Tx) error) error {
	var err error
	for attempt := 1; attempt <= 4; attempt++ {
		err = func() error {
			tx, err := db.BeginTx(ctx, opts)
			if err != nil {
				return err
			}
			defer tx.Rollback()
			if err := fn(tx); err != nil {
				return err
			}
			return tx.Commit()
		}()
		if !retryable(err) || ctx.Err() != nil {
			return err
		}
		// бэкофф с джиттером: партнёры по дедлоку, ретраящие в ногу, столкнутся снова
		time.Sleep(time.Duration(attempt*attempt) * 25 * time.Millisecond)
	}
	return fmt.Errorf("transaction gave up after 4 attempts: %w", err)
}

func retryable(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) &&
		(pgErr.Code == "40001" || pgErr.Code == "40P01") // serialization_failure, deadlock_detected
}

Вес несут три детали. Классификация идёт через errors.As по типизированной ошибке драйвера и сверяет коды SQLSTATE — никогда не по подстроке в тексте ошибки, который ломается между версиями драйвера и локалями. Замыкание перезапускается целиком, поэтому fn обязана быть безопасной к повтору: никаких побочных эффектов вне самой транзакции (письмо, отправленное внутри fn, уйдёт по разу на каждую попытку). А бэкофф с джиттером не случаен — два партнёра по дедлоку, ретраящие по одинаковому расписанию, снова встретятся на тех же строках.

Викторина

Serializable-транзакция оборвалась со SQLSTATE 40001, но код-ревью подтверждает: логика верна. Какова правильная реакция?

Почему это работает

Почему serializable обрывает транзакции, которые не сделали ничего плохого? Потому что точность дороже повторов. Настоящая сериализуемость требует знать, может ли пересечение чтений/записей конкурентных транзакций образовать цикл в графе сериализации — а точный ответ означал бы вечное отслеживание каждой зависимости. SSI в Postgres отслеживает аппроксимацию (rw-антизависимости), дешёвую в поддержке и не пропускающую ни одной настоящей аномалии, ценой редких ложных срабатываний. Инженерная ставка: оборвать и повторить несколько процентов транзакций под конкуренцией радикально дешевле, чем локи, которые пессимистичная сериализуемость навесила бы на каждую транзакцию, конкурентную или нет. Цикл повторов — не костыль, а согласованная половина протокола.

Вспомните перед уходом
  1. 01
    Восстанови инцидент Чёрной пятницы: почему здоровая база уронила весь сервис и каково структурное лекарство?
  2. 02
    Сопоставь три уровня изоляции, видимые из Go, с тем, что реально делает Postgres, и сформулируй обязательства по повтору точно.
Итог

Транзакция — это пришпиленное соединение. BeginTx забирает одно соединение из пула, и каждый стейтмент на Tx едет по нему, пока Commit или Rollback не разрешит хендл — поэтому правило урока механично: между Begin и Commit только операторы базы и дешёвый CPU. Один HTTP-вызов внутри этого интервала под нагрузкой умножается в голодание пула, и дашборды покажут здоровую базу, пока сервис тонет. Каноническая форма — defer tx.Rollback() сразу после BeginTx: Rollback после успешного Commit возвращает sql.ErrTxDone и не делает ничего, так что одна строка покрывает каждый ранний return и панику. Изоляция, запрошенная через sql.TxOptions, приезжает семантикой Postgres: read committed даёт каждому стейтменту собственный снапшот и тихо допускает потерянные обновления в read-modify-write коде; repeatable read даёт транзакции один снапшот и обрывает конфликтующих писателей с 40001; serializable добавляет SSI-отслеживание зависимостей, настоящую сериализуемость и задокументированную обязанность повторять обрывы 40001, даже когда ваша логика безупречна. Дедлоки существуют на каждом уровне — Postgres находит цикл спустя deadlock_timeout, убивает одну транзакцию с 40P01 и даёт второй продолжить. Retry-хелпер кодирует весь контракт: классифицируй по SQLSTATE через errors.As и никогда по тексту ошибки; перезапускай всё замыкание на свежем снапшоте и никогда отдельный стейтмент; ограничивай попытки; джиттерь бэкофф, чтобы партнёры по дедлоку рассинхронизировались; и держи побочные эффекты вне замыкания, потому что всё в нём выполняется по разу на попытку. Теперь, когда встретишь 40001 в проде при внешне верном коде, — это не баг, это Postgres просит повтора, а retry-хелпер — готовый ответ.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.