KoderKoder.ai
ЦеныДля бизнесаОбразованиеДля инвесторов
ВойтиНачать

Продукт

ЦеныДля бизнесаДля инвесторов

Ресурсы

Связаться с намиПоддержкаОбразованиеБлог

Правовая информация

Политика конфиденциальностиУсловия использованияБезопасностьПолитика допустимого использованияСообщить о нарушении

Соцсети

LinkedInTwitter
Koder.ai
Язык

© 2026 Koder.ai. Все права защищены.

Главная›Блог›Транзакции Postgres для многошаговых рабочих процессов: практические паттерны
22 авг. 2025 г.·7 мин

Транзакции Postgres для многошаговых рабочих процессов: практические паттерны

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

Транзакции Postgres для многошаговых рабочих процессов: практические паттерны

Почему многошаговые обновления часто становятся неконсистентными

Большинство реальных фич — это не одна операция с базой. Это короткая цепочка: вставить строку, обновить баланс, пометить статус, записать аудит, возможно поставить задачу в очередь. Частичная запись случается, когда в базу попали только некоторые из этих шагов.

Это проявляется, когда что-то прерывает цепочку: ошибка на сервере, таймаут между приложением и Postgres, крах после шага 2 или повтор, который снова выполняет шаг 1. Каждое выражение по‑отдельности корректно. Рабочий процесс ломается, когда он останавливается посередине.

Вы обычно быстро замечаете это:

  • Строка есть, а связанная строка отсутствует (заказ создан, товаров нет)
  • Деньги списаны, но статус не поменялся (оплачено, но всё ещё отмечено как неоплачено)
  • Две записи вместо одной (дубли подписок после повтора)
  • Флаги не совпадают (пользователь «активен», но у него нет плана)
  • Состояния, которые появляются только под нагрузкой или при ошибках

Конкретный пример: апгрейд плана обновляет запись клиента, добавляет платёж и увеличивает доступные кредиты. Если приложение упало после сохранения платежа, но до добавления кредитов, поддержка видит «оплачен» в одной таблице и «нет кредитов» в другой. Если клиент повторит действие, вы даже можете записать платёж дважды.

Цель проста: относиться к рабочему процессу как к одному переключателю. Либо все шаги проходят, либо ни один, чтобы никогда не хранить наполовину сделанную работу.

Транзакции, простыми словами

Транзакция — это способ базы данных сказать: рассматривай эти шаги как одну единицу работы. Либо все изменения происходят, либо ни одно. Это важно всякий раз, когда ваш рабочий процесс требует более одного обновления, например создание строки, обновление баланса и запись аудита.

Думайте о переводе денег между двумя счетами: нужно списать с аккаунта A и добавить на аккаунт B. Если приложение упадёт после первого шага, вы не хотите, чтобы система «запомнила» только списание.

Commit vs rollback

Когда вы commit, вы говорите Postgres: оставь всё, что я сделал в этой транзакции. Все изменения становятся постоянными и видимыми другим сессиям.

Когда вы rollback, вы говорите Postgres: забудь всё, что я сделал в этой транзакции. Postgres отменяет изменения так, как будто транзакция никогда не происходила.

Что Postgres гарантирует (а что нет)

Внутри транзакции Postgres гарантирует, что вы не покажете другим сессиям наполовину завершённые результаты до коммита. Если что-то не сработало и вы откатились, база уберёт записи из этой транзакции.

Транзакция не исправит плохой дизайн рабочего процесса. Если вы списали неверную сумму, использовали чужой ID пользователя или пропустили проверку, Postgres аккуратно зафиксирует неверный результат. Транзакции также не предотвращают автоматически все бизнес‑конфликты (например, перепродажу), если не сочетать их с нужными ограничениями, блокировками или уровнем изоляции.

Рабочие процессы, которые стоит группировать

Всякий раз, когда вы обновляете более одной таблицы (или более одной строки) для завершения одного реального действия, у вас кандидат на транзакцию. Суть не меняется: либо всё сделано, либо ничего.

Поток заказа — классический случай. Вы можете создать запись заказа, зарезервировать товар, принять платёж, потом пометить заказ как оплаченный. Если платёж прошёл, а обновление статуса сломалось, у вас деньги списаны, а заказ выглядит неоплаченным. Если создан заказ, но не зарезервирован запас, вы можете продать то, чего нет.

Онбординг пользователя ломается по тем же причинам: создание пользователя, вставка профиля, назначение ролей и запись о том, что нужно отправить welcome‑письмо — это одно логическое действие. Без группировки можно получить пользователя, который может войти, но не имеет прав, или профиль без пользователя.

Бэк‑офисные действия часто требуют «журнал + смена состояния» вместе. Утверждение запроса, запись аудита и обновление баланса должны пройти одновременно. Если баланс поменялся, а аудита нет, вы теряете доказательства того, кто и зачем сделал изменение.

Фоновые задания тоже выигрывают: при обработке элемента работы с несколькими шагами — захватить элемент, чтобы два работника не сделали одно и то же, применить бизнес‑обновление, записать результат для отчётности и повторов, потом пометить элемент как выполненный (или проваленный с причиной). Если эти шаги разойдутся, повторы и конкуренция создадут хаос.

Спроектируйте рабочий процесс перед написанием SQL

Многошаговые фичи ломаются, когда вы относитесь к ним как к набору независимых обновлений. Перед тем как открыть клиент базы, опишите рабочий процесс как короткую историю с одной явной чертой финиша: что именно считается «готово» для пользователя?

Начните с перечисления шагов простым языком, затем определите одно условие успеха. Например: «Заказ создан, запас зарезервирован, и пользователь видит номер подтверждения заказа». Всё, что короче — не успех, даже если некоторые таблицы обновлены.

Далее проведите чёткую границу между работой с базой и внешней работой. Шаги с базой — те, которые вы можете защитить транзакциями. Внешние вызовы, такие как приём карт, отправка писем или вызовы сторонних API, могут падать медленно и непредсказуемо, и обычно вы не можете их откатить.

Простой подход к планированию: разделите шаги на (1) обязательно «всё или ничего», (2) могут происходить после коммита.

Решите, что принадлежит внутрь транзакции

Внутри транзакции держите только шаги, которые должны быть согласованы вместе:

  • Создание или обновление основных строк (order, invoice, account balance)
  • Резервирование общих ресурсов (запас, места, квота)
  • Запись устойчивого «что делать дальше» события (outbox‑таблица)
  • Принуждение правил ограничениями (unique, foreign key)

Переносите побочные эффекты наружу. Например, сначала закоммитьте заказ, а затем отправляйте подтверждение по почте на основе записи в 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. Избегайте логирования полных полезных нагрузок, данных карт или личной информации.

Основы конкурентности: блокировки и изоляция без жаргона

Примените это к оформлению заказа
Создайте поток заказа, который остаётся корректным при таймаутах, повторах и конкуренции.
Start Building

Конкурентность — это просто два действия одновременно. Представьте двух покупателей, пытающихся купить последний билет. Оба видят «1 осталось», оба нажимают «Купить», и теперь вашему приложению нужно решить, кто его получит.

Без защиты оба запроса могут прочитать одно и то же старое значение и оба записать обновление. Так появляются отрицательные остатки, дубли бронирований или платёж без заказа.

Блокировка строк — самый простой барьер. Вы блокируете конкретную строку, которую собираетесь менять, выполняете проверки, затем обновляете её. Другие транзакции, трогающие ту же строку, должны ждать вашего коммита или отката, что предотвращает двойные обновления.

Обычный паттерн: начать транзакцию, выбрать строку инвентаря с FOR UPDATE, проверить наличие запасов, уменьшить количество, затем вставить заказ. Это «держит дверь» пока вы заканчиваете критические шаги.

Уровни изоляции контролируют, насколько «странные» результаты вы допускаете при параллельных транзакциях. Баланс обычно между безопасностью и скоростью:

  • Read Committed (по умолчанию): быстро, но вы можете видеть изменения, зафиксированные другими между запросами.
  • Repeatable Read: транзакция видит стабильную снимок, хорошо для последовательных чтений, может привести к большему числу повторов.
  • Serializable: максимальная безопасность, Postgres может прервать одну транзакцию, чтобы результат выглядел так, будто транзакции выполнялись последовательно.

Держите блокировки короткими. Если транзакция висит, пока вы делаете внешние вызовы или ждёте действия пользователя, появятся длинные ожидания и таймауты. Предпочитайте явный путь отказа: установите lock timeout, поймайте ошибку и верните «повторите попытку», вместо того чтобы позволять запросам висеть.

Если нужно делать работу вне базы (например, списывать карту), разделите рабочий процесс: быстро зарезервируйте, закоммитьте, потом выполните медленный шаг и завершите короткой транзакцией.

Повторы, которые не создают дубликаты

Повторы — нормальное явление в приложениях с Postgres. Запрос может упасть даже при правильном коде: дедлоки, таймауты выражений, кратковременные сетевые обрывы или ошибки сериализации при строгой изоляции. Если просто повторять тот же обработчик, вы рискуете создать второй заказ, снять деньги дважды или вставить дубли «событий».

Решение — идемпотентность: операция должна быть безопасна при повторном запуске с тем же вводом. База должна уметь распознать «это тот же запрос» и ответить последовательно.

Практичный паттерн — прикреплять ключ идемпотентности (часто клиентский request_id) к каждому многошаговому рабочему процессу и сохранять его в основной записи, затем добавить уникальное ограничение на этот ключ.

Например: при оформлении заказа сгенерируйте request_id при клике «Оплатить», затем вставьте заказ с этим request_id. При повторах второй запрос попадёт на уникальное ограничение, и вы вернёте существующий заказ вместо создания нового.

Что обычно важно:

  • Используйте уникальное ограничение на (request_id) или (user_id, request_id), чтобы блокировать дубликаты.
  • Когда ограничение срабатывает, получите существующую строку и верните тот же результат.
  • Побочные эффекты тоже должны следовать этому правилу: один платёжный intent на заказ, одно событие «заказ подтверждён» на заказ.
  • Логируйте request_id, чтобы поддержка могла проследить, что произошло.

Держите цикл повторов вне транзакции. Каждая попытка должна начинать новую транзакцию и заново выполнять весь блок работы с нуля. Повтор внутри упавшей транзакции не поможет, потому что Postgres пометит её как aborted.

Небольшой пример: ваше приложение пытается создать заказ и зарезервировать запас, но таймаут случился сразу после COMMIT. Клиент повторяет запрос. С идемпотентным ключом второй запрос вернёт уже созданный заказ и не удвоит резервирование.

Используйте базу данных для принуждения правил, а не только код

Выпустите согласованный поток
От плана до рабочей среды без ручной склейки инструментов.
Deploy App

Транзакции держат рабочий процесс вместе, но не делают данные правильными автоматически. Надёжный способ избежать последствий частичных записей — сделать «неправильные» состояния тяжёлыми или невозможными в базе, даже если в коде появилась ошибка.

Начните с базовых защит. Внешние ключи гарантируют, что ссылки реальны (строка 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». Они происходят из проектных решений, которые позволяют зафиксировать только половину истории.

Ловушки, которые встречаются в реальных приложениях

  • Делать медленную внешнюю работу внутри открытой транзакции. Вызов платёжного провайдера, отправка письма или загрузка файла внутри транзакции держит блокировки дольше, чем нужно. Если API медленный или таймаутит, другие пользователи встанут в очередь за вашей транзакцией.
  • Читать вне транзакции, а затем писать позже. Пример: вы получили баланс пользователя, показали его на экране, а потом списали по старому значению. Между чтением и записью другой сеанс мог изменить баланс.
  • Ловить ошибку, но при этом всё равно коммитить. Типичная схема: «попробовать шаг 1, попробовать шаг 2, залогировать ошибку, вернуть успех». Если код дошёл до COMMIT после ошибки, вы сознательно сделали базу несогласованной.
  • Обновлять таблицы в разном порядке в разных ветках кода. Если один запрос обновляет accounts затем orders, а другой наоборот, вы повышаете шанс дедлоков под нагрузкой.
  • Держать транзакции слишком долго. Длинные транзакции блокируют записи, задерживают очистку vacuum и создают странные таймауты.

Конкретный пример: при оформлении вы резервируете запас, создаёте заказ, а затем снимаете деньги с карты. Если вы снимаете карту внутри той же транзакции, вы можете держать блокировку инвентаря, пока ждёте сеть. Если списание прошло, а транзакция потом откатилась, вы сняли деньги без заказа.

Более безопасный паттерн: держите транзакцию сфокусированной на состоянии базы (зарезервировать запас, создать заказ, записать платёж как pending), закоммитьте, затем зовите внешний API и в новом коротком транзакции запишите результат. Многие команды делают это через статус pending и фоновую задачу.

Быстрый чек‑лист для операций «всё или ничего»

Когда рабочий процесс имеет несколько шагов (insert, update, charge, send), цель проста: либо всё записано, либо ничего.

Границы транзакций

Держите все необходимые записи в одной транзакции. Если один шаг падает — откатывайте и оставляйте данные как были.

Сделайте условие успеха явным. Например: «Заказ создан, запас зарезервирован и статус платежа записан». Всё остальное — путь ошибки, который должен приводить к откату.

  • Все нужные записи выполняются внутри одного блока BEGIN ... COMMIT.
  • В базе есть одно явное состояние «готово» (не просто в памяти приложения).
  • Любая ошибка приводит к ROLLBACK, и вызывающая сторона получает понятную ошибку.

Защитные механизмы (чтобы повторы не повредили)

Предположите, что один и тот же запрос может быть повторён. База должна помочь вам обеспечить правило «только один раз».

  • Поддерживайте действия «только один раз» уникальными ограничениями (один платёж на заказ, одна резервация на товар/заказ).
  • Делаейте повторы безопасными и детерминированными (тот же ввод даёт тот же финальный результат, а не дубликаты).

Держите транзакции короткими

Делайте минимальную работу в транзакции и избегайте ожидания network‑вызовов с блокировками.

  • Держите транзакции короткими и ставьте таймаут, чтобы они не застревали.
  • Медленную работу выполняйте вне транзакции, затем сохраните результат в новой короткой транзакции.

Наблюдайте за ошибками

Если вы не видите, где ломается, вы будете гадать.

  • Логируйте шаг рабочего процесса и request id при каждой ошибке.
  • Мониторьте уровни откатов и таймауты блокировок, чтобы быстро обнаруживать риски частичных записей.

Пример: поток оформления, остающийся консистентным при ошибках

Преобразуйте шаги в план
Используйте Planning Mode для отображения шагов, инвариантов и путей отказов перед написанием SQL.
Open Planning

Оформление заказа имеет несколько шагов, которые должны идти вместе: создать заказ, зарезервировать запас, записать попытку платежа и пометить статус заказа.

Представьте, что пользователь нажал «Купить» на 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‑письма в очередь, оформление + резервирование, счёт + запись в книгу, создание проекта + настройки по‑умолчанию.

Опишите шаги в первую очередь, затем правила, которые всегда должны соблюдаться (инварианты). Пример: «Заказ либо полностью оплачен и зарезервирован, либо не оплачен и не зарезервирован. Никаких полурезервов.» Превратите эти правила в единицу «всё или ничего».

Простой план:

  • Перечислите точные SQL‑операции в порядке (чтения, вставки, обновления, удаления).
  • Добавьте недостающие ограничения в базе (unique, foreign key, check).
  • Добавьте ключ идемпотентности для запроса, чтобы повторы не создавали дубликаты.
  • Оберните шаги в одну транзакцию и сделайте точку успеха явной (коммит только если все проверки прошли).
  • Решите, как выглядит безопасный повтор (тот же idempotency_key, тот же результат).

Затем протестируйте неприятные случаи целенаправленно. Смоделируйте крах после шага 2, таймаут прямо перед коммитом и двойную отправку из UI. Цель — скучные исходы: нет осиротевших строк, нет двойных списаний, ничего не висит в pending навсегда.

Если вы быстро прототипируете, полезно сначала накидать процесс в инструменте для планирования, прежде чем генерировать обработчики и схему. Например, Koder.ai (koder.ai) имеет Planning Mode и поддерживает снимки и откат, что удобно при итерации границ транзакций и ограничений.

Сделайте это для одного потока на этой неделе. Второй пойдёт намного быстрее.

Содержание
Почему многошаговые обновления часто становятся неконсистентнымиТранзакции, простыми словамиРабочие процессы, которые стоит группироватьСпроектируйте рабочий процесс перед написанием SQLШаг за шагом: обёртывание рабочего процесса в транзакциюОсновы конкурентности: блокировки и изоляция без жаргонаПовторы, которые не создают дубликатыИспользуйте базу данных для принуждения правил, а не только кодЧастые ошибки, приводящие к частичным записямБыстрый чек‑лист для операций «всё или ничего»Пример: поток оформления, остающийся консистентным при ошибкахСледующие шаги: примените это к одному рабочему процессу на этой неделе
Поделиться
Koder.ai
Создайте свое приложение с Koder сегодня!

Лучший способ понять возможности Koder — попробовать самому.

Начать бесплатноЗаказать демо