Изучите транзакции Postgres для многошаговых рабочих процессов: как группировать обновления безопасно, предотвращать частичные записи, обрабатывать повторы и сохранять согласованность данных.

Большинство реальных фич — это не одна операция с базой. Это короткая цепочка: вставить строку, обновить баланс, пометить статус, записать аудит, возможно поставить задачу в очередь. Частичная запись случается, когда в базу попали только некоторые из этих шагов.
Это проявляется, когда что-то прерывает цепочку: ошибка на сервере, таймаут между приложением и Postgres, крах после шага 2 или повтор, который снова выполняет шаг 1. Каждое выражение по‑отдельности корректно. Рабочий процесс ломается, когда он останавливается посередине.
Вы обычно быстро замечаете это:
Конкретный пример: апгрейд плана обновляет запись клиента, добавляет платёж и увеличивает доступные кредиты. Если приложение упало после сохранения платежа, но до добавления кредитов, поддержка видит «оплачен» в одной таблице и «нет кредитов» в другой. Если клиент повторит действие, вы даже можете записать платёж дважды.
Цель проста: относиться к рабочему процессу как к одному переключателю. Либо все шаги проходят, либо ни один, чтобы никогда не хранить наполовину сделанную работу.
Транзакция — это способ базы данных сказать: рассматривай эти шаги как одну единицу работы. Либо все изменения происходят, либо ни одно. Это важно всякий раз, когда ваш рабочий процесс требует более одного обновления, например создание строки, обновление баланса и запись аудита.
Думайте о переводе денег между двумя счетами: нужно списать с аккаунта A и добавить на аккаунт B. Если приложение упадёт после первого шага, вы не хотите, чтобы система «запомнила» только списание.
Когда вы commit, вы говорите Postgres: оставь всё, что я сделал в этой транзакции. Все изменения становятся постоянными и видимыми другим сессиям.
Когда вы rollback, вы говорите Postgres: забудь всё, что я сделал в этой транзакции. Postgres отменяет изменения так, как будто транзакция никогда не происходила.
Внутри транзакции Postgres гарантирует, что вы не покажете другим сессиям наполовину завершённые результаты до коммита. Если что-то не сработало и вы откатились, база уберёт записи из этой транзакции.
Транзакция не исправит плохой дизайн рабочего процесса. Если вы списали неверную сумму, использовали чужой ID пользователя или пропустили проверку, Postgres аккуратно зафиксирует неверный результат. Транзакции также не предотвращают автоматически все бизнес‑конфликты (например, перепродажу), если не сочетать их с нужными ограничениями, блокировками или уровнем изоляции.
Всякий раз, когда вы обновляете более одной таблицы (или более одной строки) для завершения одного реального действия, у вас кандидат на транзакцию. Суть не меняется: либо всё сделано, либо ничего.
Поток заказа — классический случай. Вы можете создать запись заказа, зарезервировать товар, принять платёж, потом пометить заказ как оплаченный. Если платёж прошёл, а обновление статуса сломалось, у вас деньги списаны, а заказ выглядит неоплаченным. Если создан заказ, но не зарезервирован запас, вы можете продать то, чего нет.
Онбординг пользователя ломается по тем же причинам: создание пользователя, вставка профиля, назначение ролей и запись о том, что нужно отправить welcome‑письмо — это одно логическое действие. Без группировки можно получить пользователя, который может войти, но не имеет прав, или профиль без пользователя.
Бэк‑офисные действия часто требуют «журнал + смена состояния» вместе. Утверждение запроса, запись аудита и обновление баланса должны пройти одновременно. Если баланс поменялся, а аудита нет, вы теряете доказательства того, кто и зачем сделал изменение.
Фоновые задания тоже выигрывают: при обработке элемента работы с несколькими шагами — захватить элемент, чтобы два работника не сделали одно и то же, применить бизнес‑обновление, записать результат для отчётности и повторов, потом пометить элемент как выполненный (или проваленный с причиной). Если эти шаги разойдутся, повторы и конкуренция создадут хаос.
Многошаговые фичи ломаются, когда вы относитесь к ним как к набору независимых обновлений. Перед тем как открыть клиент базы, опишите рабочий процесс как короткую историю с одной явной чертой финиша: что именно считается «готово» для пользователя?
Начните с перечисления шагов простым языком, затем определите одно условие успеха. Например: «Заказ создан, запас зарезервирован, и пользователь видит номер подтверждения заказа». Всё, что короче — не успех, даже если некоторые таблицы обновлены.
Далее проведите чёткую границу между работой с базой и внешней работой. Шаги с базой — те, которые вы можете защитить транзакциями. Внешние вызовы, такие как приём карт, отправка писем или вызовы сторонних API, могут падать медленно и непредсказуемо, и обычно вы не можете их откатить.
Простой подход к планированию: разделите шаги на (1) обязательно «всё или ничего», (2) могут происходить после коммита.
Внутри транзакции держите только шаги, которые должны быть согласованы вместе:
Переносите побочные эффекты наружу. Например, сначала закоммитьте заказ, а затем отправляйте подтверждение по почте на основе записи в outbox.
Для каждого шага опишите, что должно произойти, если следующий шаг не выполнится. «Откат» может означать откат транзакции в базе или компенсирующее действие.
Пример: если платёж прошёл, но резервирование запаса не удалось, заранее решите, будете ли вы возвращать деньги сразу или помечать заказ как «платёж принят, ожидает запас» и обрабатывать асинхронно.
Транзакция говорит Postgres: считай эти шаги одной единицей. Либо всё произойдёт, либо ничего. Это самый простой способ предотвратить частичные записи.
Используйте одно соединение с базой (одну сессию) от начала до конца. Если вы разбросаете шаги по разным соединениям, Postgres не сможет гарантировать поведение «всё или ничего».
Последовательность простая: begin, выполните нужные чтения и записи, commit если всё прошло, иначе rollback и верните понятную ошибку.
Вот минимальный пример на SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Транзакции держат блокировки пока выполняются. Чем дольше они открыты, тем больше вы блокируете других и тем вероятнее таймауты или дедлоки. Делайте только самое необходимое внутри транзакции и выносите медленные задачи (отправка почты, вызовы платёжных провайдеров, генерация PDF) наружу.
Когда что‑то падает, логируйте достаточно контекста, чтобы воспроизвести проблему, не раскрывая чувствительных данных: имя рабочего процесса, order_id или user_id, ключевые параметры (сумма, валюта) и код ошибки Postgres. Избегайте логирования полных полезных нагрузок, данных карт или личной информации.
Конкурентность — это просто два действия одновременно. Представьте двух покупателей, пытающихся купить последний билет. Оба видят «1 осталось», оба нажимают «Купить», и теперь вашему приложению нужно решить, кто его получит.
Без защиты оба запроса могут прочитать одно и то же старое значение и оба записать обновление. Так появляются отрицательные остатки, дубли бронирований или платёж без заказа.
Блокировка строк — самый простой барьер. Вы блокируете конкретную строку, которую собираетесь менять, выполняете проверки, затем обновляете её. Другие транзакции, трогающие ту же строку, должны ждать вашего коммита или отката, что предотвращает двойные обновления.
Обычный паттерн: начать транзакцию, выбрать строку инвентаря с FOR UPDATE, проверить наличие запасов, уменьшить количество, затем вставить заказ. Это «держит дверь» пока вы заканчиваете критические шаги.
Уровни изоляции контролируют, насколько «странные» результаты вы допускаете при параллельных транзакциях. Баланс обычно между безопасностью и скоростью:
Держите блокировки короткими. Если транзакция висит, пока вы делаете внешние вызовы или ждёте действия пользователя, появятся длинные ожидания и таймауты. Предпочитайте явный путь отказа: установите lock timeout, поймайте ошибку и верните «повторите попытку», вместо того чтобы позволять запросам висеть.
Если нужно делать работу вне базы (например, списывать карту), разделите рабочий процесс: быстро зарезервируйте, закоммитьте, потом выполните медленный шаг и завершите короткой транзакцией.
Повторы — нормальное явление в приложениях с Postgres. Запрос может упасть даже при правильном коде: дедлоки, таймауты выражений, кратковременные сетевые обрывы или ошибки сериализации при строгой изоляции. Если просто повторять тот же обработчик, вы рискуете создать второй заказ, снять деньги дважды или вставить дубли «событий».
Решение — идемпотентность: операция должна быть безопасна при повторном запуске с тем же вводом. База должна уметь распознать «это тот же запрос» и ответить последовательно.
Практичный паттерн — прикреплять ключ идемпотентности (часто клиентский request_id) к каждому многошаговому рабочему процессу и сохранять его в основной записи, затем добавить уникальное ограничение на этот ключ.
Например: при оформлении заказа сгенерируйте request_id при клике «Оплатить», затем вставьте заказ с этим request_id. При повторах второй запрос попадёт на уникальное ограничение, и вы вернёте существующий заказ вместо создания нового.
Что обычно важно:
Держите цикл повторов вне транзакции. Каждая попытка должна начинать новую транзакцию и заново выполнять весь блок работы с нуля. Повтор внутри упавшей транзакции не поможет, потому что Postgres пометит её как aborted.
Небольшой пример: ваше приложение пытается создать заказ и зарезервировать запас, но таймаут случился сразу после COMMIT. Клиент повторяет запрос. С идемпотентным ключом второй запрос вернёт уже созданный заказ и не удвоит резервирование.
Транзакции держат рабочий процесс вместе, но не делают данные правильными автоматически. Надёжный способ избежать последствий частичных записей — сделать «неправильные» состояния тяжёлыми или невозможными в базе, даже если в коде появилась ошибка.
Начните с базовых защит. Внешние ключи гарантируют, что ссылки реальны (строка order_item не может ссылаться на несуществующий order). NOT NULL предотвращает наполовину заполненные строки. CHECK‑ограничения ловят бессмысленные значения (например, quantity > 0, total_cents >= 0). Эти правила срабатывают при каждой записи, вне зависимости от того, какой сервис или скрипт трогает базу.
Для длинных рабочих процессов моделируйте изменения состояния явно. Вместо множества булевых флагов используйте один столбец status (pending, paid, shipped, canceled) и разрешайте только валидные переходы. Это можно заставить обходиться ограничениями или триггерами, чтобы база отвергала нелегальные переходы вроде shipped -> pending.
Уникальность — ещё одна форма корректности. Добавьте unique‑ограничения там, где дубли ломают рабочий процесс: order_number, invoice_number или idempotency_key для повторов. Тогда при повторах база блокирует вторую вставку, и вы можете безопасно вернуть «уже обработано» вместо создания второго заказа.
Когда нужен трассируемый след, храните его явно. Таблица аудита (или история) с тем, кто что и когда изменил, превращает «загадочные правки» в факты, которые можно исследовать при инцидентах.
Большинство частичных записей не из‑за «плохого SQL». Они происходят из проектных решений, которые позволяют зафиксировать только половину истории.
accounts затем orders, а другой наоборот, вы повышаете шанс дедлоков под нагрузкой.Конкретный пример: при оформлении вы резервируете запас, создаёте заказ, а затем снимаете деньги с карты. Если вы снимаете карту внутри той же транзакции, вы можете держать блокировку инвентаря, пока ждёте сеть. Если списание прошло, а транзакция потом откатилась, вы сняли деньги без заказа.
Более безопасный паттерн: держите транзакцию сфокусированной на состоянии базы (зарезервировать запас, создать заказ, записать платёж как pending), закоммитьте, затем зовите внешний API и в новом коротком транзакции запишите результат. Многие команды делают это через статус pending и фоновую задачу.
Когда рабочий процесс имеет несколько шагов (insert, update, charge, send), цель проста: либо всё записано, либо ничего.
Держите все необходимые записи в одной транзакции. Если один шаг падает — откатывайте и оставляйте данные как были.
Сделайте условие успеха явным. Например: «Заказ создан, запас зарезервирован и статус платежа записан». Всё остальное — путь ошибки, который должен приводить к откату.
BEGIN ... COMMIT.ROLLBACK, и вызывающая сторона получает понятную ошибку.Предположите, что один и тот же запрос может быть повторён. База должна помочь вам обеспечить правило «только один раз».
Делайте минимальную работу в транзакции и избегайте ожидания network‑вызовов с блокировками.
Если вы не видите, где ломается, вы будете гадать.
Оформление заказа имеет несколько шагов, которые должны идти вместе: создать заказ, зарезервировать запас, записать попытку платежа и пометить статус заказа.
Представьте, что пользователь нажал «Купить» на 1 товар.
Внутри одной транзакции выполняются только изменения базы:
orders со статусом pending_payment.inventory.available или создать строку reservations).payment_intents с клиентским idempotency_key (уникальным).outbox вроде "order_created".Если любое выражение упадёт (нет запаса, ошибка ограничения, крах), Postgres откатит всю транзакцию. У вас не будет заказа без резервации или резервации без заказа.
Платёжный провайдер — вне вашей базы, так что обрабатывайте его отдельно.
Если вызов провайдера упал до коммита, прерывайте транзакцию и ничего не пишите. Если провайдер упал после коммита, выполните новую транзакцию, которая пометит попытку платежа как failed, освободит резерв и выставит статус заказа canceled.
Пусть клиент присылает idempotency_key на каждую попытку оформления. Принудите это уникальным индексом на payment_intents(idempotency_key) (или на orders, если предпочитаете). При повторах код ищет существующие строки и продолжает работу вместо вставки нового заказа.
Не отправляйте письма внутри транзакции. Запишите outbox‑запись в той же транзакции, затем фоновой воркер отправит письмо после коммита. Так вы никогда не пришлёте письмо о заказе, который был откатан.
Выберите один поток, который трогает больше одной таблицы: регистрация + постановка welcome‑письма в очередь, оформление + резервирование, счёт + запись в книгу, создание проекта + настройки по‑умолчанию.
Опишите шаги в первую очередь, затем правила, которые всегда должны соблюдаться (инварианты). Пример: «Заказ либо полностью оплачен и зарезервирован, либо не оплачен и не зарезервирован. Никаких полурезервов.» Превратите эти правила в единицу «всё или ничего».
Простой план:
Затем протестируйте неприятные случаи целенаправленно. Смоделируйте крах после шага 2, таймаут прямо перед коммитом и двойную отправку из UI. Цель — скучные исходы: нет осиротевших строк, нет двойных списаний, ничего не висит в pending навсегда.
Если вы быстро прототипируете, полезно сначала накидать процесс в инструменте для планирования, прежде чем генерировать обработчики и схему. Например, Koder.ai (koder.ai) имеет Planning Mode и поддерживает снимки и откат, что удобно при итерации границ транзакций и ограничений.
Сделайте это для одного потока на этой неделе. Второй пойдёт намного быстрее.