Получить доступ

JDBC и безопасные запросы

Connection/Statement/ResultSet, PreparedStatement против инъекций, ручные транзакции, ресурсы и пул соединений.

Что такое 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.

Если зайдёт разговор про пул — скажите, что размер подбирают под возможности БД и что раздутый пул обычно снижает пропускную способность, а не повышает.

Вопросы по теме

Читайте также

Закрытая бета

Знать тему — половина дела

На живом собеседовании sotto слышит вопрос интервьюера и показывает короткий ответ и план. Оставьте контакт — пришлём доступ и промокод.

  • Промокод на 2 часа — сверх пробных 30 минут
  • Программа для Windows — скачать сразу, войти — через Яндекс ID или VK ID
  • Прямая связь с разработчиком: что улучшить — сделаем