Всем привет, меня зовут Сергей Прощаев. В этой статье я расскажу про JDBC.
Казалось бы, тема старая как мир. Любой Java‑разработчик, даже джуниор, с лёгкостью напишет DriverManager.getConnection(), выполнит простой SELECT и закроет всё в finally. И будет... неправ. Точнее, код‑то выполнится, но в продакшене такой подход ляжет на первых же 50 RPS.
Не первый год работаю с Java в FinTech и пересмотрел десятки проектов, где, казалось бы, простая работа с базами данных превращалась в непредвиденные сложности: падения по таймаутам, пустые Connection Pool'ы, нечитаемый код и блокировки таблиц из‑за забытых транзакций. JDBC — это не просто мост к базе, это целый полигон для скрытых граблей.
Давайте разберём, как правильно строить работу с JDBC. Не на учебных примерах «всё в одном классе», а с точки зрения продакшен‑стандартов. Поговорим про производительность, про то, как не убить базу глупыми запросами в цикле, и про шаблоны, которые реально используют опытные команды. А в конце покажу, где грань между «я знаю JDBC» и «я умею проектировать работу с данными».
Тестовое задание
Представьте, что вам на собеседовании дают задание: «Написать модуль работы с пользователями. Использовать JDBC, PostgreSQL. Функции: добавление, получение по id, получение всех, удаление».
Звучит просто? Это ловушка. Кандидат лепит UserDao с пятью методами, в каждом открывает соединение, выполняет запрос и закрывает. Всё работает. Но если бы это был реальный проект, меня бы такой код заставили переписывать.
Почему? Давайте разбираться на реальных примерах, как нужно и как не нужно.
Уровень 1: Соединение — это святое
Самая частая ошибка новичков — работа через DriverManager напрямую и открытие соединения на каждый запрос.
Как делать нельзя:
public User findById(Long id) throws SQLException { try (Connection conn = DriverManager.getConnection(URL, USER, PASS); PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) { ps.setLong(1, id); ResultSet rs = ps.executeQuery(); if (rs.next()) { return mapUser(rs); } } return null; }
На первый взгляд, код чисты. try‑with‑resources, всё закрывается. Но что здесь плохого? Установка соединения с базой — это чудовищно дорогая операция. Там и сетевая задержка, и handshake, и аутентификация. Если у вас 1000 запросов в секунду, база просто захлебнётся поднимать и разрывать соединения.
В продакшене уже 20 лет используют пулы соединений (Connection Pool). Это как такси: вы не гоняете машину из гаража каждый раз, а берёте свободную со стоянки.
Правильный подход:Используем HikariCP (стандарт де‑факто сегодня). Конфигурация пула выносится отдельно.
public class DataSourceProvider { private static HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb"); config.setUsername("user"); config.setPassword("pass"); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); dataSource = new HikariDataSource(config); } public static DataSource getDataSource() { return dataSource; } }
А в коде DAO мы теперь работаем не с DriverManager, а берём соединение из пула. Скорость взлетает колоссально.
Уровень 2: Statement или PreparedStatement?
Здесь, казалось бы, ответ знает каждый второй: PreparedStatement защищает от SQL‑инъекций. Верно. Но есть и другая сторона — производительность.
В большинстве СУБД (PostgreSQL, Oracle и др.) PreparedStatement позволяет базе кешировать план запроса. Если вы 1000 раз вставите данные через один и тот же PreparedStatement (меняя параметры), база не будет каждый раз заново парсить запрос и строить план. Это экономит CPU на базе.
Практический совет: всегда используйте PreparedStatement для любых запросов, даже если параметров нет. Это дисциплинирует код и исключает риски.
Уровень 3: ResultSet и его подводные камни
Просто получить данные мало. Их нужно правильно прочитать и закрыть. Частая ошибка — передача ResultSet куда‑то наружу и попытка читать его после закрытия соединения.
Все мы знаем, что ResultSet связан с соединением и стейтментом. Если соединение закрыто (возвращено в пул), читать из ResultSet уже нельзя.
Интересный кейс из опыта: Однажды мы писали генератор отчётов. Разработчик решил, что круто будет собрать все ID в ArrayList, а потом для каждого ID делать отдельный запрос к другой таблице. Получился классический цикл запросов. Время выполнения отчёта — 15 минут.
Мы переписали это на один JOIN и выборку всего одним запросом. Время упало до 3 секунд.
Почему? Потому что сетевые round‑trip'ы между приложением и базой — источник потери времени. Делайте один запрос, получайте всё сразу.
Уровень 4: Batch-обработка — спасаем базу от смерти
Представьте, что вам нужно вставить 10 000 записей в таблицу. Если делать это по одной:
for (User user : users) { try (PreparedStatement ps = conn.prepareStatement(INSERT_SQL)) { // set parameters ps.executeUpdate(); } }
..вы сделаете 10 000 сетевых вызовов и 10 000 транзакций (если авто‑коммит включён). Это убьёт производительность.
JDBC поддерживает пакетную вставку:
conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(INSERT_SQL)) { for (User user : users) { ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); ps.addBatch(); // Добавляем в пакет } int[] results = ps.executeBatch(); // Один сетевой вызов! conn.commit(); } catch (SQLException e) { conn.rollback(); } finally { conn.setAutoCommit(true); }
Это даёт колоссальный прирост скорости. Мы отправляем на сервер базы сразу пачку данных, база обрабатывает их как единый блок. В реальных проектах я видел ускорение в 50–100 раз.
Визуализация: Как выглядит жизнь без и с Batch
Давайте посмотрим на диаграмму последовательности, которая показывает разницу между поочерёдной вставкой и пакетной. Воспользуемся редактором Mermaid и создадим диаграмму, изображенную на рис. 1.

Уровень 5: Транзакции — явно и осознанно
Авто‑коммит — удобная штука для учебных примеров, но не применима для продакшена. Если у вас несколько операций, которые должны выполниться вместе (всё или ничего), авто‑коммит приведёт к тому, что часть изменений сохранится, а часть — нет.
Правило: берите управление транзакциями в свои руки. Установите setAutoCommit(false), выполняйте несколько операций, затем commit(). В случае ошибки — rollback().
И не держите транзакции открытыми долго. Транзакция = блокировки в базе.
Уровень 6: Работа с большими данными
Если ваш запрос может вернуть миллион строк, не валите их все в память. Используйте setFetchSize() на Statement, чтобы курсор базы данных подкачивал строки пачками.
statement.setFetchSize(1000); // Читать по 1000 строк за раз ResultSet rs = statement.executeQuery("SELECT * FROM huge_table"); while (rs.next()) { // Обрабатываем, память не переполняется }
Без этого драйвер может попытаться загрузить все строки сразу в память клиента, что приведёт к OutOfMemoryError.
Реальный кейс: как мы ускорили ETL-процесс в 30 раз
Расскажу историю из практики. Был проект по интеграции с внешней системой. Каждую ночь приходил CSV‑файл на 2 млн записей. Старый код работал так: открывался файл, для каждой строки делался SELECT, чтобы проверить, есть ли запись, и затем либо UPDATE, либо INSERT. Все это занимало по времени более 5 часов. Сервер перегревался, база нагружалась под 100%.
Мы применили несколько приёмов:
Отказ от цикличных запросов. Мы использовали временную таблицу, загружали туда весь CSV через COPY (это не JDBC, но тоже полезно знать), а затем делали один MERGE (aka UPSERT).
Пакетная обработка. Если без цикла нельзя, мы использовали executeBatch().
Оптимизация планов. Для оставшихся запросов мы использовали PreparedStatement, чтобы планы кешировались.
Увеличение fetch size. Там, где были выборки, мы читали данные курсором.
Итог: время выполнения упало с 5 часов до 15 минут.
Что ещё нужно знать? (NFR и метрики)
Сильный разработчик не остановится на функционале. Он подумает:
Таймауты: Что, если база зависла? У нас есть connectionTimeout и socketTimeout в пуле.
Мониторинг: Сколько соединений сейчас используется? Сколько запросов в очереди? HikariCP отдаёт метрики через Micrometer.
Логирование медленных запросов: Нужно настроить в драйвере или на уровне базы, чтобы видеть запросы, выполняющиеся дольше 100 мс.
Пул vs. База: Размер пула не должен превышать количество ядер БД * 2. Формула «чем больше, тем лучше» не работает, больше соединений = больше контекста = медленнее.
Заключение: JDBC — это фундамент
JDBC кажется низкоуровневым и не модным (все говорят про JPA и Hibernate). Но любой ORM в конечном счёте генерирует JDBC‑код. И если вы не понимаете, как работают соединения, batch'и и транзакции на уровне JDBC, Hibernate для вас останется чёрным ящиком, который «почему‑то тормозит».

Научиться проектировать эффективную работу с базами данных, видеть узкие места и применять правильные паттерны — это навык, который отличает профессионала. На курсе «Разработчик на Джава. Про» в OTUS мы как раз разбираем такие задачи: от основ JDBC до сложных случаев оптимизации и интеграции в высоконагруженных системах. Готовы к обучению? Пройдите вступительный тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
26 февраля, 20:00. «JDBC — ваш швейцарский нож для работы с данными». Записаться
11 марта, 20:00. «Сообщения, которые не теряются: Брокеры против хаоса в Джава». Записаться
19 марта, 20:00. «Кафка — работа с сообщениями в форматах Avro и Protobuf». Записаться
Комментарии (16)

ermadmi78
25.02.2026 17:59Пул vs. База: Размер пула не должен превышать количество ядер БД * 2. Формула «чем больше, тем лучше» не работает, больше соединений = больше контекста = медленнее.
Я бы ещё добавил рекомендацию о размере пула потоков, в котором выполняются запросы через пул соединений.
Т.к. JDBC (в отличии от R2DBC), предоставляет строго блокирующий API, то размер пула потоков должен быть равен размеру пула соединений.
Если в размер пула потоков меньше размера пула соединений, то в каждый момент времени часть соединений будет простаивать, т.к все доступные потоки заняты выполнением запросов в других соединениях. А если больше, то в каждый момент времени часть потоков будет простаивать, ожидая соединения из пула.

mmMike
25.02.2026 17:59Логирование медленных запросов: Нужно настроить в драйвере или на уровне базы, чтобы видеть запросы, выполняющиеся дольше 100 мс.
На уровне БД - понятно. Но это не лучший способ. Сопоставлять сложно события в прикладном ПО с логом БД.
Но, не видел, что бы в драйвере Oracle или PG можно было настроить такое (warn). Можно настроить stmt.setQueryTimeout(..). Что будет порождать типа ORA-01013: user requested cancel of current operation (в PG похожее).
Но это отказ, а не warning для лога. Т.е. это предельный таймаут для отмены.А что бы warn в логе..
либо своя обертка вокруг jdbc (кстати, полезно для логирования вообще), если не используется стандартная обертка типа hibernate
либо выставления "hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=..." для hibernate
Если знаете (вдруг есть) какие то средства для того что бы на уровне jdbc драйвера выводить WARN на запросы, которые выполнились, но выполнились дольше чем указано - подскажите pls.
Копался и в Oracle jdbc и в PG исходниках jdbc.. Даже намека на такое не видел.

Bmvrita
25.02.2026 17:59Бегло посмотрела, мне кажется нет напрямую. Если нужно такого вида логирование, то это всегда обёртки разного рода вокруг jdbc. А чем вас этот подход смущает? Вполне рабочий вариант. Ну и ещё если у вас spring boot аспектами можно, но опять это обёртка.

mmMike
25.02.2026 17:59ничем не смущает. так и пользую.
Смутила фраза в тексте статьиНужно настроить в драйвере
В драйвере.
Подумал, что вдруг чего не знаю (хотя часто копался в исходниках PG jdbc и дебажил Oracle jdbc)

Bmvrita
25.02.2026 17:59Спасибо! Отличная статья, все описанные проблемы и способы решения видела на практике.

ZvoogHub
25.02.2026 17:59В большинстве СУБД (PostgreSQL, Oracle и др.) PreparedStatement позволяет базе кешировать план запроса. Если вы 1000 раз вставите данные через один и тот же PreparedStatement (меняя параметры), база не будет каждый раз заново парсить запрос и строить план. Это экономит CPU на базе.
Затраты на создание плана запроса ничтожны по сравнению с самой операцией изменения/выборки данных.
План запроса зависит от данных, поэтому в реальных системах где данные часто меняются выгодней не использовать кеширование плана запросов.

Gmugra
25.02.2026 17:59И что? Статья о JDBC, что нам дает ваше замечание в этом контексте?
JDBC знать не знает о существовании планов выполнения запросов и, уже тем более о том, что сервер может уметь их кэшировать.
Но если вдруг он умеет их кэшировать, то что может являться ключем для закэшированного плана? - сам SQL-запрос, больше нечему.
т.е. если там вдруг есть такой кэш, то лучше бы нам уменьшить количество различных запросов, что и позволяет нам сделать PreparedStatement.
т.е. тут вопрос только в том даете ли вы серверу возможность хоть как то эффективно использовать кэш, если он у него есть, или не даете.Поэтому автор прав в утверждении "всегда используй PreparedStatement" как лучшую стратегию по умолчанию, даже с точки зрения производительности.
Я бы только добавил: если вы таки НЕ используете PreparedStatement, то должны четко понимать почему и зачем.
Regis
25.02.2026 17:59"всегда используй PreparedStatement" — этот совет основан на предположении, что кэшировать план запроса всегда хорошо. Это не так. В большой доле сценариев использование PreparedStatement с параметрами — будет мешать планировщику базы построить оптимальный план с учётом конкретного значения параметра. Планировщик вынужден строить план под более универсальный случай и терять в производительности.

Gmugra
25.02.2026 17:59Случаи когда binding вредит бывают, да: "bind value peeking issue" и т.п. Но говорить о том, что это "большая доля сценариев" - это крайне смело. Это, мягко говоря, не так (Если у вас есть линк на что-то внятное по теме, подтверждающие ваше мнение - дайте почитать, что-ли)
Кроме того, опять же, держимся контекста статьи:
PreparedStatement в первую очередь используют не ради перформанса: ради защита от инъекций. Именно поэтому PreparedStatement - всегда, по дефолту. То что в "большой части сценариев" ;) он еще и перформансу помогает (не намного, кстати, процентов на 10%) - приятный и полезный бонус.

Gmugra
25.02.2026 17:59Тема с Batch-ми не раскрыта до конца :) Засада: "batch size" (то самое N запесей в пакете)
Дело тут в чем: если вы захотите запихать 10000 запросов в batch, то это не значит что так оно одним блоком на сервер и уедет - неа.
Драйвер будет собирать запросы а пачки по batch-size запросов в каждой, и слать каждую пачку на сервер. И этот параметр по умолчанию очень не велик: 10 (от драйвера зависит, но всегда не особо велик).
И в JDBC API нет возможности на этот парамер влиять. Возможно у драйвера есть параметр, который можно воткнуть в connection uri (читаем доку по драйверу). А бывает и так, что это непоменять никак.
И даже если у вас есть возможность выставлять это batch size, то его точно не стоит делать очень большим. (читаем доку по драйверу :) )
Нет, сделать 1000 запросов вместо 10000 - все равно хороший выигрыш, но на практике, это почти всегда недостаточно. (Автор упомянет ускорение в 50 - 100 раз от executeBatch - я такого не видел никогда. В разы - да. Но не на 2 порядка.)
Поэтому когда речь идет о масcовом выполнении 10000+ запросов, то решение всегда уезжает за пределы JDBC: bulk insert, временные таблицы в памяти и вот это вот все, о чем упоминается в тексте заметки. И вот тут уже выгрыш на два порядка - легко. (Я видел проект где 8 часов превратилось в 70 cекунд)
Regis
Открою вам старашный "секрет" про пулы соединений: никогда не используйте на проде динамическое измнение числа соединений (или подобных ресурсов). Всегда используйте фиксированный размер, если у вас ресурс сам по себе не скейлится автоматически и вы хотите, чтобы в целом система работала как можно более стабильно и предсказуемо.
Число соединений к базе? min == max
Память JVM процсса? Xms == Xmx (и ещё
-XX:+AlwaysPreTouchвдогонку)Если только у вас база сама по себе не скейлится автоматически под нагрузкой, то причин начинать с маленького числа соединений в пуле — примерно ноль.
sandersru
В мире микросервисов пришли новые грабли - а именно когда то было 5 pod- ов а теперь 100 pod- ов. И у каждого на базу по 20 соединений.
Да, есть PgBouncer, но начинается беда с PS.
ermadmi78
Тут помогает только шардирование и/или репликация БД. Ну и паттерном Shared Database не стоит увлекаться.
А по поводу PgBouncer - ИМХО, это просто способ замаскировать архитектурные проблемы, пожертвовав производительностью Prepared Statements. Если нет жёсткого SLA на время отклика, то этот способ работает. А если есть - то начинаются проблемы.
sandersru
PgBouncer на своей стороне умеет PS кэшировать, вместо того же hikari. Но в целом спора нет, думать про архитектуру надо смолоду
Regis
А какая разница, микросервисы или нет? Или будем держаться на надежде, что если один микросервис будет перегружен, то остальным база не будет нужна?
Тут это скорее выглядит как признание того, что в случае с микросервисами становится сложнее планировать нагрузку.