Всем привет, меня зовут Сергей Прощаев. В этой статье я расскажу про 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.

Рисунок 1. Сравнение последовательной и пакетной вставки
Рисунок 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%.

Мы применили несколько приёмов:

  1. Отказ от цикличных запросов. Мы использовали временную таблицу, загружали туда весь CSV через COPY (это не JDBC, но тоже полезно знать), а затем делали один MERGE (aka UPSERT).

  2. Пакетная обработка. Если без цикла нельзя, мы использовали executeBatch().

  3. Оптимизация планов. Для оставшихся запросов мы использовали PreparedStatement, чтобы планы кешировались.

  4. Увеличение 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». Записаться