1. Почему JDBC блокирует в корутинах
Когда люди впервые слышат «корутины», мозг часто дорисовывает лишнее: будто корутина — это такой невидимый шлем, который делает любой код «асинхронным автоматически». Хотелось бы, но нет. Если JDBC‑драйвер выполняет запрос синхронно, то поток действительно ждёт ответ от БД, а корутина в этот момент просто занимает поток — как кот занимает клавиатуру.
Сама идея корутин в Kotlin в том, что они позволяют писать асинхронный код как последовательный и приостанавливать выполнение без блокировки системных ресурсов, когда операция реально поддерживает приостановку. Но большинство «обычных» JDBC‑операций — блокирующие: пока база не ответит, поток занят и никуда не девается. Поэтому корутины сами по себе проблему не решают — её решает правильный выбор диспетчера и границ, где мы выполняем блокирующую работу.
Диспетчеры корутин и Dispatchers.IO
Чтобы не путаться, полезно думать так: корутина — это «задача», а диспетчер — это «где и на каких потоках эта задача выполняется». В kotlinx.coroutines у корутины есть контекст, и один из ключевых элементов этого контекста — CoroutineDispatcher, который как раз определяет, на каких потоках будет выполняться корутина.
Если вы выполняете блокирующий JDBC‑запрос на Dispatchers.Default, вы рискуете «забить» ограниченный пул потоков, который в основном предназначен для CPU‑работы (подсчётов, обработки коллекций, форматирования и т.д.). А JDBC — это ожидание I/O, и ему логичнее жить на Dispatchers.IO, который рассчитан на блокирующие операции ввода‑вывода (файлы, сеть, БД).
Представим картинку, которую полезно держать в голове:
flowchart LR A[Coroutine on Default] -->|JDBC query| B[Thread blocks] B --> C[Default pool is busy] C --> D[Other coroutines wait] E[Coroutine on IO] -->|JDBC query| F[Thread blocks] F --> G[IO pool handles blocking] G --> H[Default pool stays free]
Идея простая: вы не делаете JDBC «неблокирующим», вы просто переносите блокировку туда, где она меньше вредит остальной программе.
2. withContext(Dispatchers.IO) и граница слоя хранения
Когда мы говорим «вынести блокирующее в IO», на практике чаще всего имеем в виду один конкретный шаблон: оборачиваем блокирующий код в withContext(Dispatchers.IO) { ... }. Это выглядит как «переключение режима выполнения» для фрагмента: снаружи у вас может быть любой контекст (например, Default), но внутри блока код гарантированно выполнится на IO‑диспетчере.
Важно понимать философию: withContext не превращает JDBC в волшебный non‑blocking драйвер. Он просто говорит: «эту часть работы выполняй на другом пуле потоков». Это хорошо сочетается с тем, что корутины в 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 и аккуратные границы остаются обязательными.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ