Массивы в ORM: хранение и запросы в Django, Hibernate и Prisma | AdminWiki

Массивы в ORM: хранение и запросы в Django, Hibernate и Prisma

16 сентября 2026 10 мин. чтения

Как ORM хранят массивы: три подхода и их последствия

Django ArrayField держит массив в колонке PostgreSQL типа ARRAY. Hibernate @ElementCollection создаёт отдельную таблицу-коллекцию с внешним ключом. Prisma scalar lists пишут значения в нативный массив PostgreSQL. Подход «массив в колонке» читает данные вместе со строкой за один SELECT, поэтому JOIN не нужен. Подход через таблицу-коллекцию требует JOIN или отдельного запроса на каждую сущность.

Практическая разница видна уже на сохранении. Статья с пятью тегами в Django или Prisma - это один INSERT. То же действие в Hibernate - INSERT в основную таблицу плюс пять INSERT в таблицу коллекции, если не включён пакетный режим. Выборка 100 статей с тегами в Django и Prisma - один SELECT. В Hibernate при стратегии LAZY это один SELECT по статьям плюс 100 запросов по таблице тегов.

ORMМеханизмСУБДХранениеJOIN при чтенииРиск N+1
DjangoArrayFieldPostgreSQL и производныеКолонка типа ARRAYНетНет
Hibernate@ElementCollectionЛюбая, через таблицу-связкуОтдельная таблица коллекцииДа, JOIN или доп. SELECTДа, при LAZY
Prismascalar lists (String[], Int[])PostgreSQLКолонка типа ARRAYНетНет

MySQL и SQLite нативных массивов не имеют, поэтому ArrayField и scalar lists там не работают. Для этих СУБД остаются JSON, отдельная таблица или сериализация; разбор вариантов с примерами и индексами собран в материале о хранении массивов в MySQL через JSON и таблицу-связку. Если данные тяготеют к документам с вложенными массивами переменной длины, сравните подход с массивами в MongoDB, где массив лежит внутри BSON-документа, а multikey-индекс ускоряет поиск по элементам.

Django ArrayField: массив в одной колонке

ArrayField доступен только на PostgreSQL и совместимых форках. Поле описывается так:

class Article(models.Model):
    title = models.CharField(max_length=200)
    tags = ArrayField(models.CharField(max_length=50), default=list)

Для изменяемого значения указывайте default=list, а не default=[], иначе все строки получат ссылку на один и тот же объект списка. Генерируемый SQL прост:

INSERT INTO article (title, tags) VALUES ('ORM и массивы', ARRAY['django','postgres']);
SELECT id, title, tags FROM article WHERE id = 42;

Значение tags возвращается в Python как список, промежуточная таблица не создаётся. Поиск по элементу выполняет условие = ANY(tags), пересечение массивов - оператор &&. Для ускорения таких условий нужен GIN-индекс: без него PostgreSQL читает колонку последовательно и сравнивает каждый массив поэлементно.

Hibernate @ElementCollection: коллекция в отдельной таблице

Аннотация @ElementCollection всегда создаёт таблицу-коллекцию с внешним ключом на основную сущность. Нативный тип ARRAY PostgreSQL при этом не используется.

@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "article_tags", joinColumns = @JoinColumn(name = "article_id"))
@Column(name = "tag")
private List<String> tags = new ArrayList<>();

Схема получается такой: article_tags(article_id bigint, tag varchar). Сохранение и чтение идут через эту таблицу:

INSERT INTO article_tags (article_id, tag) VALUES (42, 'hibernate');
SELECT at.article_id, at.tag FROM article_tags at WHERE at.article_id = 42;

Получить колонку ARRAY в PostgreSQL через @ElementCollection напрямую нельзя: аннотация рассчитана на переносимую модель «таблица плюс внешний ключ». Колонку text[] придётся объявлять через columnDefinition и писать собственный тип для разбора значения, а это уже выход за пределы стандартного маппинга.

Prisma scalar lists: списки только для PostgreSQL

Prisma поддерживает скалярные списки String[], Int[], Boolean[] и другие только на PostgreSQL. Схема выглядит так:

model Article {
  id    Int      @id @default(autoincrement())
  title String
  tags  String[]
}

В базу уходит нативный массив, а не отдельная таблица:

INSERT INTO "Article" (title, tags) VALUES ('ORM и массивы', ARRAY['prisma','postgres']);
SELECT id, title, tags FROM "Article" WHERE id = 42;

Фильтрация по элементам ограничена набором has, hasEvery, hasSome:

prisma.article.findMany({ where: { tags: { has: 'prisma' } } })

Это транслируется в WHERE 'prisma' = ANY(tags). Операторы unnest, array_agg, срезы по индексам и агрегации по элементам Prisma Client не выражает, для них нужен сырой запрос. MySQL и SQLite скалярные списки не поддерживают, в схеме их придётся заменять на JSON или таблицу-связку.

Генерация SQL: что ORM делает под капотом

Django ArrayField и Prisma scalar lists укладывают значения в один INSERT и один UPDATE. Смена списка тегов - это UPDATE article SET tags = ARRAY['orm','sql'] WHERE id = 42, одна операция на строку. Hibernate при изменении коллекции удаляет прежние строки и вставляет новые:

DELETE FROM article_tags WHERE article_id = 42;
INSERT INTO article_tags (article_id, tag) VALUES (42, 'orm');
INSERT INTO article_tags (article_id, tag) VALUES (42, 'sql');

На статье с 5000 тегов это 5001 оператор на одно изменение. ArrayField в такой ситуации выполняет один UPDATE, но перезаписывает весь массив целиком, что увеличивает объём WAL и стоимость записи при частых правках.

Почему @ElementCollection генерирует больше запросов

При ленивой загрузке 100 сущностей Hibernate выполняет один SELECT по основной таблице и по одному SELECT на каждую коллекцию. В логах это выглядит так:

select a.id, a.title from article a where a.id > 100;
select at.tag from article_tags at where at.article_id = 101;
select at.tag from article_tags at where at.article_id = 102;
... ещё 98 таких же

Итого 101 запрос вместо одного. ArrayField и scalar lists в той же выборке делают один SELECT, поскольку массив лежит в строке. Снизить число запросов в Hibernate помогает hibernate.jdbc.batch_size для записи и @BatchSize(size = 50) для чтения: коллекции подгружаются пачками по 50 владельцев, и 100 запросов превращаются в два.

Как читать SQL-логи ORM

Django включает логирование через секцию LOGGING для логгера django.db.backends с уровнем DEBUG. Hibernate выводит запросы при hibernate.show_sql=true, а hibernate.format_sql=true добавляет переносы строк и отступы. Prisma показывает SQL при log: ['query'] в конструкторе клиента. Первый признак проблемы - повторяющийся запрос, отличающийся только значением внешнего ключа. Второй - разрыв между числом запросов приложения и числом строк в ответе.

На стороне сервера общую картину даёт расширение pg_stat_statements: оно суммирует запросы по нормализованному тексту и показывает суммарное время. Запрос вида select at.tag from article_tags at where at.article_id = $1 с 200 000 вызовов сразу выдаёт N+1, даже если каждый отдельный вызов занимает 0,05 мс.

Ленивая загрузка и проблема N+1 при работе с коллекциями

@ElementCollection по умолчанию загружается лениво. Обращение к getTags() внутри цикла по 100 сущностям порождает 100 запросов, а обращение вне активной транзакции выбрасывает LazyInitializationException. ArrayField и Prisma scalar lists такой стратегии не имеют: массив приходит вместе со строкой, и отдельного состояния загрузки у него нет.

Hibernate N+1 fetch join: как применить и какие подводные камни

Самый прямой способ убрать лишние запросы - собрать коллекцию одним JOIN:

SELECT DISTINCT a FROM Article a JOIN FETCH a.tags WHERE a.id > :id

DISTINCT здесь обязателен: без него строка статьи продублируется столько раз, сколько у неё тегов, а Hibernate вернёт дубли в списке. Второй подводный камень - пагинация. Для коллекции с fetch join Hibernate вынужден вычитывать все строки в память и только потом применять setFirstResult, поэтому в логах появляется предупреждение HHH000104. При постраничном выводе надёжнее @BatchSize или @Fetch(FetchMode.SUBSELECT): они делают один дополнительный запрос на пачку владельцев. Третий вариант - @EntityGraph(attributePaths = "tags") в репозитории Spring Data, который собирает JOIN без ручного JPQL.

Django prefetch_related и select_related: когда что использовать

ArrayField не порождает N+1: теги лежат в той же строке, и prefetch_related для них не нужен. Методы нужны для связей ManyToMany и ForeignKey. select_related делает SQL JOIN и подходит для связей «один к одному» и «многие к одному». prefetch_related выполняет отдельный запрос и соединяет объекты в Python, поэтому применим к ManyToMany и обратным ForeignKey:

Article.objects.prefetch_related('tags')

Для prefetch_related с условиями используйте объект Prefetch с queryset: это позволяет ограничить набор тегов, не вытягивая всю таблицу. Разбор типовых причин лишних запросов и готовые решения по кэшированию и пагинации собраны в статье про устранение медленных запросов к базе.

Prisma include: как загружать списки без лишних запросов

Скалярные списки Prisma загружает вместе с записью, поэтому отдельного запроса на них не возникает. Для связей используется include:

prisma.article.findMany({ include: { comments: true } })

Prisma выполняет либо один JOIN, либо отдельный запрос на связь и склеивает результат в клиенте. Фильтровать скалярный список через include нельзя: доступны только операторы has, hasEvery и hasSome в блоке where.

Ограничения ORM при работе с нативными массивами и обход через raw SQL

Ни один из трёх ORM не покрывает весь набор операций над массивами. Django не создаёт GIN-индекс автоматически, Prisma не умеет unnest и array_agg, Hibernate вообще не маппит нативный ARRAY через @ElementCollection. Когда возможностей DSL не хватает, остаётся raw SQL с параметризацией.

Django: raw SQL и GinIndex для ArrayField

Индекс добавляется в Meta модели, а не через отдельную миграцию руками:

class Meta:
    indexes = [GinIndex(fields=["tags"])]

Для выборки по элементу массива подходит raw-запрос с параметром:

Article.objects.raw("SELECT * FROM article WHERE %s = ANY(tags)", ["postgres"])

Метод raw возвращает объекты модели и поддерживает доступ к полям, но не умеет prefetch_related, поэтому связанные данные придётся дочитывать отдельно. Для сложных выборок, где нужен только набор значений, используйте connection.cursor() и fetchall().

Hibernate: native query для работы с массивами PostgreSQL

Нативный запрос обходит HQL-парсер, и синтаксис ARRAY становится доступен напрямую:

List<Article> list = session.createNativeQuery(
        "SELECT * FROM article WHERE :tag = ANY(tags)", Article.class)
    .setParameter("tag", "postgres")
    .list();

Такой запрос возвращает управляемые сущности, однако ленивая загрузка их коллекций настраивается не автоматически: часть полей подгрузится отдельными запросами при обращении. Главный риск - привязка к диалекту PostgreSQL, из-за которой код перестанет работать на MySQL или Oracle без правок.

Prisma: $queryRaw для сложных операций

Тегированный шаблон $queryRaw подставляет параметры безопасно и возвращает типизированный результат:

const rows = await prisma.$queryRaw`
  SELECT id, title FROM "Article" WHERE ${tag} = ANY(tags)
`;

Использовать $queryRawUnsafe допустимо только со статичной строкой без внешних данных. Склейка пользовательского ввода в строку запроса открывает SQL-инъекцию, поскольку параметризация в этом методе не применяется.

Практические рекомендации для DevOps: что выбрать и как мониторить

СценарийЧто выбратьПочему
PostgreSQL, нужен поиск по элементам, до сотен значений в массивеDjango ArrayField или Prisma scalar listsОдна колонка, один SELECT, GIN-индекс для условий ANY и &&
Переносимость между СУБД@ElementCollection или отдельная таблицаСтандартный SQL без диалектных типов
Сложные запросы: unnest, array_agg, срезыraw SQL поверх ORMDSL не выражает эти операции
Массив на тысячи элементовОтдельная таблица или документная СУБДПерезапись массива целиком дорога по WAL

Чек-лист: как избежать N+1 и лишних JOIN

  1. Включите логирование SQL: LOGGING в Django, hibernate.show_sql и format_sql в Hibernate, log: ['query'] в Prisma.
  2. Пройдите основной сценарий выборки и посчитайте запросы. Повторяющийся шаблон с разными параметрами внешнего ключа указывает на N+1.
  3. Добавьте JOIN FETCH с DISTINCT для @ElementCollection, prefetch_related для связей в Django, include для связей в Prisma.
  4. Проверьте план через EXPLAIN ANALYZE и убедитесь, что для условий по массиву используется Bitmap Index Scan по GIN-индексу, а не Seq Scan.
  5. Замерьте запись после добавления индекса: GIN ускоряет чтение, но замедляет INSERT и UPDATE.

Мониторинг и профилирование запросов с массивами

Базовый набор метрик одинаков для всех трёх ORM. В PostgreSQL включите pg_stat_statements и смотрите топ по total_exec_time и по calls; высокое значение calls при малом mean_time - признак N+1. Для Django добавьте Django Debug Toolbar в окружение разработки, для Hibernate - статистику сессии через hibernate.generate_statistics=true, для Prisma - встроенные метрики клиента. Сравнивайте число запросов на один HTTP-запрос до и после правок: цель - стабильное значение, не зависящее от числа строк в выдаче.

Индекс GIN по колонке ARRAY и таблица-коллекция с индексом по внешнему ключу перекладывают нагрузку с чтения на запись. На интенсивных вставках это заметно: массив из 50 тегов перезаписывается целиком при каждом обновлении, а составной индекс по (article_id, tag) требует поддержки при каждом INSERT. Если база живёт в облаке, разумно вынести её на управляемый сервис и не тратить время на ручную настройку репликации и бэкапов; подходящий вариант с PostgreSQL и Kubernetes для этого даёт Timeweb Cloud.

Перед выбором СУБД и механизма хранения сверьте требования по нагрузке и типу данных с общим сравнением вариантов в статье о том, как выбрать СУБД и организовать хранение данных, а влияние типа накопителя на задержки и IOPS разобрано в обзоре хранения данных в 2026 году. Начните с логирования SQL на реальном сценарии: один замер числа запросов на список из 100 сущностей покажет, нужен ли вообще переход на нативные массивы или достаточно настроить загрузку коллекций.

Поделиться:
Сохранить гайд? В закладки браузера