Транзакции в базе данных: перевод денег без потерь
Если списать деньги с одного счёта, а зачисление на другой завершится ошибкой, база останется в неверном состоянии. Транзакция объединяет оба изменения: они сохраняются вместе или не сохраняется ни одно.

При переводе между счетами база должна выполнить два изменения: уменьшить баланс отправителя и увеличить баланс получателя. Если сервер упадёт между этими операциями, обычные отдельные запросы оставят деньги в промежуточном состоянии. В PostgreSQL 16 транзакция позволяет объединить изменения: либо применятся оба, либо ни одно. Ниже — короткий пример, а затем разбор того, что он гарантирует и чего не гарантирует.
Что именно защищает транзакция
Представьте таблицу счетов с балансом. Приложение выполняет списание и зачисление двумя запросами. Между ними может оборваться соединение, завершиться процесс или сработать ограничение базы. Если первый запрос уже сохранился, а второй нет, сумма на счетах изменится.
Транзакция задаёт границу для группы операций. Команда BEGIN открывает её, COMMIT фиксирует изменения, а ROLLBACK отменяет их. PostgreSQL не отдаёт частичный результат: до фиксации изменения транзакции не становятся окончательным состоянием базы.
Транзакция защищает целостность изменений внутри базы. Она не делает перевод успешным сама по себе и не заменяет проверку суммы, прав доступа и результата операции в приложении.
Атомарность¹ — свойство, при котором группа операций выполняется целиком или не выполняется вовсе. Это первое из четырёх свойств ACID, которыми обычно описывают транзакции.
Перевод в PostgreSQL 16
Допустим, таблица accounts уже создана, в ней есть счета с идентификаторами 10 и 20, а колонка balance хранит числовой остаток пакета. Ограничение не позволяет сохранить отрицательное значение:
ALTER TABLE accounts
ADD CONSTRAINT accounts_balance_nonnegative CHECK (balance >= 0);
Для примера переводим 25 единиц со счёта 10 на счёт 20:
BEGIN;
UPDATE accounts
SET balance = balance - 25
WHERE id = 10;
UPDATE accounts
SET balance = balance + 25
WHERE id = 20;
COMMIT;
Если оба счёта существуют и запросы успешно выполнились, после COMMIT новые остатки пакета сохранены вместе. Если второе обновление завершится ошибкой, приложение должно выполнить ROLLBACK, и списание тоже отменится. Ошибка любого запроса переводит текущую транзакцию PostgreSQL в состояние, где её нельзя просто продолжить: нужно отменить её или закрыть соединение.
Но в этом примере есть важный пробел. Если счёта с id 20 нет, второе обновление затронет ноль строк, а не обязательно завершится ошибкой. База может зафиксировать списание. Поэтому приложение должно проверять число изменённых строк после каждого обновления и откатывать транзакцию, если оно не равно ожидаемому. Проверки входных данных — положительная сумма, разные счета, разрешение на перевод — тоже остаются на стороне приложения или отдельной логики базы.
Где возникают ошибки при параллельных переводах
Транзакция не означает, что операции выполняются по одной. Несколько запросов могут одновременно работать с одними счетами. Если два перевода читают один и тот же начальный баланс, а затем записывают рассчитанные значения, один из результатов может затереть другой. Это называют потерянным обновлением.
При обычном обновлении строки PostgreSQL блокирует изменяемую строку на время операции. Но если приложение сначала читает баланс, вычисляет новый баланс в коде, а потом отправляет безусловный запрос на запись, оно может принять решение на устаревших данных. Для типового списания безопаснее выразить условие прямо в запросе:
UPDATE accounts
SET balance = balance - 25
WHERE id = 10
AND balance >= 25;
После него приложение проверяет, что изменилась ровно одна строка. Ноль строк означает, что счёта нет или средств недостаточно; продолжать зачисление нельзя. Этот фрагмент сам по себе не завершает перевод: списание и зачисление всё равно нужно связать общей транзакцией и обработать результат обеих операций.
Блокировки тоже имеют цену. Если две транзакции захватывают одни и те же счета в разном порядке, они могут ждать друг друга; PostgreSQL обнаружит взаимную блокировку и отменит одну из транзакций. Приложение должно уметь повторить такую операцию, но не бесконечно и не без ограничений: сначала нужно убедиться, что повтор безопасен.
Четыре свойства ACID — без магии
- Атомарность: списание и зачисление фиксируются вместе или откатываются вместе.
- Согласованность: ограничения базы, например запрет отрицательного баланса, не дают сохранить некоторые некорректные состояния. Но бизнес-правила нужно задать явно.
- Изоляция: параллельные транзакции не должны приводить к результатам, запрещённым выбранным уровнем изоляции. Конкретные гарантии зависят от уровня изоляции и запросов.
- Долговечность: после успешного
COMMITбаза считает изменения зафиксированными. Дальнейшая сохранность зависит также от настроек и инфраструктуры резервного копирования и репликации.
ACID — не обещание, что любая программа с BEGIN автоматически корректна. Например, транзакция в одной базе не отменит уже отправленное письмо, запрос во внешний платёжный сервис или сообщение, которое другая система успела обработать.
Как выбирать границы транзакции
Держите внутри транзакции только те операции базы, которые должны примениться вместе. Не открывайте её перед долгим сетевым запросом: пока приложение ждёт внешний сервис, строки могут оставаться заблокированными, а другие запросы — простаивать.
Для одного перевода практический порядок обычно такой:
- Проверьте формат суммы, права пользователя и допустимость операции.
- Откройте транзакцию и выполните списание с условием достаточного баланса.
- Убедитесь, что списание затронуло ровно одну строку.
- Зачислите сумму на второй счёт и проверьте результат.
- Зафиксируйте транзакцию только после успешных проверок; при любой ошибке выполните откат.
Для реального денежного продукта этой схемы мало: нужны защита от повторного запроса, журнал операций, обработка сбоев и продуманное восстановление. Например, клиент может повторить запрос после сетевого тайм-аута, хотя первый перевод уже зафиксировался. Если повтор не отличить от нового перевода, деньги спишутся дважды. Идемпотентность и интеграция с платёжными системами — отдельная тема.
Что делать дальше
Проверьте перевод на тестовой базе в трёх сценариях: оба счета существуют, денег недостаточно, получателя нет. После каждого сценария сверяйте не только ответ приложения, но и фактические балансы. Затем протестируйте два параллельных перевода с одного счёта и убедитесь, что ни один из них не обходит проверку остатка. В проде отслеживайте частоту откатов, взаимных блокировок и повторных попыток: рост этих показателей часто заметен раньше, чем жалобы на неверные балансы.
¹ Атомарность — неделимость группы операций с точки зрения результата: транзакция фиксирует всю группу или отменяет её.
Антиблок-аналитик 115-ФЗ: Анализ выписок, поиск рисков блокировки счёта, инструкции по безопасным транзакциям.
Спросить за 41 ₽Источники: PostgreSQL 16: Transaction Isolation, PostgreSQL 16: Explicit Locking
Частые вопросы
Если запрос упал после списания, деньги пропадут?
Достаточно ли обернуть два UPDATE в BEGIN и COMMIT?
Что будет, если одновременно переводить деньги с одного счёта?
Можно ли держать транзакцию открытой, пока отвечает внешний сервис?
Транзакция гарантирует, что перевод не повторится дважды?
Этот пример подходит для любой версии PostgreSQL?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.