Работа с базами данных из Java через JDBC

Работа с базами данных из Java через JDBC Полезное

JDBC (Java Database Connectivity) — это стандартный API из пакета java.sql, через который Java-программа подключается к реляционной БД, отправляет SQL-запросы и читает результат. Он входит в Java SE и работает поверх драйвера конкретной СУБД (PostgreSQL, MySQL, Oracle). Важно понимать границу: JDBC дает единый интерфейс для работы с реляционными источниками, поэтому при смене СУБД структура Java-кода обычно сохраняется, но переносится не все — меняются драйвер и URL, а вместе с ними могут поменяться SQL-диалект, поддерживаемые типы, способ получения сгенерированных ключей и уровни изоляции. Смена одной строки подключения переносит приложение не всегда: сами запросы и схема нередко требуют правок. Дальше по тексту я отмечаю, что именно гарантирует сам API, а что зависит от драйвера и СУБД. Минимальный пример ниже написан под PostgreSQL.

Ниже разберем рабочий цикл: соединение через DriverManager, разницу Statement и PreparedStatement, защиту от SQL-инъекций, пул HikariCP, транзакции и то, когда вместо чистого JDBC берут ORM (JPA/Hibernate).

Минимальный рабочий пример: соединение и запрос

Начнем с целого куска, который можно запустить, — он подключается к PostgreSQL и печатает пользователей.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class UsersDemo {
    // URL, логин и пароль лучше брать из переменных окружения, а не хранить в коде
    static final String URL = System.getenv("DB_URL");   // jdbc:postgresql://localhost:5432/shop
    static final String USER = System.getenv("DB_USER");
    static final String PASSWORD = System.getenv("DB_PASSWORD");

    public static void main(String[] args) {
        String sql = "SELECT id, username FROM users WHERE id > ?";
        try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setInt(1, 2);                 // подставляем значение в плейсхолдер ?
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {          // курсор двигается по строкам результата
                    System.out.println(rs.getInt("id") + " " + rs.getString("username"));
                }
            }
        } catch (SQLException e) {
            System.out.println("Ошибка БД: " + e.getMessage());
        }
    }
}

Ожидаемый вывод (при трех строках в таблице с id 1, 3, 5) — две строки, id 1 отфильтрован условием id > 2:

3 petrov
5 ivanov

Что здесь происходит:

  • DriverManager.getConnection(...) возвращает Connection — открытую сессию с БД. С JDBC 4.0+ драйвер из classpath регистрируется сам, Class.forName(...) не нужен.
  • prepareStatement(sql) создает PreparedStatement с плейсхолдером ?, executeQuery() выполняет SELECT и отдает ResultSet — результирующий набор с построчным курсором.
  • try-with-resources закрывает ResultSet, PreparedStatement и Connection даже при исключении. Забытый close() держит соединение и утекает ресурс.

Statement и PreparedStatement: в чем разница

Statement выполняет статический SQL без параметров, PreparedStatement — параметризованный: значения подставляются через ? методами setInt, setString и подобными. Это не только про удобство, но и про безопасность и скорость.

Признак Statement PreparedStatement
Параметры нет, SQL целиком строкой да, плейсхолдеры ?
SQL-инъекции уязвим при конкатенации ввода значения передаются отдельно от текста запроса
Повторный запуск план разбирается заново подготовку и план драйвер/СУБД могут переиспользовать
Типы данных вручную в строке через setXxx, корректный формат
Где уместен разовый статичный запрос без ввода почти всегда, особенно с данными извне

Не путайте два разных аргумента за PreparedStatement. Безопасность гарантирована самой моделью: значения передаются отдельно от текста запроса, и это свойство API, а не настройка. А вот выигрыш по скорости при повторных запусках — не универсальная гарантия JDBC: подготовку и кеширование плана выполняют драйвер и СУБД, и реальный эффект зависит от их реализации и нагрузки, поэтому его нужно измерять, а не считать данностью.

Методы выполнения общие: executeQuery() для SELECT (возвращает ResultSet), executeUpdate() для INSERT/UPDATE/DELETE (возвращает число измененных строк).

SQL-инъекция: как ломается конкатенация и как чинится

Частая ошибка новичка — собрать запрос склейкой строк с пользовательским вводом. Уязвимый код:

// login и password приходят из формы, злоумышленник вводит login = admin' --
String sql = "SELECT * FROM users WHERE login = '" + login
           + "' AND password = '" + password + "'";  // пароль тут для примера; в проде хранят хеш, а не открытый пароль
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql);   // уязвимо

Если в поле login ввести admin' --, драйвер получит такую строку (двойной дефис в SQL — начало комментария, остаток запроса отбрасывается):

SELECT * FROM users WHERE login = 'admin' --' AND password = '...'

Проверка пароля закомментирована, запрос вернет строку admin без пароля — это вход под чужим аккаунтом. А ввод ' OR '1'='1' -- в поле логина сделает условие истинным и вернет всю таблицу. Это и есть SQL-инъекция: ввод попал в запрос как код, а не как данные.

Исправление — тот же запрос через PreparedStatement с плейсхолдерами:

String sql = "SELECT * FROM users WHERE login = ? AND password = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, login);            // кавычки и -- станут частью значения, не SQL
    ps.setString(2, password);
    try (ResultSet rs = ps.executeQuery()) {
        // ...
    }
}

Теперь admin' -- ищется буквально как логин и ничего не находит: драйвер передает значение отдельно от текста запроса, синтаксис сломать нельзя. Граница: плейсхолдер подставляет только значения. Имя таблицы или столбца через ? не передать — если оно приходит извне, его проверяют по белому списку допустимых имен.

Пул соединений: зачем нужен HikariCP

Открытие Connection — дорогая операция: сетевое рукопожатие, аутентификация, выделение ресурсов на стороне БД. Открывать соединение на каждый запрос под нагрузкой нельзя. Пул держит набор готовых соединений и выдает их по требованию. На 2026 год стандартный выбор в экосистеме Java и дефолт в Spring Boot — HikariCP.

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv("DB_URL"));
config.setUsername(System.getenv("DB_USER"));
config.setPassword(System.getenv("DB_PASSWORD"));
config.setMaximumPoolSize(10);          // потолок одновременных соединений

try (HikariDataSource ds = new HikariDataSource(config)) {
    try (Connection conn = ds.getConnection()) {   // берем из пула
        // работаем как обычно
    }   // close() не рвет соединение, а возвращает его в пул
}

Ключевая деталь: conn.close() при работе с пулом не закрывает физическое соединение, а возвращает его в пул для повторного использования. Try-with-resources тут не мешает, а помогает — соединение гарантированно вернется.

Транзакции: все или ничего

Транзакция — группа операций, которые применяются или отменяются целиком. Классический пример — перевод денег: списание и зачисление должны пройти вместе.

По умолчанию JDBC работает в режиме автокоммита — каждый запрос фиксируется сразу. Чтобы объединить операции, автокоммит отключают:

try (Connection conn = ds.getConnection()) {
    conn.setAutoCommit(false);          // начинаем транзакцию
    boolean committed = false;
    try (PreparedStatement debit = conn.prepareStatement(
             "UPDATE accounts SET balance = balance - ? WHERE id = ?");
         PreparedStatement credit = conn.prepareStatement(
             "UPDATE accounts SET balance = balance + ? WHERE id = ?")) {

        debit.setInt(1, 100); debit.setInt(2, 1);
        if (debit.executeUpdate() != 1)   // проверяем, что списание затронуло ровно одну строку
            throw new SQLException("Счет списания не найден");
        credit.setInt(1, 100); credit.setInt(2, 2);
        if (credit.executeUpdate() != 1)  // и зачисление тоже
            throw new SQLException("Счет зачисления не найден");

        conn.commit();                  // обе операции применились
        committed = true;
    } finally {
        if (!committed) conn.rollback(); // любой выход до commit - откат (в т.ч. RuntimeException)
        conn.setAutoCommit(true);        // вернуть режим перед возвратом соединения в пул
    }
}

Разберем, почему шаблон именно такой. Флаг committed и откат в finally дают надежную атомарность: транзакция откатывается при любом выходе из блока до commit(), а не только при SQLException. Если ловить лишь SQLException, то RuntimeException между списанием и зачислением оставит транзакцию открытой, и данные разойдутся (списали, но не зачислили). Восстановить autoCommit перед закрытием соединения тоже обязательно: close() при работе с пулом возвращает соединение для повторного использования, и без сброса следующий, кто его возьмет, унаследует отключенный автокоммит. Наконец, проверка числа измененных строк (executeUpdate() != 1) ловит случай, когда счета с нужным id нет: без нее «перевод» молча пройдет мимо несуществующего счета.

JDBC или ORM: что выбрать

ORM (Object-Relational Mapping) отображает строки таблиц на объекты Java, скрывая ручной SQL и разбор ResultSet. Стандарт спецификации — JPA (Jakarta Persistence API), самая распространенная реализация — Hibernate. Это не замена JDBC: ORM работает поверх него.

Критерий Чистый JDBC ORM (JPA/Hibernate)
Контроль над SQL полный, пишете сами генерируется, тонкая настройка сложнее
Скорость старта больше ручного кода быстрее для CRUD и связей
Производительность предсказуема зависит от маппинга, есть скрытые запросы
Кривая входа ниже, нужен только SQL выше, свои понятия и жизненный цикл
Когда брать отчеты, тонкая оптимизация, простые задачи сложная доменная модель, много сущностей и связей

Ориентир: для пары запросов и максимального контроля берут JDBC; для приложения с десятками сущностей и связей ORM экономит много рутинного кода. Понимать JDBC полезно в обоих случаях — ORM все равно опускается до него, и узкие места диагностируются на уровне SQL.

Выводы

  • JDBC — это API пакета java.sql: DriverManager дает Connection, тот — Statement/PreparedStatement, SELECT возвращает ResultSet с построчным курсором.
  • PreparedStatement с плейсхолдерами ? закрывает SQL-инъекции: значения передаются отдельно от текста запроса. Конкатенация ввода в SQL — прямая уязвимость.
  • Соединения дорогие: под нагрузкой их берут из пула (HikariCP), а close() возвращает соединение в пул, а не рвет его.
  • Транзакции включают через setAutoCommit(false); надежный шаблон откатывает при любом выходе до commit() (не только при SQLException), восстанавливает autoCommit и проверяет число измененных строк.
  • ORM (JPA/Hibernate) работает поверх JDBC и оправдан на сложной модели; для простых задач берут чистый JDBC.
  • JDBC дает переносимость API, а не полную независимость от СУБД: при смене базы структура кода сохраняется, но драйвер, URL, SQL-диалект и типы могут поменяться; ускорение PreparedStatement и batch зависит от драйвера и его надо измерять.

Где применяется / связь с практикой

Работа с БД из Java — базовый навык бэкенд-разработчика: почти любое серверное приложение обращается к данным через JDBC (напрямую или через Hibernate/Spring Data). Соединения, параметризованные запросы и транзакции спрашивают на собеседованиях уровня джуниор.

Освойте тему на практике

Разобрать JDBC на практике, с живой БД и код-ревью, можно на курсе Java Basic в Otus. Посмотреть формат занятий вживую помогают открытые уроки Otus — там разбирают реальные задачи и отвечают на вопросы.

FAQ

Нужно ли вызывать Class.forName для загрузки драйвера?
С JDBC 4.0 (Java 6 и новее) драйвер регистрируется автоматически через механизм SPI, если его jar есть в classpath. Явный Class.forName(...) нужен только для старых драйверов или нестандартных сценариев загрузки.

Чем batch-вставка лучше цикла из отдельных INSERT?
addBatch() накапливает запросы, а executeBatch() отправляет их на выполнение как пакет. Это уменьшает накладные расходы и часто заметно ускоряет массовые операции. Но «одна пачка = один сетевой пакет» и «ускорение в разы» — не гарантия API: число сетевых обменов, переписывание вставок и итоговый выигрыш зависят от драйвера и его настроек (например, отдельного флага пакетной перезаписи), а некоторые драйверы batch и вовсе не поддерживают. Перед тем как полагаться на ускорение, проверьте DatabaseMetaData.supportsBatchUpdates(), документацию драйвера, размер пакета и результат на своей нагрузке.

Защищает ли PreparedStatement имена таблиц и столбцов?
Нет. Плейсхолдер ? подставляет только значения. Имя таблицы или столбца параметром не передать — если оно приходит извне, его нужно проверять по белому списку допустимых имен, иначе инъекция снова возможна.

OTUS Журнал