Как 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 |
|---|---|---|---|---|---|
| Django | ArrayField | PostgreSQL и производные | Колонка типа ARRAY | Нет | Нет |
| Hibernate | @ElementCollection | Любая, через таблицу-связку | Отдельная таблица коллекции | Да, JOIN или доп. SELECT | Да, при LAZY |
| Prisma | scalar 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 поверх ORM | DSL не выражает эти операции |
| Массив на тысячи элементов | Отдельная таблица или документная СУБД | Перезапись массива целиком дорога по WAL |
Чек-лист: как избежать N+1 и лишних JOIN
- Включите логирование SQL: LOGGING в Django, hibernate.show_sql и format_sql в Hibernate, log: ['query'] в Prisma.
- Пройдите основной сценарий выборки и посчитайте запросы. Повторяющийся шаблон с разными параметрами внешнего ключа указывает на N+1.
- Добавьте JOIN FETCH с DISTINCT для @ElementCollection, prefetch_related для связей в Django, include для связей в Prisma.
- Проверьте план через EXPLAIN ANALYZE и убедитесь, что для условий по массиву используется Bitmap Index Scan по GIN-индексу, а не Seq Scan.
- Замерьте запись после добавления индекса: 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 сущностей покажет, нужен ли вообще переход на нативные массивы или достаточно настроить загрузку коллекций.