Блог

JIT-компиляция в PostgreSQL

Юрий Мельников

Сегодня столкнулся с контринтуитивной ситуацией, когда JIT-компиляция не только не помогает, но и вредит! Как такое возможно, что при включенной JIT-компиляции время выполнения запроса увеличивается восемь раз? Попробуем разобраться.

Немного истории

JIT (Just-In-Time compilation) в PostgreSQL появился как попытка ускорить выполнение дорогих запросов за счёт компиляции части плана выполнения в нативный машинный код. Идея в том, чтобы уйти от интерпретации выражений (фильтров, вычислений, агрегатов) и выполнять их максимально близко к «железу». Для этого PostgreSQL использует LLVM, который на лету генерирует и оптимизирует машинный код под конкретный запрос.

JIT задумывался прежде всего для CPU-интенсивных аналитических сценариев: сложные выражения, большие объёмы данных, длительные запросы, где стоимость компиляции окупается временем выполнения. В таких случаях выигрыш достигается за счёт снижения overhead интерпретатора и более агрессивных оптимизаций, которые невозможно или нецелесообразно делать заранее. В OLAP-нагрузке это действительно может дать ощутимый прирост — но только если запрос «достаточно тяжёлый», чтобы JIT успел себя оправдать.

JIT проектировался не под типичный OLTP.

JIT появился в PostgreSQL 11 как экспериментальная и отключённая по умолчанию возможность. Начиная с PostgreSQL 12, JIT включён по умолчанию (jit = on), но с важной оговоркой: он срабатывает только при превышении порогов стоимости (jit_above_cost, jit_inline_above_cost, jit_optimize_above_cost).

Разработчики PostgreSQL исходили из того, что PostgreSQL давно и активно используют как OLAP или аналитическую БД: отчёты, ad-hoc аналитика, тяжёлые SELECT с агрегациями и так далее. Такие запросы выполняются секунды и минуты, содержат сложные выражения, реже запускаются, но «жгут» процессор. Именно в этом сценарии стоимость JIT-компиляции тонет в общем времени выполнения и ускорение выражений и фильтров имеет смысл.

И вот здесь начинается расхождение с реальностью.

На практике PostgreSQL чаще всего используют как OLTP-базу для веб-приложений: CRUD, API, бэкенды, микросервисы. Преимущественные характеристики таких нагрузок — это короткие запросы (миллисекунды), высокая частота, много простых SELECT / INSERT / UPDATE. Для этого класса нагрузок overhead JIT часто не окупается и даже «дорогой» по planner cost запрос может быть коротким по факту, а компиляция + LLVM может занимать сравнимое время с execution.

Универсальность PostgreSQL сыграла против него самого.

PostgreSQL одновременно OLTP и OLAP, активно используется в аналитике и часто разворачивается как «универсальная БД». Поэтому было принято решение включить JIT глобально, но ограничить его через cost-based thresholds и переложить финальную настройку на инженеров под конкретную нагрузку. Это разумно с точки зрения ядра СУБД, но опасно в продакшене, если не смотреть EXPLAIN ANALYZE.

В PostgreSQL важно понимать одну вещь: planner cost — это не время и даже не попытка его угадать. Это абстрактная модель относительной «дороговизны» плана, используемая только для выбора плана, а не для оценки latency.

Planner cost измеряется в условных единицах, не привязан к миллисекундам, не учитывает реальную производительность CPU и не знает ничего о текущей загрузке системы. Это значит, что его он представляет из себя грубые эвристики, а не измерения.

Planner Cost хорошо работает, когда план выполняется долго и разница между вариантами плана значительная, но для коротких запросов, где execution занимает 5–20 ms, JIT добавляет 10–30 ms, а planner всё ещё считает это «дорогим» запросом. С точки зрения cost это «окупится», а с точки рения пользователя стало объективном медленнее.

Как правильно ограничивать или отключать JIT в PostgreSQL

Важно сразу зафиксировать: JIT — не бинарный выбор. Его не обязательно либо «включать всем», либо «выключать навсегда». В PostgreSQL есть несколько уровней контроля — от аккуратной настройки до полного отключения.

Самый безопасный вариант: отключать JIT на уровне сессии. Подходит для OLTP-сервисов, API, фоновых воркеров и batch-задач с предсказуемыми запросами. Это хорошо, потому что не влияет на другие подключения, легко включить обратно и можно применять точечно (connection pool, role, приложение). Часто это оптимальный дефолт для backend-сервисов.

JIT — это не одна кнопка, а набор стадий: генерация, inlining, оптимизация, emission. В некоторых сценариях разумно оставить JIT включённым, но отключить самые дорогие стадии. Это снижает overhead, сохраняя часть выигрыша для больших запросов.

Полное глобальное отключение — крайний вариант и имеет смысл, если нет аналитических запросов, CPU дорогой ресурс или база чисто OLTP. Это честное и валидное решение, а не «антипаттерн».

Личный опыт

Сегодня столкнулись с показательной ситуацией в PostgreSQL: сложный расчётный VIEW с несколькими JOIN, большим количеством CASE, round() и numeric-арифметики выполнялся около 52 секунд. На первый взгляд — типичный «тяжёлый» запрос. Однако EXPLAIN ANALYZE показал, что реальная проблема была не в данных и не в сортировке, а в JIT-компиляции: около 47 секунд ушло исключительно на LLVM JIT (тысячи сгенерированных функций, десятки секунд на оптимизацию и emission). Execution как таковой занимал лишь небольшую часть общего времени.

Решение оказалось тривиальным: SET jit = off. Без переписывания запроса, без индексов и без изменения логики время выполнения сократилось с ~52 до ~7 секунд. Этот кейс хорошо иллюстрирует, что cost-based включение JIT не гарантирует выигрыша по времени: planner cost — это абстрактная модель, а не latency-метрика.

JIT в PostgreSQL — мощный инструмент, но по умолчанию он оптимизирует абстрактную стоимость, а не реальное время.В продакшене выигрывает тот, кто это понимает и управляет им осознанно.