Отчёт по продажам считался на 40 минут дольше при одинаковом объёме данных

Два прогона одного и того же батч-отчёта, одинаковое количество строк — один укладывался в семь минут, второй шёл сорок. Разница была не в данных и не в железе, а в том, как строился SQL-запрос внутри джобы.

Запрос собирался динамически: к базовому SELECT добавлялись условия фильтрации в зависимости от параметров отчёта. Для каждого набора параметров получался текстуально новый SQL — с разными литералами вместо плейсхолдеров. Планировщик Postgres не узнавал запрос как уже виденный и каждый раз строил план заново, причём иногда выбирал неоптимальный путь из-за оценки селективности по конкретным значениям в тексте запроса.

Медленный прогон попал на комбинацию параметров, где планировщик решил идти через Nested Loop по крупной таблице вместо Hash Join — просто потому что литералы в запросе дали другую оценку кардинальности, чем в быстром прогоне.

// было: литералы прямо в тексте запроса String sql = "SELECT * FROM orders WHERE region = '" + region + "' AND status = '" + status + "'";

// стало: параметризованный запрос, один план на все вызовы String sql = "SELECT * FROM orders WHERE region = ? AND status = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, region); ps.setString(2, status);

Ловушка: кажется, что PreparedStatement с плейсхолдерами сам по себе решает проблему кэширования планов. Это не так по умолчанию — Postgres кэширует план на стороне сессии только после нескольких одинаковых выполнений (generic plan включается после пятого вызова), и если соединение каждый раз новое из пула без переиспользования, выигрыша от плейсхолдеров в кэшировании плана не будет — но стабильность самого плана по разным значениям параметров всё равно сохранится.

Динамически собранный SQL с литералами внутри текста — это не просто риск SQL-инъекции, это ещё и непредсказуемый план выполнения на каждый вызов.

Тренажёр: 900+ вопросов и задач, мок с таймером, план повторов

senior·base — что спрашивают на самом деле


В этом посте были ссылки, но мы их удалили по правилам Сетки