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 имена таблиц и столбцов?
Нет. Плейсхолдер ? подставляет только значения. Имя таблицы или столбца параметром не передать — если оно приходит извне, его нужно проверять по белому списку допустимых имен, иначе инъекция снова возможна.



