Индексы и языки/ORM: зависимость и особенности

Зависят ли индексы от языка программирования и как ORM влияют на их использование: генерируемые запросы и N+1, неявные приведения типов, prepared statements и план, миграции индексов и как из кода проверить, что индекс реально работает

Индекс живёт в БД и от языка приложения не зависит — но вот использование индекса зависит очень даже. ORM генерирует запросы, которые могут не попадать в существующий индекс; неявное приведение типов в параметре ломает index scan; агрегат по списку id незаметно превращается в N отдельных запросов вместо одного. «Добавил индекс, а быстрее не стало» на практике часто оказывается проблемой на стороне кода, а не БД.

Это пятая, финальная статья серии «Индексы в базах данных». Опирается на структуры из первой статьи, практику выбора и диагностики из второй, обслуживание и антипаттерны из четвёртой; реализации по конкретным БД — в третьей. Здесь — стык индексов с приложением: два одинаковых по смыслу стенда на Go (pgx) и Java (Hibernate) поверх одной и той же таблицы events из предыдущих статей серии.

Проблема N+1 в ORM: робот-переводчик запросов и двадцать одна отдельная тележка-поездка к каталогу против одной тележки на 20 карточек одним запросом; внизу — ключ-параметр типа text «555» не влезает в bigint-ячейку (Seq Scan) против правильного bigint (Index Scan)

В статье

Индексы vs язык: что не зависит, а что зависит

Сам индекс — структура на диске внутри БД: B-tree, GIN, hash или что-то ещё (см. статью #1). Планировщику всё равно, откуда пришёл SQL — написан ли он руками в psql, собран sqlx-билдером или сгенерирован Hibernate. Индекс не знает, на каком языке написано приложение, и не может «работать хуже» только потому, что клиент — Go или Java.

Но между приложением и планировщиком стоят два слоя, которые язык и его экосистема как раз определяют: как формируется сам SQL (руками, билдером запросов или ORM) и как сериализуются параметры на уровне драйвера (bind-параметры, их объявленный тип, кодирование). Оба слоя способны свести на нет самый удачно спроектированный индекс — не потому, что индекс плохой, а потому, что запрос, который до него доходит, не тот, для которого индекс строился. Дальше — три конкретных механизма, на которых это ломается чаще всего: N+1, каст типа в параметре и отсутствие подходящего индекса под сгенерированный запрос.

ORM и N+1: реальные счётчики запросов Go и Java

N+1 — самый известный побочный эффект ORM, но его удобно продемонстрировать не абстрактно, а на измеримом счётчике запросов. Сценарий на стенде digital-cookbook/databases/db-indexes/orm одинаковый для обоих языков: взять 20 user_id из таблицы events (2 млн строк, индекс idx_events_user_cov), затем посчитать число событий на каждого — сначала «наивно», в цикле по одному запросу на пользователя, затем одним батч-запросом.

На Go (pgx v5.10.0) наивный цикл выглядит так:

// --- (1) N+1: типичный ORM-паттерн "выбрали список — по каждому отдельный запрос" ---
// 20 отдельных SELECT count(*) вместо одного агрегата с IN/ANY.
fmt.Println("=== (1) N+1 vs батч ===")
var n1 int
rows, err := pool.Query(ctx, "SELECT DISTINCT user_id FROM events ORDER BY user_id LIMIT 20")
if err != nil {
	panic(err)
}
var ids []int64
for rows.Next() {
	var id int64
	if err := rows.Scan(&id); err != nil {
		panic(err)
	}
	ids = append(ids, id)
}
rows.Close()

for _, id := range ids {
	var c int
	if err := pool.QueryRow(ctx, "SELECT count(*) FROM events WHERE user_id=$1", id).Scan(&c); err != nil {
		panic(err)
	}
	n1++
}
fmt.Printf("N+1: выполнено %d отдельных запросов (по одному на user_id)\n", n1)

// Правильно: один запрос через ANY($1) — тот же результат, 1 round-trip вместо N.
var total int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM events WHERE user_id = ANY($1)", ids).Scan(&total); err != nil {
	panic(err)
}
fmt.Printf("Батч: 1 запрос, total=%d (сумма по тем же %d user_id)\n", total, len(ids))

Вывод стенда: N+1: выполнено 20 отдельных запросов (по одному на user_id), затем Батч: 1 запрос, total=357 (сумма по тем же 20 user_id). Разница не в корректности — оба варианта считают одно и то же — а в количестве обращений к базе: 20 round-trip’ов вместо одного. На таблице со 2 млн строк и индексом по user_id каждый из 20 запросов сам по себе быстрый (Index Scan по точечному условию), но цена не в стоимости одного запроса, а в их числе: 20 сетевых round-trip’ов вместо одного, и это не индекс не справляется, а код обращается к БД 20 раз там, где хватило бы одного WHERE user_id = ANY($1).

pgx здесь никакой «магии» не делает — прямой SQL руками, но это ровно тот паттерн, который прячется внутри for _, user := range users { user.Orders() } в любом ORM с ленивой загрузкой: явный цикл, который выглядит невинно на уровне кода приложения, оборачивается N запросами на уровне БД.

Java-стенд (Hibernate 6.6.15.Final поверх той же таблицы через postgresql 42.7.4) воспроизводит тот же паттерн средствами JPQL, и лог Hibernate показывает его буквально:

// Типичный ORM-паттерн: сначала список "родителей" (пользователей), затем в цикле —
// по каждому отдельный запрос агрегата (эквивалент lazy-коллекции orders.size()).
// В логе Hibernate это видно как N однотипных `select count(*) ... where user_id=?`.
@SuppressWarnings("unchecked")
static void n1Demo(EntityManager em) {
    Query idsQuery = em.createNativeQuery("SELECT DISTINCT user_id FROM events ORDER BY user_id LIMIT 20");
    List<Number> ids = idsQuery.getResultList();

    int n1 = 0;
    for (Number id : ids) {
        long userId = id.longValue();
        Query countQuery = em.createQuery(
                "select count(e) from Event e where e.userId = :uid");
        countQuery.setParameter("uid", userId);
        long c = (long) countQuery.getSingleResult();
        n1++;
    }
    System.out.printf("N+1: выполнено %d отдельных запросов (по одному на user_id)%n", n1);
}

Лог hibernate.show_sql=true за этим вызовом — это ровно SELECT DISTINCT user_id FROM events ORDER BY user_id LIMIT 20, за которым 20 раз повторяется select count(e1_0.id) from events e1_0 where e1_0.user_id=? — по одному на каждое значение :uid. Итог: N+1: выполнено 20 отдельных запросов (по одному на user_id), число совпадает с Go до единицы, потому что источник один и тот же — DISTINCT user_id ORDER BY user_id LIMIT 20.

Батч-версия на Java заменяет цикл на один JPQL с IN и GROUP BY:

// Правильно: один JPQL с IN(...) и GROUP BY — Hibernate генерирует 1 SQL-запрос,
// возвращающий агрегат сразу по всем user_id.
@SuppressWarnings("unchecked")
static void batchDemo(EntityManager em) {
    Query idsQuery = em.createNativeQuery("SELECT DISTINCT user_id FROM events ORDER BY user_id LIMIT 20");
    List<Number> ids = idsQuery.getResultList();
    List<Long> userIds = ids.stream().map(Number::longValue).toList();

    Query batchQuery = em.createQuery(
            "select e.userId, count(e) from Event e where e.userId in :uids group by e.userId");
    batchQuery.setParameter("uids", userIds);
    List<Object[]> rows = batchQuery.getResultList();

    long total = rows.stream().mapToLong(r -> (Long) r[1]).sum();
    System.out.printf("Батч: 1 запрос, %d групп, total=%d (сумма по тем же %d user_id)%n",
            rows.size(), total, userIds.size());
}

Hibernate компилирует это в один SQL с where e1_0.user_id in (?, ?, ..., ?) group by e1_0.user_id — 20 плейсхолдеров вместо 20 отдельных выполнений. Вывод: Батч: 1 запрос, 20 групп, total=357 — то же самое число, что и на Go, и это не совпадение по построению теста, а полезная перекрёстная проверка: два независимых стека (pgx против драйвера напрямую и Hibernate поверх JDBC), два разных способа агрегации (ANY($1) против IN (...) GROUP BY), одна и та же выборка 20 user_id, и оба сошлись на total=357. Если бы в одном из путей была ошибка в фильтрации или в интерпретации результата — числа разошлись бы; то, что они не разошлись, подтверждает, что агрегат считается корректно независимо от того, каким клиентом и на каком языке он выполнен.

Индекс idx_events_user_cov в обоих случаях выполняет свою работу одинаково хорошо — что в 20 точечных запросах, что в одном батче с IN/ANY. N+1 не делает конкретный запрос медленнее, он умножает число обращений к БД на количество идентификаторов, потому что код на уровне приложения выбрал цикл там, где база могла принять множество значений одним вызовом.

Неявный каст bind-параметра ломает index scan

Второй эффект тоньше N+1, потому что не виден в коде на первый взгляд — типы совпадают синтаксически, но не совпадают на уровне того выражения, по которому построен индекс. idx_events_user_cov в этой серии стендов строится по user_id как есть — bigint. Если параметр сравнивается со строковым представлением этой колонки (частый случай, когда ORM или маппер объекта передаёт идентификатор как String, а не как числовой тип, и в запросе появляется явный или неявный ::text), индекс, построенный на bigint-значении, больше не покрывает выражение (user_id)::text.

На стенде это видно напрямую по плану, полученному через EXPLAIN из Go-кода:

// --- (2) каст bind-параметра ломает индекс: план из кода через EXPLAIN ---
// user_id — bigint, но ORM (или невнимательный разработчик) сравнивает его со строкой:
// user_id::text = '555'. Индекс idx_events_user построен на bigint-выражении user_id,
// поэтому под кастом он бесполезен — планировщик уходит в Seq Scan по 2M строк.
fmt.Println("\n=== (2) план с кастом user_id::text = '555' (ломает индекс) ===")
printPlan(ctx, pool, "EXPLAIN SELECT * FROM events WHERE user_id::text = '555'")

fmt.Println("\n=== (3) для сравнения: план без каста user_id = 555 (использует индекс) ===")
printPlan(ctx, pool, "EXPLAIN SELECT * FROM events WHERE user_id = 555")

// printPlan выполняет EXPLAIN через драйвер и печатает план построчно — так ORM-код
// может на лету проверить, не деградировал ли план (например, в интеграционном тесте).
func printPlan(ctx context.Context, pool *pgxpool.Pool, sql string) {
	pr, err := pool.Query(ctx, sql)
	if err != nil {
		panic(err)
	}
	defer pr.Close()
	for pr.Next() {
		var line string
		if err := pr.Scan(&line); err != nil {
			panic(err)
		}
		fmt.Println(line)
	}
}

С кастом user_id::text = '555' планировщик выдаёт:

Seq Scan on events  (cost=0.00..60847.00 rows=10000 width=70)
  Filter: ((user_id)::text = '555'::text)

Filter, а не Index Cond — это последовательное сканирование всех строк таблицы с последующей фильтрацией, оценка rows=10000 при том, что реальных совпадений по user_id=555 кратно меньше. Без каста, тот же логический запрос, но user_id = 555 (целое сравнивается с целым):

Index Scan using idx_events_user_cov on events  (cost=0.43..24.99 rows=21 width=70)
  Index Cond: (user_id = 555)

Index Cond вместо Filter, оценка cost меньше на три порядка (24.99 против 60847.00). Разница между двумя планами — не в данных и не в индексе, а исключительно в том, как записано условие: (user_id)::text = '555'::text не совпадает с индексируемым выражением user_id, и планировщик физически не может использовать B-tree, построенный на других значениях (строковое представление bigint не упорядочено так же, как сам bigint — сравнение '9' > '10' истинно как для строк, но ложно для чисел, так что даже теоретическая попытка использовать индекс дала бы неверный результат). Это ровно тот же принцип совпадения выражения, что разобран для функциональных индексов в статье #2 — просто источник несовпадения здесь не в WHERE, написанном разработчиком, а в том, как ORM или маппер сериализует параметр.

На практике каст чаще всего вылезает из трёх мест: entity-поле объявлено как String, а колонка в БД — числовая или bigint; конфигурация ORM явно приводит внешний ключ к строке для сравнения с UUID-подобным идентификатором; или разработчик руками пишет WHERE user_id::text = :id, потому что :id приходит из HTTP-параметра как строка и кастовать показалось проще, чем распарсить. Во всех трёх случаях лечение одно — привести тип параметра к типу колонки на границе приложения (распарсить id в int64/long до передачи в запрос), а не полагаться на неявное приведение внутри SQL.

Отсутствие индекса под ORM-запрос

Третий эффект — не поломанный, а отсутствующий индекс: ORM генерирует запрос, который выглядит невинно на уровне кода (findByStatusAndCreatedAtBetween, WHERE status = ? AND created_at BETWEEN ? AND ? в сгенерированном виде), но под него нет составного индекса — есть только отдельные индексы по status и по created_at, каждый из которых планировщик использует по отдельности хуже, чем один составной (status, created_at) мог бы использовать целиком (см. составные индексы и left-prefix в статье #2).

Отличие от предыдущего пункта: там индекс есть, но не совпадает выражение; здесь подходящего индекса просто не существует, потому что тот, кто проектировал схему, не видел, какие именно запросы будет генерировать ORM-слой в конкретных сценариях использования (например, ленивая подгрузка связанных сущностей с фильтром, добавленным позже спецификацией/криэрией). Практическое следствие: проектирование индексов должно идти от реально выполняемых запросов (pg_stat_statements, логи Hibernate/hibernate.show_sql, EXPLAIN ANALYZE на конкретном сгенерированном SQL), а не только от схемы данных — ORM-слой добавляет условия и джойны, которые не всегда очевидны на этапе проектирования таблицы.

Prepared statements и план: bind-peeking

Драйверы, использующие protocol-level prepared statements (и pgx, и JDBC-драйвер PostgreSQL умеют так работать), поднимают ещё один вопрос: с какими значениями параметров планировщик строит план — заглядывая в переданные значения (custom plan) или без учёта конкретных значений (generic plan)? PostgreSQL начинает с custom plan на первых нескольких выполнениях одного и того же подготовленного запроса (заглядывая в фактические bind-параметры — bind-peeking), а затем, если стоимость custom plan в среднем не намного ниже generic, переключается на generic plan, который строится один раз и переиспользуется для любых будущих значений параметров.

Проблема возникает, когда распределение значений параметра сильно неравномерно (skew) — для одних значений подходящий план использует индекс, для других (высокочастотных, покрывающих большую долю таблицы) индекс не даёт выигрыша против последовательного скана. Если планировщик переключился на generic plan по «средним» первым нескольким вызовам, а затем в это же подготовленное выражение прилетает редкое по частоте, но специфичное по стоимости значение — используется тот же generic plan, который может оказаться не оптимальным именно для него. На практике это проявляется как «запрос то быстрый, то медленный с одними и теми же параметрами по коду, но разными значениями» — при том, что ни индекс, ни схема не менялись между вызовами.

Практический вывод для ORM-кода: если профилирование показывает деградацию плана именно на предсказуемо неравномерных ключах (частые/редкие статусы, суперпользователи с аномально большим числом строк и так далее), стоит явно проверить pg_stat_statements и, при необходимости, вынести горячий путь на SQL_MODE/hint-уровне ORM в отдельный явный запрос вместо параметризованного шаблона — либо снизить plan_cache_mode до force_custom_plan для конкретного соединения, если ORM это позволяет настроить. Это не типичный сценарий для большинства CRUD-приложений, но стоит знать о нём, когда план внезапно «плавает» без видимых изменений в данных или индексах.

Миграции индексов из кода

Индекс, как и любая структура схемы, должен жить в системе миграций рядом с таблицами — Flyway/Liquibase в Java-экосистеме, golang-migrate/goose в Go, или встроенные механизмы фреймворка. Два практических правила, которые применимы к обоим языкам одинаково:

  • CREATE INDEX CONCURRENTLY в отдельной миграции, вне транзакции. Разобранный в статье #4 механизм online-создания несовместим с транзакционным блоком, в который многие системы миграций оборачивают каждый файл по умолчанию — Flyway и golang-migrate позволяют пометить конкретную миграцию как нетранзакционную (-- no-transaction для golang-migrate PostgreSQL-драйвера, аналогично у Flyway через executeInTransaction=false в конфигурации), но это нужно сделать явно, иначе CONCURRENTLY в обычной миграции просто упадёт с ошибкой на попытке выполниться внутри BEGIN/COMMIT.
  • Индекс — часть той же миграции, что и колонка/запрос, который его требует, а не отложенная задача «оптимизируем потом». Отсутствие индекса под ORM-запрос из предыдущего раздела чаще всего и есть результат того, что миграция добавила колонку и код, который её фильтрует, но не добавила индекс — потому что на момент написания кода запрос ещё не был горячим, а через полгода стал, и никто не вернулся сверить одно с другим.

Как из кода проверить, что индекс работает

Оба стенда выше показывают один и тот же приём: не верить на слово ORM или собственному предположению об использовании индекса, а спросить у планировщика напрямую — через EXPLAIN, выполненный тем же драйвером и с теми же параметрами, что и реальный запрос. Функция printPlan в Go-примере делает ровно это: выполняет EXPLAIN <SQL> через тот же pgxpool.Pool, что и боевой код, и печатает план построчно — тот же приём можно завернуть в интеграционный тест, который проваливается, если в плане появился Seq Scan там, где ожидался Index Scan (например, простой strings.Contains(planText, "Index Scan") как assertion). А для ручного разбора собранного плана удобен визуализатор explain.tensor.ru: вставляете вывод EXPLAIN (ANALYZE, BUFFERS), и он наглядно показывает узлы, где реальное время и число строк расходятся с оценкой планировщика.

На Java-стороне эквивалентный источник правды — не EXPLAIN, вызванный вручную, а сам лог Hibernate: hibernate.show_sql=true (или более подробный org.hibernate.SQL=DEBUG через slf4j) печатает каждый сгенерированный SQL до его выполнения. Это первый шаг диагностики — прежде чем разбираться, использует ли план индекс, нужно вообще увидеть SQL, который ORM отправил в БД, потому что часто именно это и есть открытие: не тот запрос, который ожидался на уровне кода (user.getOrders() тихо превращается в отдельный SELECT по user_id). Дальше тот же SQL из лога можно взять один в один и прогнать через EXPLAIN (ANALYZE, BUFFERS) напрямую в БД — это самый надёжный способ проверить план для ORM-запроса, потому что план строится PostgreSQL для точно того текста, который ORM реально сформировал, а не для его предполагаемого руками эквивалента.

Итоговый минимальный чек-лист проверки «индекс действительно используется» из кода, применимый к обоим языкам:

  1. Включить логирование сгенерированного SQL (hibernate.show_sql/org.hibernate.SQL=DEBUG в Java; явный pgx.Trace/логирующий враппер в Go, либо просто логировать текст запроса перед выполнением).
  2. Взять реальный SQL с реальными параметрами (не типовой пример) и прогнать EXPLAIN (ANALYZE, BUFFERS) — через тот же драйвер, что и в приложении, как в примере printPlan.
  3. Проверить Index Scan/Index Only Scan/Bitmap Index Scan в плане, а не только присутствие индекса в схеме — индекс может существовать и не использоваться (см. диагностику в статье #2 и поиск неиспользуемых индексов через pg_stat_user_indexes в статье #4).
  4. Сверить число запросов, а не только их план — счётчик из N+1-примера выше (n1++ в Go, аналогично можно считать через Hibernate Statistics API — sessionFactory.getStatistics().getQueryExecutionCount()) ловит проблему, которую план одного запроса в принципе не покажет: индекс может быть безупречен в каждом отдельном запросе и всё равно давать N+1 округление по сумме round-trip’ов.

Рабочий стенд к обоим примерам — digital-cookbook → db-indexes/orm: Go (pgx v5.10.0, Go 1.26.3) и Java (Hibernate 6.6.15.Final, postgresql 42.7.4, JDK 21, Maven 3.9, exec-maven-plugin 3.5.0) поверх одного и того же PostgreSQL-стенда (postgres:18.4) из предыдущих статей серии.

Что дальше

Эта статья закрыла серию на стыке индексов и кода приложения: индекс не зависит от языка, но зависит от того, какой SQL до него доходит — а это определяют ORM, драйвер и разработчик. N+1 умножает число обращений к БД независимо от качества индекса; неявный каст bind-параметра делает индекс невидимым для планировщика даже когда сам индекс идеален; отсутствие составного индекса под реально генерируемый ORM-запрос — отдельная категория той же проблемы; а bind-peeking показывает, что даже с правильным индексом план может плавать в зависимости от истории вызовов подготовленного запроса.

Пять статей серии вместе дают полный путь: что такое индекс и какие структуры за ним стоят (#1), как выбрать и продиагностировать нужный (#2), как это устроено в разных БД (#3), какой ценой индексы обходятся на практике и как за ними следить (#4), и, наконец, как код на конкретном языке — здесь на примере Go и Java — способен свести всю эту работу к нулю, если не проверять, что реально уходит в БД.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии