Короткий ответ: транзакционные данные, по которым нужна выборка элементов, храните в отдельной дочерней таблице; полуструктурированные и редко обновляемые наборы в колонке JSONB; простые однородные списки в нативном массиве PostgreSQL; сериализацию оставляйте кэшу и логам.
Реляционная модель требует атомарных значений в ячейке. Массив ломает это правило, и СУБД теряет часть того, ради чего её выбирают: внешние ключи на каждый элемент, CHECK-ограничения на значения и точную статистику планировщика по отдельным элементам. Пять подходов ниже отличаются не синтаксисом, а тем, какие гарантии вы теряете и какую цену за это платите.
Почему хранение массивов в реляционной БД - это компромисс
Первая нормальная форма запрещает списки в одной ячейке. Нарушив её, вы обмениваете целостность и гибкость запросов на простоту схемы и скорость чтения одной строки. Обмен не бывает бесплатным: там, где JSONB избавляет от JOIN, вы получаете перезапись всего документа и слабый контроль типов; там, где дочерняя таблица даёт внешние ключи, вы платите JOIN-ами и ростом стоимости вставки пропорционально числу элементов.
Практическое решение: TimeWeb
В 2026 году рабочих стратегий пять:
- отдельная дочерняя таблица со связью один-ко-многим;
- денормализованные колонки фиксированной длины (col_1, col_2, col_3);
- колонка JSON или JSONB;
- нативный массив PostgreSQL (int[], text[], uuid[]);
- сериализация массива в text или bytea.
PostgreSQL 17, MySQL 8.4 LTS, AWS RDS и Yandex Managed PostgreSQL поддерживают JSON-колонки, а PostgreSQL дополнительно массивы с GIN-индексами. Стандарт SQL:2023 закрепил конструкторы SQL/JSON (JSON_ARRAY, JSON_OBJECT, JSON_TABLE, JSON_VALUE), и они уже реализованы в PostgreSQL 16 и 17, что снижает разницу между «родным» JSON и обычными таблицами. Наличие типа в СУБД не означает, что он подходит вашей задаче.
Выбор влияет на три вещи сразу: возможность индексировать отдельные элементы, уровень целостности данных и стоимость будущей миграции. Поэтому решение принимают на этапе проектирования схемы, а не после первой сотни тысяч строк. Если вы ещё определяетесь с самой СУБД под учётные данные, логи или кэш, начните со сравнительного разбора движков, а уже потом выбирайте способ упаковки массива.
Отдельная таблица со связью один-ко-многим: классика нормализации
Канонический реляционный вариант: родительская таблица и дочерняя с внешним ключом. Порядок элементов задаёт колонка position, потому что строки в таблице не упорядочены.
CREATE TABLE orders (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
position smallint NOT NULL,
product_id bigint NOT NULL,
qty integer NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, position)
);
CREATE INDEX order_items_product_idx ON order_items (product_id);
Плюсы подхода: внешние ключи на элементы, CHECK и NOT NULL для каждого значения, обычные B-tree индексы, точная статистика, поддержка ACID на уровне всей операции. Минусы: больше JOIN-ов, сложнее запросы, стоимость вставки растёт линейно с числом элементов, а полнотекстовый поиск по «массиву» превращается в агрегацию.
Пример SQL: чтение и запись
Запись выполняется в одной транзакции, чтобы не оставить заказ без позиций.
BEGIN;
INSERT INTO orders (customer_id) VALUES (42) RETURNING id;
-- получили id = 101
INSERT INTO order_items (order_id, position, product_id, qty)
VALUES (101, 1, 777, 2),
(101, 2, 778, 1);
COMMIT;
Чтение всего массива одной строкой через агрегацию:
SELECT o.id,
array_agg(i.product_id ORDER BY i.position) AS products,
sum(i.qty) AS total_qty
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.customer_id = 42
GROUP BY o.id;
Для JSON-ответа API замените array_agg на jsonb_agg(jsonb_build_object('product_id', i.product_id, 'qty', i.qty) ORDER BY i.position). Поиск заказов, где встречается конкретный товар, остаётся обычным индексным запросом:
SELECT o.id FROM orders o JOIN order_items i ON i.order_id = o.id WHERE i.product_id = 777;
Главная практическая ловушка здесь - N+1 запрос в ORM. Загрузка 200 заказов без жадного соединения даёт 201 запрос к базе вместо одного. Лечится JOIN FETCH, предзагрузкой (preload) или отдельным запросом с IN по списку идентификаторов.
Когда подход не подходит
Нормализация избыточна в четырёх случаях: массив всегда обновляется целиком, а не поэлементно; у элементов нет собственных атрибутов; нужна атомарная замена всего списка одной операцией; выборка по отдельным элементам не требуется. Список тегов статьи без счётчиков, весов и дат часто проще держать в text[], чем заводить третью таблицу. Подробное сравнение компромиссов нормализации и денормализации с примерами и чек-листом разобрано в отдельном материале.
Критерий выбора простой: если массив - это сущность с атрибутами и по ней строят отчёты, берите 1:N. Для транзакционных систем это по-прежнему вариант по умолчанию.
Денормализованные колонки: когда массив фиксированной длины
Если значений всегда ровно три, отдельная таблица и JSONB избыточны. Колонки фиксированной длины дают самый быстрый доступ без JOIN и полный контроль типов.
CREATE TABLE resolvers (
id bigserial PRIMARY KEY,
ip1 inet NOT NULL,
ip2 inet,
ip3 inet,
CHECK (ip1 IS DISTINCT FROM ip2)
);
Плюсы: прозрачные планы выполнения, индексы B-tree по каждой позиции, нулевое кодирование и декодирование, простые дампы и бэкапы. Минусы: жёсткая длина, NULL-ы как заполнители отсутствующих значений, отсутствие CHECK на «непустой» набор целиком, болезненное изменение количества позиций.
Добавление четвёртого DNS-сервера требует ALTER TABLE ADD COLUMN, а перестановка значений местами ломает семантику позиций. При миллионах строк ALTER TABLE без перезаписи возможен только при добавлении колонки с NULL или с DEFAULT, где PostgreSQL 11+ обходится метаданными. Любая логика «сдвинуть всё влево» после удаления элемента остаётся на приложении.
Ориентир: 2-5 значений, которые меняются редко. Как только длина становится переменной, подход превращается в ручной JSON с издержками обоих миров.
JSON и JSONB: гибкость без изменения схемы
В PostgreSQL колонка jsonb хранит данные в разобранном бинарном виде, поддерживает GIN-индексы и операторы @>, ?, ?|, ?&. Тип json хранит исходную строку и не индексируется. В MySQL роль такого типа играет JSON: он всегда разбирается при чтении, зато поддерживает multi-valued индексы при приведении к типизированному массиву.
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text NOT NULL,
tags jsonb NOT NULL DEFAULT '[]'::jsonb
);
INSERT INTO products (name, tags)
VALUES ('Роутер', jsonb_build_array('network', 'wifi', '5ghz'));
CREATE INDEX products_tags_gin ON products USING GIN (tags jsonb_path_ops);
SELECT id, name FROM products WHERE tags @> '["wifi"]'::jsonb;
Пример SQL: чтение и запись
Разворачивание массива в строки для аналитики:
SELECT p.id, t.tag FROM products p CROSS JOIN LATERAL jsonb_array_elements_text(p.tags) AS t(tag) WHERE t.tag LIKE 'wi%';
Точечное обновление одного элемента переписывает весь документ, поэтому планировщик обновляет строку целиком:
UPDATE products
SET tags = jsonb_set(tags, '{0}', to_jsonb('routing'::text), false)
WHERE id = 1;
UPDATE products
SET tags = tags || to_jsonb('poe'::text)
WHERE id = 1;
Разница операторов критична: -> возвращает jsonb, ->> возвращает text. Сравнение tags->0 = '"wifi"' работает, а tags->>0 = 'wifi' требует текстового сравнения и не использует GIN-индекс выражения. Индекс jsonb_path_ops компактнее и быстрее для @>, но не поддерживает операторы существования ключа ? и ?|, для них нужен jsonb_ops.
JSONB vs нативные массивы PostgreSQL
Однородный список строк или чисел удобнее держать в text[] или int[]: меньше накладных расходов, короче запросы, работает array_length и array_position. JSONB выигрывает, когда элементы разнородные или вложенные: список объектов с полями name, weight и enabled в массиве не выразить.
Индексация: GIN работает для обоих вариантов, но операторы различаются. Для массивов это @>, <@ и &&, для JSONB дополнительно ?, ?| и поиск по пути. По скорости простых операций на однородных данных text[] обычно быстрее, потому что не разбирает структуру на каждый доступ. Для тегов подойдёт text[], для списка характеристик товара - jsonb.
В MySQL аналог GIN-индекса появился в 8.0.17 как multi-valued index: CREATE INDEX idx ON t ((CAST(tags->'$' AS UNSIGNED ARRAY))); он ускоряет MEMBER OF, JSON_CONTAINS и JSON_OVERLAPS, но работает только с числовыми значениями. Подробный разбор четырёх способов хранения массивов в MySQL, включая генеративные колонки и миграцию, собран отдельно.
Критерий: массив разнородный, обновляется редко, нужна выборка по элементам. JSONB остаётся стандартом для полуструктурированных данных, но не заменяет нормализацию там, где важна целостность.
Нативные массивы PostgreSQL: text[], int[] и операторы
Массивы встроены в типовую систему: int[], text[], uuid[], numeric[][]. Размерность не фиксирована, но и не контролируется: колонка text[] примет и один элемент, и тысячу.
CREATE TABLE posts (
id bigserial PRIMARY KEY,
tags text[] NOT NULL DEFAULT '{}'
);
INSERT INTO posts (tags) VALUES (ARRAY['sql','postgres','jsonb']);
CREATE INDEX posts_tags_gin ON posts USING GIN (tags);
SELECT id FROM posts WHERE tags @> ARRAY['sql'];
SELECT id FROM posts WHERE tags && ARRAY['sql','mysql'];
Пример SQL: чтение и запись
SELECT id, unnest(tags) AS tag FROM posts WHERE id = 1; UPDATE posts SET tags = array_append(tags, 'tuning') WHERE id = 1; UPDATE posts SET tags = array_remove(tags, 'tuning') WHERE id = 1; SELECT id FROM posts WHERE 'sql' = ANY(tags); SELECT id, array_length(tags, 1) AS tag_count, array_position(tags, 'sql') AS pos FROM posts;
Оператор @> проверяет вхождение всех элементов правого массива, && находит пересечение, <@ проверяет вложенность. GIN-индекс ускоряет все три, но при обновлении колонки строка переписывается целиком, а HOT-обновление невозможно, потому что индексируемая колонка изменилась. Частые правки одного и того же списка дают распухание таблицы и требуют внимания к autovacuum и fillfactor.
Ограничения: на элементы массива нельзя повесить внешний ключ, нельзя задать CHECK на каждый элемент по отдельности (только на весь массив, например CHECK (tags <@ ARRAY['sql','mysql','postgres'])), нельзя хранить атрибуты элемента. Порядок элементов сохраняется, но семантика порядка на приложении.
Синтаксис, функции array_agg, unnest и разбор планов выполнения с EXPLAIN ANALYZE для всех операторов разобраны в практическом руководстве по массивам PostgreSQL.
Критерий: однородный список без атрибутов, нужен отбор по вхождению. В 2026 году это рабочий вариант для тегов, ACL-списков, наборов идентификаторов, но не замена таблице связки.
Сериализация: хранение массива как строки или BLOB
Подход без иллюзий: массив упаковывают в JSON-строку, CSV, MessagePack, Protobuf, pickle или PHP serialize и кладут в text либо bytea.
CREATE TABLE api_cache (
cache_key text PRIMARY KEY,
payload bytea NOT NULL,
format text NOT NULL,
expires_at timestamptz NOT NULL
);
CREATE INDEX api_cache_expires_idx ON api_cache (expires_at);
Плюсы: полный контроль над форматом, компактность (MessagePack и Protobuf экономят 30-60% против JSON на типичных структурах), отсутствие накладных расходов на разбор СУБД. Минусы жёстче: индексация элементов невозможна, целостность держится только на приложении, данные нельзя прочитать из SQL без внешней функции, а несовместимость версий формата ломает чтение старых записей.
Отдельный риск: десериализация недоверенных данных. Форматы, умеющие создавать объекты (pickle, PHP serialize, Java serialization), дают путь к удалённому выполнению кода при разборе подложенного значения. Для bytea в кэше держите только те форматы, которые не исполняют код: JSON, MessagePack, Protobuf, CBOR.
Критерий: массив не участвует в запросах и нужен только приложению. Кэш ответов API, сырые логи, снимки состояний, полезные нагрузки очередей. Если по элементам планируется поиск, подход исключается сразу.
Сравнительная таблица подходов
| Подход | Схема | Поиск по элементам | Целостность | Цена обновления | Когда выбирать |
|---|---|---|---|---|---|
| Дочерняя таблица 1:N | Родитель + дочерняя с FK и position | B-tree, обычный JOIN | Внешние ключи, CHECK, NOT NULL | Растёт с числом элементов, точечные UPDATE дешёвые | Элементы с атрибутами, отчёты, строгая целостность |
| Денормализованные колонки | col_1 ... col_n в строке | B-tree по каждой колонке | Типы и CHECK, много NULL | Самая быстрая, пока длина фиксирована | 2-5 значений, меняются редко |
| JSONB | Одна колонка jsonb | GIN (jsonb_path_ops, jsonb_ops) | Слабая, контроль в приложении | Перезапись всего документа, HOT недоступен | Разнородные и вложенные данные, редкие правки |
| Нативные массивы PostgreSQL | text[], int[], uuid[] | GIN, операторы @>, <@, && | Тип элементов, без FK | Перезапись всего массива | Однородный список, отбор по вхождению |
| Сериализация в text/bytea | JSON, CSV, MessagePack, Protobuf | Нет | Нет на уровне СУБД | Дешёвая, строка пишется целиком | Кэш, логи, блобы, данные только для приложения |
В 2026 году PostgreSQL 17 и облачные managed-сервисы поддерживают все пять вариантов, включая GIN-индексы и SQL/JSON. Разница остаётся в гарантиях и эксплуатационной цене, а не в доступности синтаксиса.
Критерии выбора: частота обновлений, объём, индексация
Проходите по пунктам в этом порядке, он отсекает лишние варианты за минуту.
- Нужна выборка строк по содержимому массива? Если нет, JSON-строка в text или bytea закрывает задачу. Если да, остаются 1:N, нативные массивы и JSONB.
- Есть ли у элементов собственные поля (цена, количество, дата, вес сортировки)? Если да, вариант один: дочерняя таблица 1:N.
- Как часто меняется массив? Ежесекундные правки отдельных элементов выдерживает 1:N. Редкие правки целиком допускают JSONB и text[].
- Какой размер? До десятков элементов JSONB и массивы удобны. Сотни и тысячи элементов на строку лучше разложить в таблицу: там работают batching, партиционирование и точечные UPDATE.
- Нужны ли внешние ключи и ограничения на элементы? Если да, 1:N, других вариантов нет.
- Нужен ли отбор по нескольким массивам одновременно (пересечение, вложенность)? text[] и JSONB с GIN справляются, но составные условия быстро упираются в размер индекса.
Типовые кейсы: теги статьи без атрибутов - text[] с GIN; список IP-адресов DNS - три колонки inet; позиции заказа с количеством - 1:N; характеристики товара разной структуры - jsonb; кэш ответов внешнего API - bytea с MessagePack.
Если база живёт в облаке, проверьте заранее, какие версии и расширения доступны: в managed-сервисах (например, в облачной инфраструктуре Timeweb Cloud) набор поддерживаемых версий PostgreSQL и расширений влияет на то, какие подходы вы сможете применить без переезда.
Типичные ошибки при миграции между подходами
Смена стратегии хранения это не ALTER TABLE, а проект с проверкой данных и откатом. Ошибки, которые чаще всего ломают продакшен:
- миграция без транзакции: часть строк уже в новой колонке, часть в старой, приложение видит разные данные при разных запросах;
- потеря порядка элементов при переносе из JSON-массива в таблицу без колонки position;
- несовместимость типов: text[] не примет числовые значения без приведения, uuid[] отклонит строки с пробелами;
- миграция «одним UPDATE на всю таблицу»: блокировки, рост WAL, репликационное отставание и раздувание таблицы;
- забытые индексы: перенесли данные в jsonb, но не построили GIN, и запросы с @> уходят в seq scan;
- перегрузка JSONB: документ размером 3 КБ уходит в TOAST, любое обновление читает и пишет внешние страницы;
- отсутствие обратной совместимости: старый код читает колонку, которую вы уже удалили.
Рабочая схема миграции в четыре шага. Сначала добавьте новое хранилище и включите двойную запись: приложение пишет в старое и новое место одновременно. Затем перенесите исторические данные пакетами по 5-10 тысяч строк с COMMIT на каждый пакет, сверяя контрольные суммы (количество элементов, суммы, хеши). После сверки переключите чтение на новый источник, оставив старый на неделю для отката. Только потом удалите старую колонку или таблицу, отдельной миграцией и после бэкапа.
Инструменты вроде Flyway и Liquibase версионируют шаги и не дают применить одну миграцию дважды, но не проверяют корректность данных. Отдельно планируйте перестройку индексов: GIN с fastupdate накапливает pending list, и после массовой загрузки имеет смысл выполнить REINDEX CONCURRENTLY, чтобы вернуть предсказуемое время поиска.
Итог: что выбрать в 2026 году
Транзакционные системы с требованиями к целостности держат массивы в дочерней таблице со связью один-ко-многим. Полуструктурированные данные, которые редко обновляются и требуют гибкой схемы, живут в JSONB. Однородные списки без атрибутов, по которым нужен отбор, удобнее в нативных массивах PostgreSQL с GIN-индексом. Колонки фиксированной длины оправданы для двух-пяти редко меняющихся значений. Сериализация остаётся инструментом для кэша, логов и блобов, где поиск по элементам не нужен.
Универсального ответа нет и в 2026 году, но есть проверяемая последовательность: определите, нужна ли выборка по элементам, есть ли атрибуты, как часто меняется массив и какой он длины. Прогоните свой случай по чек-листу выше, а затем проверьте план выполнения на реальном объёме данных. Обзор типов и способов организации хранения в 2026 году с цифрами по IOPS и задержкам поможет связать выбор структуры данных с уровнем инфраструктуры.