1. Навіщо взагалі потрібні транзакції
Коли ви пишете програму, зазвичай хочеться, щоб дія або відбулася повністю, або не відбулася взагалі. Не «гроші списалися, але не зарахувалися», не «витрата додалася, але запис у журналі дій не зʼявився», не «користувача створено, але профіль не створено». У памʼяті програми ми часто розвʼязуємо це звичайним порядком операцій і перевірками. Але база даних живе своїм життям: це окремий світ, де команда може впасти посеред виконання, а частина змін — устигнути зберегтися.
Транзакція — це спосіб сказати базі: «Дивись, зараз буде набір команд. Вважай їх однією цілісною дією. Якщо все пройшло — зафіксуй. Якщо щось зламалося — відкоть так, ніби ми нічого не робили».
Уявіть, що ви несете дві склянки кави: одну собі, одну колезі. Якщо спіткнулися, ви б воліли або донести обидві, або (сумно) пролити обидві, але точно не ситуацію «собі доніс, а колега тепер ворог на все життя». Транзакція — це як кришечки на склянках і правило «несемо разом».
2. autoCommit: чому режим за замовчуванням заважає
Перш ніж писати «правильну» транзакцію, потрібно зрозуміти налаштування, яке за замовчуванням увімкнене майже в усіх JDBC-зʼєднаннях: autoCommit.
У JDBC для Connection є режим:
- autoCommit = true — кожен SQL-запит фіксується одразу, як окрема мінітранзакція.
- autoCommit = false — ви вручну керуєте тим, коли фіксувати зміни (commit()), а коли відкотити (rollback()).
Звучить зручно: «нехай воно саме фіксується». Але це добре лише доти, доки у вас один запит, який сам по собі й є цілісною дією. Щойно дія складається з двох і більше запитів, autoCommit = true перетворює вашу «цілісну дію» на «ланцюжок незалежних пострілів».
Зведімо все в таблицю — так легше сприймати:
| Режим | Що відбувається | Коли підходить | Головний ризик |
|---|---|---|---|
|
Кожен запит одразу зберігається | Один запит = одна дія | Частково виконані сценарії |
|
Ви самі вирішуєте, коли зберегти | Кілька запитів як одна дія | Якщо забути 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) { "Не вдалося додати витрату: inserted=$inserted1" }
val inserted2 = insertAudit(c, "Додано витрату $amount у категорії: category=$category")
check(inserted2 == 1) { "Не вдалося додати запис аудиту: inserted=$inserted2" }
}
println("Витрату збережено") // Витрату збережено
}
Тут 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) { "Сума має бути більшою за 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) { "Відправника не знайдено: 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) { "Одержувача не знайдено: 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(...) або значення, що повертаються, і вирішуйте, чи вважати це помилкою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ