JavaRush /Курсы /Kotlin SELF /Транзакции в JDBC — границы, autoCommit, commit/rollback

Транзакции в JDBC — границы, autoCommit, commit/rollback

Kotlin SELF
59 уровень , 2 лекция
Открыта

1. Зачем вообще нужны транзакции

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

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

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

2. autoCommit: почему режим по умолчанию мешает

Перед тем как писать «правильную» транзакцию, нужно понять настройку, которая по умолчанию стоит почти во всех JDBC‑соединениях: autoCommit.

В JDBC у Connection есть режим:

  • autoCommit = trueкаждый SQL‑запрос фиксируется сразу, как отдельная мини‑транзакция.
  • autoCommit = false — вы вручную управляете тем, когда фиксировать изменения (commit()), а когда откатывать (rollback()).

Звучит удобно: «пусть оно само коммитится». Но это хорошо только пока у вас один запрос, который сам по себе и является целым действием. Как только действие становится из двух и более запросов, autoCommit = true превращает ваше «цельное действие» в «цепочку независимых выстрелов».

Сведём в таблицу — так проще читать глазами:

Режим Что происходит Когда подходит Главный риск
autoCommit = true
Каждый запрос сразу сохраняется Один запрос = одно действие Частично выполненные сценарии
autoCommit = false
Вы сами решаете, когда сохранить Несколько запросов как одно действие Если забыть commit/rollback, можно зависнуть в странном состоянии

И да, «забыть commit()» — это реальная классика. Это как написать диплом, но не нажать «Сохранить». Вроде работал, но результата нет.

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

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

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

Практическое правило для новичков звучит так:

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

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

Схематично транзакция выглядит так:

flowchart TD
    A[Начали действие] --> B[autoCommit = false]
    B --> C[SQL #1]
    C --> D[SQL #2]
    D --> E{Ошибки были?}
    E -- нет --> F["commit()"]
    E -- да --> G["rollback()"]
    F --> H[autoCommit обратно / закрытие]
    G --> H[autoCommit обратно / закрытие]

Главная идея: commit только один раз, в конце. И rollback() — тоже один раз, если что-то пошло не так.

4. Базовый шаблон транзакции в JDBC

Теперь соберём основной шаблон. Он будет казаться многословным… но это тот случай, когда многословность — ваша страховка от «призраков в базе».

Ключевые элементы:

  • conn.autoCommit = false — мы берём управление на себя.
  • try { ...; conn.commit() } — выполняем все SQL‑операции и фиксируем результат.
  • catch { conn.rollback(); throw } — при любой ошибке откатываем и пробрасываем исключение (пусть вызывающий код решает, что делать).
  • finally { conn.autoCommit = ... } — возвращаем соединение в «нормальное состояние», если вы собираетесь его переиспользовать.

Кстати, finally как «гарантированная уборка» — это уже знакомая идея из работы с исключениями: код в finally выполняется независимо от того, было исключение или нет.

Мини‑пример: транзакция как функция‑обёртка

Начнём с маленькой, но полезной утилиты. Она не «магия», а просто способ не копировать один и тот же try/catch/finally по всему проекту.

import java.sql.Connection

fun <T> inTx(conn: Connection, block: (Connection) -> T): T {
    val oldAutoCommit = conn.autoCommit
    conn.autoCommit = false

    try {
        val result = block(conn)
        conn.commit()
        return result
    } catch (e: Exception) {
        conn.rollback()
        throw e
    } finally {
        conn.autoCommit = oldAutoCommit
    }
}

Обратите внимание на маленькую «паранойю»: мы сохраняем старое значение autoCommit и возвращаем именно его, а не просто ставим true. Это полезно, если соединение пришло к вам уже в нестандартном режиме (в больших приложениях такое бывает чаще, чем хочется).

5. Практика: добавляем расход и пишем аудит в одной транзакции

Теперь давайте привяжем это к нашему ExpenseTracker.

Предположим (на уровне идеи, не углубляясь в администрирование БД), что у нас есть две таблицы:

  • expenses(id, amount, category, note)
  • audit_log(id, message)

Мы хотим сделать действие «добавить расход» так, чтобы:

1) расход вставился в expenses,

2) запись добавилась в audit_log,

3) если упало на шаге 2 — шаг 1 тоже откатился.

Вставка расхода

Начнём с самой вставки расхода. Здесь нам важно помнить, что executeUpdate() возвращает количество затронутых строк — это отличный «быстрый тест», что запрос реально что-то сделал.

import java.sql.Connection

fun insertExpense(conn: Connection, amount: Long, category: String, note: String): Int {
    val sql = "INSERT INTO expenses(amount, category, note) VALUES (?, ?, ?)"

    conn.prepareStatement(sql).use { st ->
        st.setLong(1, amount)
        st.setString(2, category)
        st.setString(3, note)
        return st.executeUpdate()
    }
}

Вставка аудита

Аудит — просто запись «что произошло». В реальной жизни аудит бывает сложнее, но нам сейчас важен сам принцип «вторая операция».

import java.sql.Connection

fun insertAudit(conn: Connection, message: String): Int {
    val sql = "INSERT INTO audit_log(message) VALUES (?)"

    conn.prepareStatement(sql).use { st ->
        st.setString(1, message)
        return st.executeUpdate()
    }
}

Объединяем в транзакцию

Теперь связываем две операции в одну транзакцию с помощью inTx.

import java.sql.Connection

fun addExpenseWithAudit(conn: Connection, amount: Long, category: String, note: String) {
    inTx(conn) { c ->
        val inserted1 = insertExpense(c, amount, category, note)
        check(inserted1 == 1) { "Expense insert failed: inserted=$inserted1" }

        val inserted2 = insertAudit(c, "Added expense $amount in category=$category")
        check(inserted2 == 1) { "Audit insert failed: inserted=$inserted2" }
    }

    println("Expense saved") // Expense saved
}

Здесь check(...) — это наш «стоп‑кран»: если ожидали 1 строку, а получили 0, значит что-то пошло не так (например, таблицы нет, ограничения не прошли, или запрос неверный). Мы считаем это ошибкой, выбрасываем исключение, а inTx автоматически сделает rollback().

6. Почему в транзакции нельзя «поймать ошибку и продолжить»

Иногда новичок видит исключение и думает: «Сейчас я его поймаю, напечатаю println("ой"), и всё будет нормально». Проблема в том, что транзакция — это не место, где можно делать вид, что ничего не произошло.

Если внутри транзакции случилась ошибка, у вас почти всегда только два честных варианта:

  • откатить и сообщить, что операция не выполнена;
  • откатить и попробовать заново (но это уже отдельная стратегия и обычно требует аккуратного дизайна).

Смысл catch в транзакционном шаблоне не в том, чтобы «поглотить» ошибку, а в том, чтобы гарантированно выполнить rollback() и затем дать ошибке подняться выше.

И здесь снова вспоминаем знакомую конструкцию: try/catch/finally — это не только обработка ошибок, но и механизм гарантированного выполнения «уборки» через finally.

7. Пример: перевод денег как две связанные операции

С расходами всё хорошо, но иногда хочется пример, который интуитивно «болит», если выполнить наполовину. Перевод денег — классика.

Пусть у нас есть таблица accounts(id, balance). Перевод — это:

1) уменьшить баланс from,

2) увеличить баланс to.

Если между ними упасть — вы либо «потеряли деньги», либо «создали деньги». Обе ситуации интересны… но обычно только для тестов, а не для продакшена.

import java.sql.Connection

fun transfer(conn: Connection, fromId: Long, toId: Long, amount: Long) {
    require(amount > 0) { "Amount must be > 0" }

    inTx(conn) { c ->
        val dec = c.prepareStatement(
            "UPDATE accounts SET balance = balance - ? WHERE id = ?"
        ).use { st ->
            st.setLong(1, amount)
            st.setLong(2, fromId)
            st.executeUpdate()
        }

        check(dec == 1) { "Sender not found: id=$fromId" }

        val inc = c.prepareStatement(
            "UPDATE accounts SET balance = balance + ? WHERE id = ?"
        ).use { st ->
            st.setLong(1, amount)
            st.setLong(2, toId)
            st.executeUpdate()
        }

        check(inc == 1) { "Receiver not found: id=$toId" }
    }
}

Да, тут есть нюанс «а вдруг у отправителя денег не хватает», но это уже логика приложения (и/или ограничения на уровне БД). В рамках этой лекции нам важнее увидеть транзакционный каркас: два UPDATE как одно действие.

8. Связь с use {}: закрываем ресурсы даже при откате

Очень легко сделать ошибку: «Если у нас транзакция, то use {} уже не нужен». Нужен, и даже больше, чем раньше.

Логика простая: транзакция отвечает за согласованность данных, а use {} отвечает за закрытие ресурсов (PreparedStatement, ResultSet, иногда и самого Connection). Это разные гарантии, обе важны.

В Kotlin use {} по смыслу похоже на «try/finally, где в finally закрывают ресурс». То есть даже если внутри случилась ошибка, ресурс будет закрыт. Это та же философия гарантированной уборки, что и finally.

Отсюда хороший стиль: внутри транзакции все statements и result sets закрываются через use {}, а снаружи транзакции (или в обёртке inTx) вы управляете commit()/rollback().

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

Ошибка №1: оставили autoCommit = false и забыли вернуть обратно.
Это одна из самых «злых» проблем, потому что проявляется не сразу. Первый запрос вы сделали «вручную», а потом какой-нибудь другой кусок кода внезапно тоже начал работать в ручном режиме и стал требовать commit(). Если соединение переиспользуется, обязательно возвращайте autoCommit в finally. Самый спокойный вариант — запоминать старое значение и восстанавливать его, как мы сделали в inTx.

Ошибка №2: commit() вызывается слишком рано.
Иногда встречается код: сделали первый UPDATE, сразу commit(), потом второй UPDATE, и снова commit(). Это превращает транзакцию в два независимых действия и возвращает нас к проблеме «наполовину». В нормальном дизайне commit() должен быть один — в конце, когда все связанные операции прошли успешно.

Ошибка №3: в catch забыли сделать rollback().
Поймать исключение и не откатить — значит оставить соединение в подвешенном состоянии, где изменения уже частично «висят», но не зафиксированы. Дальше начинается сериал «Почему база ведёт себя странно, а я ничего такого не делал». Правило простое: если вы вручную выключили autoCommit, то любая ошибка до commit() почти всегда должна вести к rollback().

Ошибка №4: ловим исключение и «глушим» его, делая вид, что всё хорошо.
Иногда код делает catch (e: Exception) { rollback(); println(...) } и продолжает выполнение, как будто операция состоялась. Это опасно: вызывающий код думает, что всё хорошо, а на самом деле ничего не сохранилось. Если вы не проектируете явный результат операции (например, Boolean или sealed class), то исключение лучше пробросить выше после rollback(). Механика try/catch/finally как раз позволяет это делать аккуратно.

Ошибка №5: транзакция слишком широкая и включает «всё подряд».
Если внутрь транзакции вы кладёте не только SQL, но ещё и ввод пользователя, печать меню, сетевые запросы и разбор текста — вы держите транзакцию дольше, чем нужно. Даже если «в учебном SQLite всё работает», привычка плохая. Транзакция должна быть короткой: SQL‑операции + минимальные проверки результата.

Ошибка №6: не проверяют результат executeUpdate() и коммитят «в пустоту».
Команда UPDATE ... WHERE id = ? может обновить 0 строк, если такого id нет. Если вы потом делаете commit(), формально всё «успешно», но бизнес‑смысл провален. Проверяйте числа изменённых строк через check(...) или возвращаемые значения и решайте, считать ли это ошибкой.

1
Задача
Kotlin SELF, 59 уровень, 2 лекция
Недоступна
Переключатель режима
Переключатель режима
1
Задача
Kotlin SELF, 59 уровень, 2 лекция
Недоступна
Двойная запись
Двойная запись
1
Задача
Kotlin SELF, 59 уровень, 2 лекция
Недоступна
Транзакция утилита
Транзакция утилита
1
Задача
Kotlin SELF, 59 уровень, 2 лекция
Недоступна
Банковский перевод
Банковский перевод
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ