Kotlin Корутины + БД connection pool. Как не получить каскадное падение

от автора

Вспомним, что такое IO-dispatcher в Kotlin корутинах.

IO-dispatcher — это планировщик, который использует пул потоков, оптимизированный под блокирующие IO операции. Верхний лимит по размеру — max(64, число ядер)

IO-пул держит 64+ потоков, и корутина отпускает поток только тогда, когда под капотом неблокирующая операция — тогда она suspend’ится.

Если же внутри suspend-функции прячется JDBC, синхронный HTTP-клиент или Thread.sleep, поток занят до конца вызова — корутинная обёртка тут ничего не меняет

Представим типичное backend-приложение на Kotlin:

API -> бизнес-логика -> Connection Pool (Hikari) -> DB

Если говорить в реалиях Kotlin, то обращение к блокирующим внешним ресурсам: БД и иным сервисам — часто выполняется в отдельном IO coroutine dispatcher.

Допустим, что все эти цепочки идут в параллель и для обращения к сервисам мы используем неблокирующий http client.

API -> бизнес-логика -> IO dispatcher -> Connection Pool(Hikari) -> DBAPI -> бизнес-логика -> Service (non-blocking, без IO dispatcher)API -> бизнес-логика -> IO dispatcher -> File IO / блокирующий gRPC

Под небольшой нагрузкой приложение работает, всё хорошо. Но что, если у нас много обращений к БД в секунду?

Арифметика насыщения

Если connection pool равен 10, а среднее время удержания connection — 20мс, предельная пропускная способность:
10 / 0.02 = 500 req/s

На практике из-за дисперсии времени удержания очередь начинает заметно расти уже при утилизации ~70–80% (классический результат из queueing theory), то есть около:
500 × 0.75 ≈ 375 req/s
Дальше connection’ы регулярно держатся дольше 20мс, и очередь ожидающих начинает накапливаться внутри Hikari.

А если у сервиса высокий пользовательский трафик, фоновые задачи и время удержания connection больше 20мс, то rps к БД набираются легко.

Финальный каскад

Обращения к БД через JDBC блокирующие, а идут они через IO dispatcher. Ожидание свободного коннекта в Hikari — это тоже блокировка потока: корутина висит внутри getConnection() и не suspend’ится.

В результате, чем длиннее очередь в Hikari, тем больше потоков IO-dispatcher’а одновременно заняты этим ожиданием. Если деградация БД продолжается — весь IO-пул оказывается занят ожиданием коннектов.

Каскад: БД замедлилась → забился connection pool → ожидающие коннекта корутины выедают IO-пул → любой другой блокирующий код, которому нужен IO dispatcher (файлы, блокирующий gRPC, другие БД), встаёт в очередь и деградирует вместе с БД-вызовами.

Неблокирующие вызовы при этом продолжают работать — они не используют IO dispatcher. Но это слабое утешение, если половина бизнес-сценариев ходит в БД.

Как избежать?

Чтобы не получить каскадное падение, нужно ограничить параллелизм кода, который ходит в БД, размером самого пула коннектов. Это реализация bulkhead pattern: очередь формируется на уровне корутин (дешёвых) до попытки взять коннект, а общий IO-пул остаётся свободен для всего остального кода.

// где-то на уровне приложения / DIval jdbcDispatcher = Dispatchers.IO.limitedParallelism(hikariMaxPoolSize)// или отдельный пул потоковval jdbcDispatcher = Executors    .newFixedThreadPool(hikariMaxPoolSize)    .asCoroutineDispatcher()// использованиеsuspend fun req() = withContext(jdbcDispatcher) {block()}

Но стоит учесть, что limitedParallelism шарит общий IO-пул (может поднимать потоки сверх 64, то есть не откусывает от основного IO), а newFixedThreadPool — создаёт отдельный.
Будет отлично, если размер dispatcher’а совпадает с maximumPoolSize у Hikari — больше лучше не брать (коннектов всё равно не хватит), меньше — недоиспользуем пул.

ссылка на оригинал статьи https://habr.com/ru/articles/1026058/