JavaRush /Курсы /Kotlin SELF /Доступ к БД в корутинах: Di...

Доступ к БД в корутинах: Dispatchers . IO

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

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().useprepareStatement(...).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 и аккуратные границы остаются обязательными.

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