JavaRush /Курсы /Spring Data JPA /Транзакции в БД: commit

Транзакции в БД: commit и rollback

Spring Data JPA
3 уровень , 3 лекция
Открыта

1. Проблема «полузаказа»

Если многошаговая операция падает посередине, данные легко застревают в состоянии «наполовину сделано» — это и есть проблема «полузаказа». Представьте mini-shop: в коде всё выглядит аккуратно — вставили строку заказа, вставили позиции, списали остаток. Но база данных — не телепат и не читает ваш «намеренный сюжет» по выражению лица. Если между шагами случится ошибка, у разработчика быстро появляется желание переехать в лес и выращивать картошку.

Давайте посмотрим на проблему без философии, на уровне фактов. У нас есть таблицы (из предыдущего дня):

  • customer_order — корневая запись заказа;
  • order_item — позиции заказа;
  • stock_item — остатки.

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

Чтобы почувствовать, насколько это неприятно, достаточно мысленно задать себе вопрос: «А что я покажу пользователю?» Если заказ уже есть в customer_order, то формально «оформление прошло». Но если остатки не списались, склад говорит: «А я ничего не отдавал». Получается спор двух таблиц. А спор таблиц, к сожалению, всегда выигрывает таблица с неправильными данными.

2. Режим autocommit

Слово autocommit звучит дружелюбно, почти как «автосохранение» в Google Docs. И в простых случаях это действительно удобно: вы выполнили INSERT — и он сразу стал «официальным фактом» в базе. Но как только бизнес-операция состоит из нескольких шагов, autocommit превращается в человека, который после каждого предложения подписывает контракт, даже если дальше в тексте написано «ой, это черновик, не подписывать».

Смысл autocommit очень простой: каждая SQL-команда выполняется в собственной транзакции и автоматически подтверждается. То есть:

  • отправили INSERT — база выполнила и сразу «зафиксировала»;
  • отправили INSERT второй — база выполнила и снова зафиксировала;
  • отправили UPDATE — и вот тут случилась ошибка… но предыдущие команды уже зафиксированы, назад дороги нет.

В терминах JDBC (мы не уходим в JDBC-курс, просто фиксируем факт) у Connection по умолчанию часто стоит autoCommit = true. Во многих SQL-клиентах тоже есть галочка Auto-commit, и она обычно включена. В psql команды тоже идут как отдельные транзакции, пока вы явно не сделали BEGIN.

Вот минимальная «опасная» последовательность (псевдокод), которая выглядит как бизнес-операция, а технически — это три независимых подтверждения:

// Включаем автокоммит: каждая команда станет отдельной транзакцией.
connection.setAutoCommit(true);

execute("insert into customer_order ..."); // уже подтверждено (откатить нельзя)
execute("insert into order_item ...");     // уже подтверждено (откатить нельзя)
execute("update stock_item ...");          // здесь ошибка — поздно

С точки зрения базы это не «одна операция оформления заказа». Это просто три команды и три отдельные мини-жизни. И если третья жизнь закончилась трагедией, первые две продолжают существовать как ни в чём не бывало.

Чтобы было совсем наглядно, вот небольшая таблица:

Режим выполнения Что происходит после каждой команды Подходит для многошаговой операции?
autocommit = true команда автоматически фиксируется обычно нет
autocommit = false +
commit()
в конце
фиксируется только целый сценарий да

3. Явная транзакция: BEGIN/COMMIT/ROLLBACK

Теперь хорошая новость: базы данных придумали транзакции задолго до Spring, Hibernate и всех наших любимых аннотаций. И транзакция на уровне БД — это ровно тот механизм, который позволяет сказать: «Эти несколько команд — один смысловой блок. Либо всё, либо ничего». Самый важный момент здесь в том, что мы сами обозначаем границы этого блока.

В PostgreSQL классическая форма выглядит так: BEGIN (или START TRANSACTION) — затем команды — затем COMMIT или ROLLBACK.

Мини-пример в чистом SQL (очень короткий, чтобы не превращать лекцию в отдельный сценарий заказа):

begin; -- стартуем транзакцию: дальше изменения пока "черновик"

insert into customer_order (id, order_number, customer_email, total_amount)
values (700, 'ORD-700', 'alex@example.com', 399.98); -- шаг 1: создаём заказ

update stock_item
set available_quantity = available_quantity - 2
where product_id = 10; -- шаг 2: списываем остатки

commit; -- фиксируем оба шага как одно целое

А если что-то пошло не так (ошибка или решение приложения «не продолжаем сценарий»), то вместо commit делаете rollback:

begin; -- начали транзакцию

insert into customer_order (id, order_number, customer_email, total_amount)
values (700, 'ORD-700', 'alex@example.com', 399.98); -- успели сделать изменение

-- допустим, тут обнаружили проблему
rollback; -- отменяем ВСЕ изменения этой транзакции

Полезно держать в голове простую блок-схему:

flowchart TD
    A[BEGIN] --> B[Выполняем шаги бизнес-операции]
    B --> C{Все шаги успешны?}
    C -- Да --> D[COMMIT]
    C -- Нет --> E[ROLLBACK]

В этой схеме нет ничего «фреймворкового». Это чистая база данных. Фреймворки позже просто научатся автоматически делать то же самое, но сейчас нам важно понять фундамент: транзакция — это контейнер для нескольких SQL-действий.

4. Атомарность бизнес-операции

Слово «атомарность» звучит так, будто сейчас будет квантовая физика и кот Шрёдингера в роли DBA. На самом деле всё проще: атомарность означает, что операция выглядит как неделимая. Либо она произошла целиком, либо считается, что не произошла вообще. В мире данных это означает: после завершения транзакции в базе нет состояния «наполовину».

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

В mini-shop ситуация очень похожа. Если мы создали заказ, но не списали остатки, мы фактически сказали системе: «Заказ существует», но не обеспечили его реальность в складе. А если наоборот — списали остаток, но заказ не сохранился, то товар исчез со склада «в никуда». Оба сценария для бизнеса выглядят одинаково плохо, просто бухгалтер плачет по разным причинам.

Важно поймать ощущение: атомарность — это не «про удобство», а про выживаемость данных. И именно транзакция делает атомарность возможной в сценариях из нескольких команд.

5. COMMIT: фиксация изменений

Если BEGIN — это «мы начали историю», то COMMIT — это «мы публикуем её как официальную версию». С момента COMMIT изменения становятся зафиксированными: они сохраняются в базе как часть постоянного состояния, и просто «передумать» командой ROLLBACK уже нельзя. После коммита отмена делается только новой бизнес-операцией — например, отменой заказа или возвратом остатков, — а это уже совсем другая логика.

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

Поэтому в нормальном сценарии COMMIT стоит:

— не после первого INSERT, потому что это ещё не заказ «как целое»;
— не после вставки позиций, если остатки ещё не списались;
— а в конце, когда мы уверены, что всё прошло успешно.

В терминах поведения это можно сформулировать так: коммит подтверждает весь набор изменений транзакции разом, как одну «пачку». Это и есть практическая атомарность.

Если хочется увидеть это на пальцах, можно запомнить простую мысль: «COMMIT — это кнопка “Сохранить” для всей истории, а не для одной команды».

6. ROLLBACK: откат изменений

У новичка слово rollback иногда вызывает ощущение, будто это аварийная кнопка на атомной станции. На самом деле ROLLBACK — нормальная, штатная часть сценария. Ошибки случаются, условия не выполняются, данные могут не пройти ограничения, товара может не хватить — и в таких случаях правильное поведение системы: откатить изменения и оставить базу в исходном состоянии, как будто попытки не было.

Есть два важных типа причин для отката. Первый — техническая ошибка, когда команда реально упала (например, FK-нарушение или попытка записать NULL в NOT NULL поле). Второй — бизнес-условие, когда команда формально могла бы выполниться, но мы сами решили «не продолжаем»: например, обновление остатков не затронуло ни одной строки, потому что товара недостаточно, и мы не хотим сохранять заказ.

В PostgreSQL есть полезная особенность, о которой лучше узнать сейчас, чем в 2 ночи на проде. Если внутри транзакции произошла ошибка выполнения SQL, транзакция может перейти в состояние «сломана», и пока вы не сделаете ROLLBACK, база будет отвечать примерно в стиле: «Нет, я больше ничего не делаю, сначала откатись».

Это можно увидеть в простом примере:

begin; -- стартуем транзакцию

insert into customer_order (id, order_number, customer_email, total_amount)
values (1, 'ORD-1', 'a@b.c', 10.00); -- успели создать заказ

-- допустим, тут ошибка (например, FK violation)
insert into order_item (id, order_id, product_id, quantity, unit_price, line_total)
values (1, 999, 10, 1, 10.00, 10.00); -- order_id=999 не существует => ошибка

-- после ошибки любые команды могут не выполняться, пока не откатим
rollback; -- возвращаемся в "как было до begin"

Мораль: ROLLBACK — это не «стыдная часть кода». Это логичное завершение неуспешной попытки. В здоровой архитектуре откат предусмотрен так же явно, как и успешный коммит.

7. Границы транзакции

После того как мы познакомились с BEGIN/COMMIT/ROLLBACK, возникает естественный вопрос: «А где ставить границы транзакции? На каждую команду? На каждую таблицу? На каждый метод?». И здесь важно не уйти в крайности: транзакция должна быть не «минимальной», а «осмысленной».

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

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

В нашем mini-shop хороший пример «правильного размера» — оформление заказа. Это одна бизнес-операция, которая включает несколько таблиц, и именно она должна быть одной транзакцией. А вот, например, чтение списка товаров из каталога обычно не является такой операцией изменения состояния (мы сегодня не уходим в детали, просто фиксируем интуицию).

То есть правило по-человечески звучит так: граница транзакции должна совпадать с границей осмысленного изменения данных.

8. Транзакции в Java без Spring

Сейчас мы всё ещё в SQL-мире, но полезно увидеть, как «кнопки» commit/rollback выглядят со стороны Java. Не для того, чтобы писать JDBC-код вручную весь курс (мы этого делать не будем), а чтобы понимать: в итоге любые инструменты в Java всё равно приходят к этим базовым операциям.

Вот минимальный пример на JDBC-уровне. Он специально короткий и без архитектурных украшений, чтобы вы увидели только механику:

import java.sql.Connection;

// Отключаем автокоммит: теперь мы сами контролируем границы транзакции.
connection.setAutoCommit(false);

try {
    // Внутри транзакции делаем несколько шагов одной бизнес-операции.
    execute(connection, "insert into customer_order ...");
    execute(connection, "insert into order_item ...");
    execute(connection, "update stock_item ...");

    // Фиксируем всё разом, если все шаги прошли успешно.
    connection.commit();
} catch (Exception ex) {
    // Если на любом шаге ошибка — откатываем ВСЁ, что успели сделать.
    connection.rollback();
    throw ex;
}

Здесь важна не функция execute (она может быть хоть PreparedStatement, хоть чем угодно), а сама структура: отключили автокоммит, выполнили серию шагов, зафиксировали одной командой, а при ошибке сделали откат.

Если хочется увидеть чуть реалистичнее, можно представить, что executeUpdate возвращает количество изменённых строк, и мы уже на этом уровне принимаем бизнес-решение. Например, если UPDATE stock_item ... обновил 0 строк, значит товара не хватило, и мы сами инициируем откат (это важная идея для сценария заказа):

import java.sql.Connection;

// Переходим в ручное управление транзакцией.
connection.setAutoCommit(false);

try {
    // Шаг 1: создаём заказ (или его часть).
    execute(connection, "insert into customer_order ...");

    // Шаг 2: пытаемся списать остатки; результат — сколько строк реально обновилось.
    int updated = executeUpdate(connection, "update stock_item ...");

    // Если не обновили ни одной строки, считаем бизнес-операцию неуспешной.
    if (updated == 0) throw new IllegalStateException("Not enough stock");

    // Если всё ок — фиксируем весь сценарий.
    connection.commit();
} catch (Exception ex) {
    // Любая ошибка (и техническая, и бизнесовая) => откат транзакции.
    connection.rollback();
    throw ex;
}

Обратите внимание на полезный психологический эффект: транзакция в коде выглядит как один блок try/catch. Это и есть идея «unit of work»: мы либо дошли до commit(), либо всё откатили.

9. Проверка ROLLBACK на практике

Транзакции легче всего понять, когда вы хотя бы раз руками увидели эффект «как будто ничего не было». Поэтому полезно держать в голове маленькую проверку, которую можно сделать в SQL-клиенте даже без второго подключения и без сложных режимов изоляции.

Сценарий такой: внутри транзакции мы вставляем строку и тут же видим её запросом SELECT. Затем делаем ROLLBACK и снова выполняем SELECT — строки больше нет.

Пример (условный, но очень показательный):

begin; -- начинаем транзакцию

insert into category (id, code, name, active)
values (10, 'BOOKS', 'Книги', true); -- вставка пока видна только "внутри" транзакции

select count(*) from category where id = 10; -- 1 (внутри транзакции мы видим свою вставку)

rollback; -- откатываем вставку

select count(*) from category where id = 10; -- 0 (после отката как будто ничего не было)

Этот опыт помогает мозгу перестать воспринимать транзакцию как абстрактную штуку из учебника. Вы буквально видите: внутри транзакции изменения существуют, но они ещё не стали «официальной историей». COMMIT делает их официальными, ROLLBACK выбрасывает черновик в мусорку.

И это именно то, что нам нужно для многошаговых операций в mini-shop: пока мы не уверены, что все шаги успешны, мы работаем в режиме черновика.

10. Типичные ошибки с транзакциями

С транзакциями проблемы чаще всего возникают не потому, что человек «не знает команду COMMIT», а потому, что он неверно выбирает границы и неправильно относится к ошибкам. Поэтому давайте проговорим самые распространённые грабли в живом стиле, чтобы вы узнавали их по звуку приближающихся шагов.

Ошибка №1: полагаться на autocommit в составной операции.
Новичок видит, что INSERT и UPDATE «и так работают», и не включает транзакцию, думая, что это «для сложных систем». В результате при ошибке на шаге №3 шаги №1–2 уже подтверждены, и база хранит «полуоперацию». Лечится это ровно одним решением: как только у вас больше одной команды, которые вместе образуют один смысл, вы начинаете мыслить транзакцией.

Ошибка №2: делать commit посередине сценария «на всякий случай».
Иногда кажется логичным «зафиксировать заказ, а потом уже разбираться с остатками». Это создаёт иллюзию надёжности, но на самом деле ломает атомарность. После первого коммита вы уже не можете откатить создание заказа, даже если дальше всё упало. Правильный коммит — в конце, когда вы действительно завершили бизнес-операцию.

Ошибка №3: забывать про rollback в ветке ошибки или «глотать» исключение.
В коде это выглядит особенно коварно: вы поймали исключение, залогировали, продолжили работу, а транзакция осталась открытой или в ошибочном состоянии. В результате следующие команды начинают вести себя странно, а вы не понимаете почему. Ментальная модель здесь должна быть строгой: если операция не дошла до commit, вы обязаны сделать rollback — явно и сразу.

Ошибка №4: считать, что «ошибка SQL» — единственная причина отката.
Очень часто откат нужен не из-за падения команды, а из-за бизнес-условия. Например, UPDATE stock_item ... с условием available_quantity >= 2 может вернуть 0 обновлённых строк — это не SQL-ошибка, а корректный результат со смыслом «товара не хватило». Если вы в таком случае всё равно делаете commit, вы фиксируете заказ без списания остатков. Здесь важно привыкнуть: транзакция — это инструмент не только для аварий, но и для нормальной бизнес-логики «не выполняем сценарий».

Ошибка №5: смешивать в одну транзакцию несвязанные действия.
Иногда разработчик думает: «Раз транзакция делает всё надёжным, давайте в одну транзакцию запихнём и создание заказа, и обновление товара в каталоге, и ещё изменение категории». Получается транзакция-комбайн, в которой трудно понять смысл и которая делает систему менее предсказуемой. Транзакция должна соответствовать одной операции, а не всему, что «оказалось под рукой».

1
Задача
Spring Data JPA, 3 уровень, 3 лекция
Недоступна
Вставка заказа с последующим rollback
Вставка заказа с последующим rollback
1
Задача
Spring Data JPA, 3 уровень, 3 лекция
Недоступна
Успешная транзакция с commit
Успешная транзакция с commit
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ