Что такое JDBC
JDBC — стандартный Java API доступа к реляционным БД. Приложение работает с абстракциями Connection, Statement/PreparedStatement и ResultSet, а конкретный драйвер реализует протокол нужной СУБД.
Соединение получают не через DriverManager напрямую, а обычно из DataSource (пула): открыть TCP-соединение и аутентифицироваться дорого, поэтому соединения переиспользуют.
PreparedStatement против SQL injection
Statement выполняет готовую строку SQL и провоцирует склейку с пользовательским вводом — прямой путь к SQL injection. PreparedStatement отделяет шаблон запроса от параметров: значения передаются отдельно и не могут изменить структуру запроса.
Бонус — драйвер/БД могут переиспользовать подготовленный план для повторяющихся запросов. Параметризовать можно только значения; имя таблицы/колонки через ? не подставить — это часть структуры.
try (PreparedStatement st = con.prepareStatement(
"select * from users where email = ?")) {
st.setString(1, email); // значение, а не часть SQL
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) { /* ... */ }
}
}
Ручные транзакции
По умолчанию каждая команда — отдельная транзакция (autoCommit=true). Чтобы объединить несколько операций, autoCommit отключают, выполняют операции и завершают commit, а при ошибке — rollback.
Перед возвратом соединения в пул важно вернуть его в исходное состояние (autoCommit), иначе следующий пользователь получит «грязное» соединение.
con.setAutoCommit(false);
try {
// несколько операций в одной транзакции
con.commit();
} catch (SQLException e) {
con.rollback();
throw e;
} finally {
con.setAutoCommit(true);
}
Ресурсы и пул соединений
Connection, Statement и ResultSet — закрываемые ресурсы; try-with-resources закрывает их в обратном порядке даже при исключении. Утечка соединений быстро исчерпывает пул и кладёт приложение.
Пул (HikariCP и пр.) ограничивает число соединений и переиспользует их. Размер пула подбирают под БД, а не «побольше»: слишком большой пул создаёт перегрузку на стороне базы.
Контринтуитивный факт, который любят спрашивать: увеличение пула часто СНИЖАЕТ пропускную способность. Соединения упираются в число ядер и дисков БД, а лишние лишь плодят конкуренцию за одни и те же ресурсы и переключения контекста. Отсюда рекомендация HikariCP держать пул небольшим (десятки, а не сотни) и лечить нехватку соединений ускорением запросов, а не расширением пула.
Если соединений не хватает, поток ждёт в пуле до connectionTimeout и получает исключение. Поэтому долгие операции (внешние HTTP-вызовы, тяжёлые вычисления) не держат внутри транзакции: соединение всё это время занято и недоступно остальным.
Чтение больших результатов
По умолчанию JDBC-драйвер вычитывает результат запроса целиком в память клиента. На выборке в миллионы строк это OutOfMemoryError, и никакой try-with-resources от него не спасёт.
Лечится потоковым чтением: setFetchSize(N) просит драйвер отдавать результат порциями по N строк. Нюанс в том, что у драйверов разные требования. В PostgreSQL курсор на стороне сервера работает только при autoCommit=false — иначе fetchSize молча игнорируется. В MySQL исторически нужен приём с setFetchSize(Integer.MIN_VALUE) для построчного стриминга.
Ещё дешевле не тянуть лишнее вообще: ограничивать выборку LIMIT/keyset-пагинацией и перечислять нужные колонки вместо SELECT *.
con.setAutoCommit(false); // в PostgreSQL обязательно для серверного курсора
try (PreparedStatement st = con.prepareStatement("select id, total from orders")) {
st.setFetchSize(1000); // тянем порциями, а не весь результат в память
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) { /* обрабатываем построчно */ }
}
}
Batch и сгенерированные ключи
Когда нужно вставить/обновить много строк, по одному executeUpdate на строку — это сетевой round-trip на каждую операцию, что катастрофически медленно. Batch (addBatch/executeBatch) накапливает команды и отправляет их пачкой за один-два round-trip, ускоряя массовую запись в разы. Для PreparedStatement обычно ставят addBatch в цикле и периодически делают executeBatch (раз в N строк, чтобы не раздувать память), всё внутри одной транзакции.
Важный нюанс для MySQL: реальное склеивание в один INSERT включается только с параметром драйвера rewriteBatchedStatements=true — без него batch почти не даёт выигрыша.
Авто-сгенерированный ключ (auto-increment/identity) после вставки получают через Statement.RETURN_GENERATED_KEYS и getGeneratedKeys(), а не отдельным SELECT — это и быстрее, и без гонки.
try (PreparedStatement st = con.prepareStatement(
"insert into orders(user_id, total) values (?, ?)")) {
for (Order o : orders) {
st.setLong(1, o.userId());
st.setBigDecimal(2, o.total());
st.addBatch(); // накапливаем
}
st.executeBatch(); // одна пачка вместо N round-trips
}
Как отвечать на собеседовании
Ключевые тезисы: PreparedStatement параметризует и защищает от инъекций; транзакцию ведут через autoCommit=false + commit/rollback; ресурсы закрывают через try-with-resources; соединения берут из пула, потому что их создание дорого; массовую запись делают через batch (addBatch/executeBatch, для MySQL — rewriteBatchedStatements), а сгенерированные ключи берут через getGeneratedKeys. В Spring ручное управление обычно заменяет @Transactional.
Про инъекции отвечайте механизмом, а не лозунгом: PreparedStatement передаёт значение ОТДЕЛЬНО от текста запроса, поэтому оно физически не может изменить его структуру. И сразу добавьте границу применимости — параметризовать можно только значения, имя таблицы или колонки через ? не подставить, там нужен whitelist допустимых значений в коде.
Два места, где кандидат выглядит практиком: соединение перед возвратом в пул надо вернуть в исходное состояние (autoCommit), иначе следующий получит «грязное»; и большой результат надо читать через setFetchSize, иначе драйвер вытянет всё в память — с оговоркой, что в PostgreSQL для этого обязателен autoCommit=false.
Если зайдёт разговор про пул — скажите, что размер подбирают под возможности БД и что раздутый пул обычно снижает пропускную способность, а не повышает.