1. Чому JDBC блокує в корутинах
Коли люди вперше чують «корутини», мозок часто домальовує зайве: ніби корутина — це невидимий шолом, який робить будь-який код «асинхронним автоматично». Хотілося б, але ні. Якщо JDBC‑драйвер виконує запит синхронно, потік справді чекає на відповідь від БД, а корутина в цей момент просто займає потік — як кіт займає клавіатуру.
Ідея корутин у Kotlin в тому, що вони дають змогу писати асинхронний код як послідовний. А ще — призупиняти виконання без блокування системних ресурсів, коли операція справді підтримує призупинення. Та більшість «звичайних» JDBC‑операцій — блокувальні: доки база не відповість, потік зайнятий і нікуди не зникає. Тому корутини самі по собі проблему не розвʼязують. Її розвʼязує правильний вибір диспетчера та межа, де ми виконуємо блокувальну роботу.
Диспетчери корутин і Dispatchers.IO
Щоб не плутатися, корисно думати так: корутина — це «завдання», а диспетчер — це «де й на яких потоках це завдання виконується». У kotlinx.coroutines корутина має контекст, і один із ключових елементів цього контексту — CoroutineDispatcher. Саме він визначає, на яких потоках виконуватиметься корутина.
Якщо ви виконуєте блокувальний JDBC‑запит на Dispatchers.Default, ви ризикуєте «забити» обмежений пул потоків. Здебільшого він призначений для CPU‑роботи (обчислень, оброблення колекцій, форматування тощо). А JDBC — це очікування I/O. Тож йому логічніше жити на Dispatchers.IO, який розрахований на блокувальні операції введення-виведення (файли, мережа, БД).
Уявімо картинку, яку корисно тримати в голові:
flowchart LR A[Корутина на Default] -->|JDBC-запит| B[Потік блокується] B --> C[Пул Default зайнятий] C --> D[Інші корутини чекають] E[Корутина на IO] -->|JDBC-запит| F[Потік блокується] F --> G[Пул IO обробляє блокування] G --> H[Пул Default лишається вільним]
Ідея проста: ви не робите JDBC «неблокувальним». Ви лише переносите блокування туди, де воно менше шкодить решті програми.
2. withContext(Dispatchers.IO) і межа шару зберігання
Коли ми кажемо «винести блокувальне в IO», на практиці найчастіше маємо на увазі один конкретний шаблон: обгортаємо блокувальний код у withContext(Dispatchers.IO) { ... }. Це виглядає як «перемикання режиму виконання» для фрагмента. Зовні у вас може бути будь-який контекст (наприклад, Default), але всередині блока код гарантовано виконається на IO‑диспетчері.
Важливо розуміти філософію: withContext не перетворює JDBC на магічний неблокувальний драйвер. Він просто каже: «цю частину роботи виконуй в іншому пулі потоків». Це добре поєднується з тим, що корутини в Kotlin спроєктовані навколо контексту виконання та диспетчера, який керує тим, де саме виконується завдання.
Мініобгортка, яку дуже люблять писати в проєктах, щоб не розмазувати Dispatchers.IO по всьому коду:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun <T> dbIo(block: () -> T): T =
withContext(Dispatchers.IO) { block() }
Зверніть увагу: блок тут звичайна лямбда () -> T, тому що JDBC‑виклики зазвичай не suspend. Ми не «чекаємо корутинно» — ми справді виконуємо синхронний код, просто на іншому диспетчері.
Де має жити Dispatchers.IO
Дуже хочеться зробити так: «у main перед викликом репозиторію обгорну все в withContext(IO)». На маленькому проєкті це навіть працюватиме. Але з погляду архітектури це швидко перетворюється на кашу: ви забудете обгорнути один виклик — і раптом частина запитів піде на Default. Потім ви додасте ще один сценарій — і вже не будете впевнені, де у вас IO, а де ні.
Тому вводимо доволі нудне, але дуже корисне правило: перемикання на Dispatchers.IO живе в шарі зберігання (repository/DAO), а не в CLI й не в бізнес-логіці. Це і є «межа шару зберігання»: увесь зовнішній код сприймає репозиторій як «чорний ящик», який сам уміє правильно працювати з БД.
Мініконтракт репозиторію для нашого консольного застосунку обліку витрат:
import kotlinx.datetime.Instant
data class Expense(
val id: Long,
val title: String,
val amountCents: Long,
val createdAt: Instant,
)
interface ExpenseRepository {
suspend fun add(title: String, amountCents: Long): Long
suspend fun listLatest(limit: Int): List<Expense>
}
Ззовні це виглядає просто: suspend‑методи, жодних JDBC‑типів, жодних Connection. І це чудово: зовнішній код не має знати, яким ключем ви відкриваєте SQLite й скільки болю в ResultSet.
3. Транзакції та контексти
Коли ви тільки починаєте використовувати withContext(IO), зʼявляється дивна помилка мислення: «обгорну кожен SQL‑запит окремо — буде ще безпечніше». І ось тут ми ловимо тонкий момент: транзакція — це рамка атомарності, і вона має виконуватися як цілісний шматок.
Якщо ви почнете робити так: «перший запит у withContext(IO), потім повернувся назовні, потім другий запит знову в withContext(IO)», ви фактично розриваєте транзакційний сценарій і робите код менш прозорим. Технічно транзакція на JDBC живе на Connection. І доки ви не закрили Connection та не зробили commit/rollback, усе ще можна тримати в одному місці — і саме так варто робити.
Тому правило формулюється дуже практично: якщо операція потребує транзакції, то і транзакція, і всі запити всередині неї мають повністю виконуватися всередині одного withContext(Dispatchers.IO).
Ось шаблон «транзакція як helper» — коротко й читабельно:
import java.sql.Connection
inline fun <T> inTx(conn: Connection, block: () -> T): T {
conn.autoCommit = false
try {
val result = block()
conn.commit()
return result
} catch (e: Exception) {
conn.rollback()
throw e
} finally {
conn.autoCommit = true
}
}
Зверніть увагу: це не suspend‑функція, тому що JDBC‑виклики всередині неї синхронні. suspend у нас буде зовні — на рівні репозиторію, де ми обгорнемо все в withContext(IO).
4. Практичний приклад Expense Tracker
Щоб приклади не були набором окремих уривків, домовимося про мінімальну структуру проєкту. Ми не будуємо «ідеальний enterprise», але робимо все достатньо акуратно, щоб код не соромно було відкрити за тиждень.
Схема залежностей (памʼятаємо ідею архітектурних шарів: CLI не має залежати від JDBC напряму):
flowchart TD CLI["CLI (main, команди)"] --> S["Service (сценарії)"] S --> R["ExpenseRepository (interface)"] R --> J["JdbcExpenseRepository (impl)"] J --> DB[(SQLite / DB)]
Сервіс без JDBC і без Dispatchers.IO
Тепер накидаємо сервіс, який «говорить людською мовою», а не SQL‑термінами:
class ExpenseService(
private val repo: ExpenseRepository,
) {
suspend fun addExpense(title: String, amountCents: Long): Long {
require(title.isNotBlank()) { "title must not be blank" }
require(amountCents > 0) { "amount must be positive" }
return repo.add(title.trim(), amountCents)
}
}
Сервіс нічого не знає про Dispatchers.IO. Він просто викликає suspend‑репозиторій. А вже репозиторій відповідає за те, «де саме це виконується».
JDBC‑репозиторій з withContext(IO) усередині
Зараз зробимо найважливіше: реалізацію репозиторію, яка (1) працює з JDBC, (2) закриває ресурси, (3) гарантує IO‑диспетчер.
Почнемо з маленького «постачальника зʼєднань». У реальних проєктах згодом зʼявляються пули зʼєднань, але сьогодні ми цього свідомо не чіпаємо. Нам важливіші дисципліна шарів і контекст виконання.
import java.sql.Connection
import java.sql.DriverManager
class DbConnectionFactory(
private val url: String,
) {
fun open(): Connection = DriverManager.getConnection(url)
}
Тепер реалізація репозиторію. Спочатку — add(...). Зверніть увагу на три рівні вкладеності: withContext(IO) → open().use → prepareStatement(...).use.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.Statement
class JdbcExpenseRepository(
private val connections: DbConnectionFactory,
) : ExpenseRepository {
override suspend fun add(title: String, amountCents: Long): Long =
withContext(Dispatchers.IO) {
connections.open().use { conn ->
val sql = "INSERT INTO expenses(title, amount_cents) VALUES (?, ?)"
conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS).use { st ->
st.setString(1, title)
st.setLong(2, amountCents)
st.executeUpdate()
st.generatedKeys.use { rs ->
rs.next()
rs.getLong(1)
}
}
}
}
}
Тут є один «дорослий» момент: generatedKeys і rs.next() — не забудьте, що ResultSet стоїть «до першого рядка». Це не корутинна проблема, це чистий JDBC‑ритуал.
Тепер — listLatest(limit). Ми дістаємо рядки, мапимо їх у Expense і повертаємо список:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant
override suspend fun listLatest(limit: Int): List<Expense> =
withContext(Dispatchers.IO) {
connections.open().use { conn ->
val sql = """
SELECT id, title, amount_cents, created_at
FROM expenses
ORDER BY id DESC
LIMIT ?
""".trimIndent()
conn.prepareStatement(sql).use { st ->
st.setInt(1, limit)
st.executeQuery().use { rs ->
val items = mutableListOf<Expense>()
while (rs.next()) {
items += Expense(
id = rs.getLong("id"),
title = rs.getString("title"),
amountCents = rs.getLong("amount_cents"),
createdAt = Instant.parse(rs.getString("created_at")),
)
}
items
}
}
}
}
Так, Instant.parse(...) може викинути виняток, якщо в БД лежить «брудний» рядок. Це нормально: шар зберігання або гарантує коректність формату (через схему/валідацію), або чесно падає. Сьогодні ми не перетворюємо це на окремий дизайн помилок — просто не ховаємо проблему під килим.
Виклик із main
У консольних застосунках найчастіша конструкція — runBlocking у main. JVM очікує звичайну точку входу, а ви хочете викликати suspend‑функції. Важливо, що runBlocking — це міст між «звичайним світом» і «світом suspend».
Водночас дуже важливо не ліпити withContext(IO) навколо кожного виклику репозиторію в main. Інакше ви ламаєте саму ідею межі шару зберігання.
Мініприклад «зібрали залежності й викликали сервіс»:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val factory = DbConnectionFactory("jdbc:sqlite:app.db")
val repo: ExpenseRepository = JdbcExpenseRepository(factory)
val service = ExpenseService(repo)
val id = service.addExpense("Coffee", 350)
println("Added expense id=$id") // Added expense id=1
}
main лишається чистим: він не знає про JDBC і не знає, які диспетчери використовуються всередині. Він знає лише «є сервіс, є репозиторій, є результат».
5. Exposed у корутинах
Exposed інколи створює ілюзію, що «раз це DSL, значить воно якось хитро оптимізує потоки». Але Exposed усе одно ходить у базу через JDBC «під капотом», а отже, з погляду потоків це все та сама блокувальна операція. Тому правило про Dispatchers.IO нікуди не зникає.
Схема стає навіть простішою: ми не працюємо руками з Connection, зате обгортаємо transaction { ... } в IO‑контекст повністю.
Припустімо, у вас уже є таблиця Users/Expenses із минулої лекції. Тоді корутинний доступ виглядає так:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
suspend fun addExpenseExposed(title: String, amountCents: Long): Long =
withContext(Dispatchers.IO) {
transaction {
Expenses.insert {
it[Expenses.title] = title
it[Expenses.amountCents] = amountCents
} get Expenses.id
}.value
}
Зверніть увагу на сенс: у withContext(IO) ми тримаємо всю транзакцію. Це той самий принцип «не рвати транзакцію», тільки в Exposed він виглядає ще логічніше: транзакція — один блок.
І ще одна важлива думка: якщо всередині transaction { } ви почнете робити багато неповʼязаної логіки (парсинг, читання файлів, мережеві виклики), ви збільшуєте час утримання транзакції. Тому всередині транзакції тримаємо лише те, що стосується цілісності даних, а все інше — зовні.
6. Помилки та винятки
У цей момент новачкам хочеться зробити «щоб не падало»: обгорнути все в try/catch усередині репозиторію та повернути, наприклад, порожній список. Це виглядає як турбота про користувача, але на практиці лише маскує проблему. Коли шар зберігання мовчки проковтує виняток, зовнішній код продовжує працювати з неправильними припущеннями: «у БД просто немає витрат», хоча насправді «файл БД недоступний» або «таблицю не створено».
Набагато чесніше дотримуватися одного з двох стилів. Або репозиторій пробросує винятки назовні (а сценарій/CLI вирішує, як повідомити користувачу), або репозиторій повертає явний результат «успіх/помилка» у вигляді value‑типу. Сьогодні, у межах лекції, ми лишаємося в першому стилі: виняток не ховаємо, ресурси закриваємо через use { } (вони закриються навіть за винятку), а транзакції відкочуємо через rollback() у шаблоні.
Мініприклад: сценарій верхнього рівня ловить виняток і виводить нормальне повідомлення, не перетворюючи «помилка диска» на «у вас 0 витрат»:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
try {
// ... виклики сервісів
} catch (e: Exception) {
println("Database error: ${e.message}") // Database error: ...
}
}
Так, це не ідеальне опрацювання помилок (ми не робимо типізацію помилок і не логуємо stack trace красиво), але це вже чесна модель: якщо БД зламалася — ми не робимо вигляд, що все гаразд.
7. Типові помилки під час доступу до БД у корутинах
Помилка №1: виконувати JDBC/Exposed на Dispatchers.Default «бо й так працює».
Це одна з найпідступніших проблем: у маленькому застосунку ви не побачите біди, але щойно зʼявиться кілька паралельних операцій (або просто повільний диск/мережа), Default почне простоювати в блокуваннях. А Default потрібен для обчислень, а не для очікування I/O. Саме тому існує ідея диспетчера як частини coroutine context.
Помилка №2: розмазувати withContext(Dispatchers.IO) по всьому коду замість шару зберігання.
Спочатку це виглядає «швидко й зручно»: обгорнули один виклик у CLI — і готово. За тиждень ви забудете обгорнути другий, за два — зʼявиться третій сценарій, і ви більше не зможете відповісти на запитання «а всі запити точно в IO?». Межа шару зберігання потрібна рівно для того, щоб ця гарантія була централізованою.
Помилка №3: обгортати в withContext(IO) кожен окремий запит, розриваючи транзакційний сценарій.
Якщо операція логічно має бути атомарною, тримайте її як один блок: один withContext(IO) навколо всієї транзакції. Коли транзакцію розірвано на шматки, код стає не лише менш ефективним, а й гірше читається: губиться відчуття «ось тут починається атомарна операція, ось тут закінчується».
Помилка №4: ловити Exception усередині репозиторію й зводити все до println, щоб «не падало».
Такий репозиторій перетворюється на діру, яка «зʼїдає» помилки. Зовнішній код перестає розуміти, що відбувається: «даних немає» та «не вдалося підʼєднатися до БД» стають нерозрізнюваними. Краще або пробросувати виняток, або повертати явний результат‑помилку, але не вдавати, що все гаразд.
Помилка №5: тримати Connection або transaction { } занадто довго, змішуючи це з неповʼязаною логікою.
Іноді транзакція випадково починає включати в себе введення користувача, форматування, читання файла, обчислення — і це розтягує час утримання ресурсів БД. Навіть якщо ви поки не вивчаєте рівні ізоляції та конкуренцію транзакцій, корисна звичка одна: транзакція має бути компактною й містити лише те, що справді стосується узгодженості даних.
Помилка №6: забути, що корутини не «роблять код неблокувальним автоматично».
Корутину можна запустити, але якщо всередині ви викликаєте блокувальний JDBC‑метод, потік однаково буде зайнятий очікуванням. Корутинна модель обіцяє зручний стиль коду й керування контекстом виконання, але не скасовує фізику I/O. Тому Dispatchers.IO та акуратні межі залишаються обовʼязковими.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ