
Начать работу с массивами в PostgreSQL проще простого: объявили колонку как integer[], вставили значения — и готово.
Или вообще собрали массив на лету:
SELECT '{1,2,3}'::int[]; SELECT array[1,2,3];
int4 --------- {1,2,3} (1 row) array --------- {1,2,3} (1 row)
Официальная документация дает неплохую базу. Но за этим простым интерфейсом скрывается куда более сложная механика, чем многие привыкли думать. Массивы в PostgreSQL — это не просто «списки, которые можно засунуть в поле таблицы». У них своя стратегия работы с памятью, собственная логика индексации и целый ворох граничных случаев.
В статье подробно разберем аспекты работы с массивами, которые могут неожиданно создать проблемы в продакшене.
Искушение документной моделью данных
Стоп, речь пойдет о JSONB-массивах? Вовсе нет. Дело в том, что сама концепция массивов в реляционных СУБД — это, по сути, докум��нтное хранилище под прикрытием.
При проектировании баз данных принцип локальности ускоряет выборку за счет физической близости связанных данных. И неважно, что вы используете — нативный тип integer[] или JSON-список [1, 2, 3]. Вы принимаете одно и то же архитектурное решение: ставите локальность выше нормализации.
Сохраняя tag_ids в массиве, вы встраиваете связанные данные прямо в строку таблицы — точно так же, как NoSQL база встраивает вложенные документы. Само по себе это не ошибка. Документные БД появились не зря: никаких джойнов (JOIN), простое чтение, естественное соответствие объектам в коде.
Но PostgreSQL — это реляционная СУБД, построенная вокруг реляционной модели, где:
внешние ключи гарантируют ссылочную целостность;
джойны связывают нормализованные таблицы;
обновления затрагивают конкретные строки, а не списки целиком.
Используя массивы, вы получаете удобство документной модели, расплачиваясь реляционными гарантиями. Для элементов массива не работают ни внешние ключи, ни действия вроде ON DELETE CASCADE. Удалите запись из tags — и осиротевший ID так и останется в массиве навечно.
Существует основное правило: если всплыла потребность в ссылочной целостности — вам, скорее всего, нужна таблица-связка (link table), а не массив. Массивы хороши для данных, которые живут и умирают вместе с родительской строкой. Но не для связей, перекинутых между разными таблицами.
Например, связь «автор — пост в блоге» (один автор пишет кучу постов) — это типичная реляционная модель. А вот белый список IP-адресов для сервисного аккаунта — отличный кандидат на массив, так как он нужен только этой конкретной сущности.
Учитывая гибкость JSONB, резонно спросить: зачем вообще возиться с более капризными нативными типами? Ответ кроется в «скучной» стороне СУБД — предсказуемости и эффективности. Колонка integer[] гарантирует, что внутри лежат только целые числа, и ничего кроме.
Плюс массивы эффективнее расходуют место при хранении примитивов, потому что не тащат за собой оверхед метаданных, без которого не обходятся JSON-объекты.
Синтаксические ловушки
Будем считать, что основы по массивам вы и так знаете, так что базовую базу разбирать не будем.
Массивы не обязаны начинаться с единицы
По умолчанию индексация SQL-массивов начинается с 1. И, казалось бы, нет ничего плохого в том, чтобы итерироваться по ним вот так:
FOR i IN 1 .. array_length(fruits, 1) LOOP RAISE NOTICE 'Index % contains: %', i, fruits[i]; END LOOP;
Проблемы начинаются, когда встречается массив с произвольными границами — а PostgreSQL такое допускает:
SELECT '[-5:-3]={10,20,30}'::int[];
Чтобы не стрелять себе в ногу и правильно итерироваться по любому массиву, всегда используйте array_lower() и array_upper() в PL/pgSQL:
SELECT array_lower('[-5:-3]={10,20,30}'::int[], 1);
array_lower ------------- -5 (1 row)
Или generate_subscripts() в чистом SQL:
SELECT generate_subscripts('[-5:-3]={10,20,30}'::int[], 1);
generate_subscripts --------------------- -5 -4 -3 (3 rows)
Потерянные размерности
Создавая таблицу, вы наверняка ожидаете строгой типизации. И это верно для всего... кроме размерности массивов. Казалось бы, integer[][] должен обеспечить двумерную матрицу. Как бы не так. Синтаксис [] — это, по сути, синтаксический сахар. По умолчанию PostgreSQL вообще не контролирует размерность подмассивов на уровне схемы.
CREATE TABLE dimension_test ( matrix integer[][] ); INSERT INTO dimension_test VALUES ('{{1,2}, {3,4}}'); -- ошибки не будет INSERT INTO dimension_test VALUES ('{1,2,3}'); -- 3D-матрица тоже подойдет INSERT INTO dimension_test VALUES ('{{{1,2},{3,4}}, {{5,6},{7,8}}}');
Если хотите железно зафиксировать размерность массива, на определение типа полагаться нельзя. Нужно использовать ограничение CHECK:
CREATE TABLE strict_matrix ( -- dims: убеждаемся, что массив двумерный -- array_length: следим, чтобы размер был ровно 3x3 board integer[] CHECK (array_ndims(board) = 2 AND array_length(board, 1) = 3) );
INSERT INTO strict_matrix VALUES ( ARRAY[ [0, 1, 0], [1, 1, 0], [0, 0, 1] ] );
Единственное, за чем Postgres всё-таки следит, — это однородность массивов на каждом уровне вложенности. То есть вставить подмассивы разной длины он вам все-таки не даст.
INSERT INTO dimension_test VALUES ('{{1,2}, {3}}'); ERROR: malformed array literal: "{{1,2}, {3}}" LINE 1: INSERT INTO dimension_test VALUES ('{{1,2}, {3}}'); ^ DETAIL: Multidimensional arrays must have sub-arrays with matching dimensions.
Срезы массивов
При обращении к элементам массива важно помнить, что синтаксис [1] и [1:1] — это разные вещи. Первый обращается к элементу, второй работает как конструктор среза.
select matrix[1][1] from dimension_test; matrix -------- 1 (1 row)
При нарезке массива, даже если срез состоит из одного элемента, вернется одноэлементный массив, а не скалярное значение.
select matrix[1:1][1:1], matrix[1][1:1], matrix[1:1][1] from dimension_test ; matrix | matrix | matrix --------+--------+-------- {{1}} | {{1}} | {{1}} (1 row)
Самое гадкое
Доступ к элементам массива реализован слишком уж снисходительно по отношению к пользователю, что только мешает отлавливать баги:
-- выход за границы массива возвращает NULL SELECT (ARRAY[1,2,3])[10]; array -------- (null) (1 row)
-- срез за пределами массива возвращает пустой массив SELECT (ARRAY[1,2,3])[5:10]; array ------- {} (1 row)
Но самый взрывающий мозг момент, особенно для тех, кто пришел из других языков, — это то, что PostgreSQL воспринимает многомерные массивы как единую матрицу, а не как «массив массивов».
-- неверная размерность SELECT (ARRAY[[1,2],[3,4]])[1]; array -------- (null) (1 row)
По опыту работы с другими языками, вы ожидаете получить {1,2}, но Postgres так не умеет. Он пытается вытащить первую ячейку матрицы и падает (потому что индекс неполный).
И, перефразируя аналогию Флетчера про «double bubble» (с которой тоже всё пошло не по плану — бонусные баллы тем, кто понял отсылку), нотацией срезов это не лечится.
select ('{{1,2},{3,4}}'::int[])[1:1]; int4 --------- {{1,2}} (1 row)
Единственный способ разрулить эту головоломку — развернуть срез через unnest и собрать обратно через агрегацию.
SELECT array_agg(val) FROM unnest(('{{1,2},{3,4}}'::int[])[1:1]) val; array_agg ----------- {1,2} (1 row)
Только имейте в виду, что array_agg не гарантирует сохранение порядка, если вы явно не пропишете ORDER BY . С простыми запросами unnest это обычно прокатывает, но полагаться на неявную сортировку — так себе идея.
Альтернатива — прогнать туда-обратно через JSONB. Выглядит, конечно, как костыль, с какой стороны ни посмотри. Поэтому, если вам нужно работать со сложными многомерными структурами, где каждый подмассив имеет самостоятельное значение — не мучайтесь, берите JSONB. Он будет работать ровно так, как вы от него ожидаете.
Индексация массивов
Хотя на колонку с массивом технически можно повесить B-tree индекс, толку от него будет мало. Он спасет, только если вы ищете полное совпадение массивов (=) или сортируете их в лексикографическом (словарном) порядке.
-- что больше? Правильный ответ: B -- A: {1, 1000, 1000} -- B: {2, 0}
Поэтому для реальных операций по поиску внутри массива B-tree индекс бесполезен.
Для работы с ними вам нужен GIN (Generalized Inverted Index — обобщенный инвертированный индекс). Если B-tree — это телефонная книга, то GIN — это предметный указатель в конце этой книги. Чтобы найти один или несколько элементов, надо отыскать все возможные места их упоминания, а потом пересечь результаты.
CREATE INDEX posts_by_tags ON posts USING GIN (tags);
GIN заточен под операции с множествами: главное — наличие элемента, порядок не важен. Вот какие операторы GIN поддерживает для массивов:
Вхождение
@>— ищет строки, которые содержат ВСЕ указанные элементы.
-- содержит ВСЕ теги tags @> '{urgent, bug}'
Пересечение
&&— ищет строки, содержащие ХОТЯ БЫ ОДИН из элементов.
-- содержит bug ИЛИ feature tags && '{bug, feature}'
Есть ещё
<@и=(равенство), но с ними всё и так понятно.
Две стороны ANY
А вот тут начинается веселье. Вы наверняка часто слышали совет: «Не используйте динамический SQL для списков IN, используйте ANY». Совет правильный, но со своими нюансами.
Оператор ANY ведет себя радикально по-разному в зависимости от того, с какой стороны сравнения находится массив.
Совет использовать ANY работает, когда вы передаете списки в базу данных как параметры. Вместо того чтобы генерировать простыню из 100 параметров (WHERE id IN ($1, $2, ... $100)), которая раздувает кэш запросов и заставляет базу каждый раз делать жесткий парсинг (hard-parse), вы передаете один параметр-массив.
-- хорошо: один параметр, один план запроса SELECT * FROM users WHERE id = ANY($1::int[]);
Ловушка в том, чтобы полагать, что этот синтаксис так же эффективен при запросах к колонкам. Спойлер: нет. Если вы используете ANY для проверки наличия значения внутри колонки таблицы, вы фактически заставляете базу бежать в цикле.
-- плохо: GIN не поддерживает ANY; запрос свалится в Seq Scan SELECT * FROM tickets WHERE 'feature' = ANY(tags);
Когда вы пишете WHERE 'feature' = ANY(tags), вы не используете магии массивов. Вы применяете скалярный оператор равенства =, обернутый в цикл. А поскольку скалярный = не входит в класс операторов array_ops, планировщик понимает, что индекс тут не помощник, и сваливается в последовательное сканирование (Seq Scan).
Правильный способ переписать запрос:
-- снова хорошо SELECT * FROM tickets WHERE tags @> ARRAY['feature'];
Быстрые обновления и их цена
Поскольку GIN-индекс заточен под работу с множествами, его поддержка обходится дорого. В B-tree одна строка = одна запись в индексе. В GIN одна строка = N записей в индексе, где N — количество элементов в вашем массиве.
Это приводит к размножению записей. Чтобы нивелировать этот эффект, PostgreSQL по умолчанию использует механизм «быстрых обновлений» (fast update). Суть стратегии такая: новые записи сначала попадают в pending list (неотсортированный временный буфер) и сливаются в основную структуру индекса только потом, во время VACUUM.
Учитывая хаотичную природу GIN, это то самое исключение из принципа «VACUUM is a Lie» (доклад о том, что вакуум не освобождает место физически). В этом случае VACUUM действительно занимается структурным обслуживанием индекса.
С одной стороны, это делает INSERT терпимыми, но с другой — может тормозить SELECT. При каждом обращении к индексу Postgres вынужден сканировать и упорядоченный основной индекс, и весь тот беспорядок, что накопился в pending list. Если этот буфер разрастется, производительность запросов начинает ползти вниз.
Если у вас сценарий с интенсивным чтением и редкими записями (read-heavy), эту опцию лучше отключить, чтобы гарантировать стабильную скорость чтения.
CREATE INDEX posts_by_tags ON posts USING GIN (tags) WITH (fastupdate = off);
Хранение и модификация
Пора вернуться к документной модели. В PostgreSQL строки неизменяемы (спасибо MVCC) — никаких «обновлений на месте» не существует, а массивы хранятся как атомарные значения. Отсюда вытекает неприятный факт: чтобы изменить даже один-единственный элемент массива, PostgreSQL придется скопировать и переписать всю строку целиком.
UPDATE user_activity SET event_ids = event_ids || 10001 WHERE user_id = 50;
Каждое добавление элемента переписывает весь массив, что, по сути, означает перезапись всей строки таблицы.
TOAST в действии
Когда массив становится достаточно большим (> 2 КБ — см. ниже), PostgreSQL автоматически переносит его в отдельную область хранения через механизм TOAST. Это позволяет держать основную строку компактной, но обновления массивов при этом превращаются в существенную проблему для производительности.
Разница, казалось бы, незначительная, но последствия ощутимые. При обычном MVCC-обновлении PostgreSQL просто копирует версию строки в основной куче (heap). А вот обновление TOAST-массива заставляет его выкачать все внешние чанки, распаковать весь объект в память, применить изменение, снова сжать и записать новый полноразмерный блоб обратно в TOAST-таблицу. Простая модификация превращается в CPU- и I/O-затратную операцию, которая переписывает весь набор данных, а не только дельту.
Откуда взялась цифра 2 КБ? Порог TOAST рассчитывается так, чтобы на одну 8-килобайтную страницу кучи помещалось минимум четыре кортежа. Это определяется параметром TOAST_TUPLES_PER_PAGE (по умолчанию — 4). Так Postgres ищет баланс между эффективностью и накладными расходами на чтение вынесенных данных (TOAST indirection).
Проблема сжатия
До 14-й версии PostgreSQL полагался исключительно на pglz — алгоритм, который гнался за степенью сжатия в ущерб скорости. Из-за этого цикл «распаковать — изменить — снова сжать» при работе в TOAST превращался в сущую пытку.
В PostgreSQL 14 в качестве альтернативы был представлен LZ4:
ALTER TABLE articles ALTER COLUMN tags SET COMPRESSION lz4;
LZ4 работает заметно быстрее и на сжатие, и на распаковку, при этом уступает в плотности сжатия совсем незначительно. Поэтому, если вы имеете дело с большими массивами, переход на LZ4 — самый простой способ снизить нагрузку на процессор, которую создает TOAST.
Когда большие массивы всё-таки оправданы
У вас могло сложиться впечатление, что массивы — это зло и сплошные проблемы. Но при оценке целесообразности их использования главный вопрос не в том, какого размера массив, а в том, как часто вы его меняете. Массив из 10 000 элементов, который вы записали один раз и дальше используете только в режиме read-only — это абсолютно нормальный, рабочий сценарий. А вот массив всего на 50 элементов, в который вы дописываете данные при каждом входящем запросе — это уже настоящая головная боль.
А если совместить это со сжатием, картина станет ещё интереснее.
DROP TABLE IF EXISTS compression_test; CREATE TABLE compression_test ( id serial PRIMARY KEY, compressed_floats float4[], raw_floats float4[] ); -- не сжимать raw_floats ALTER TABLE compression_test ALTER COLUMN raw_floats SET STORAGE EXTERNAL; -- вставляем полуслучайные данные с низкой кардинальностью INSERT INTO compression_test (compressed_floats, raw_floats) SELECT semi_random_arr, semi_random_arr FROM ( SELECT ARRAY( SELECT floor(random() * 50)::float4 FROM generate_series(1, 10000) ) as semi_random_arr ) as generator; SELECT 'Compressed (EXTENDED)' as strategy, pg_size_pretty(pg_column_size(compressed_floats)::bigint) as size_on_disk FROM compression_test UNION ALL SELECT 'Raw (EXTERNAL)', pg_size_pretty(pg_column_size(raw_floats)::bigint) FROM compression_test;
strategy | size_on_disk -----------------------+-------------- Compressed (EXTENDED) | 15 kB Raw (EXTERNAL) | 39 kB (2 rows)
Массовая загрузка данных с использованием массивов
До сих пор могло показаться, что толку от массивов немного. Да, у них есть свои нюансы с хранением, зато для передачи данных они незаменимы.
Самый быстрый способ залить в базу 5000 строк — это не цикл в коде приложения и уж точно не гигантская строка VALUES (...), (...). Это unnest.
INSERT INTO measurements (sensor_id, value, captured_at) SELECT * FROM unnest( $1::int[], -- массив ID датчиков $2::float[], -- массив значений $3::timestamptz[] -- массив временных меток );
Всё что нужно — один сетевой round-trip и один запрос для парсинга и планирования. PostgreSQL самостоятельно разберет массивы на строки. Работает и для UPSERT, и для MERGE.
Специализированные массивы
Стандартные массивы PostgreSQL относятся к полиморфным типам (anyarray). Это мощная фича, дающая возможность одному определению функции работать с большим количеством разных типов данных. Массивы должны одинаково хорошо справляться с целыми числами, строками, временными метками и пользовательскими типами. Но если вы работаете с одним конкретным типом данных, использование специализированных расширений даст заметный выигрыш в производительности.
Расширение intarray
Если вы работаете исключительно с 4-байтовыми целыми числами (int4/integer), встроенные операции с массивами не используют весь потенциал производительности. Расширение intarray предоставляет специализированные функции и индексные операторы, которые работают значительно быстрее стандартной реализации массивов в PostgreSQL.
Перед использованием расширение нужно явно включить:
CREATE EXTENSION IF NOT EXISTS intarray;
Разница в удобстве для разработчика становится заметна сразу. Чтобы отсортировать массив в стандартном SQL, приходится разворачивать через unnest, сортировать и собирать обратно. С intarray у вас есть нативные функции вроде sort() и uniq().
-- стандартные массивы SELECT array_agg(val ORDER BY val) FROM unnest('{3, 1, 2}'::int[]) val; -- с расширением intarray SELECT sort('{3, 1, 2}'::int[]);
Помимо базовых функций управления, расширение вводит специализированный синтаксис запросов, упрощающий сложную булеву логику. Вместо того чтобы выстраивать цепочки из проверок на пересечение (&&) и вхождение (@>) можно выразить условия в одной «строке запроса», используя оператор @@.
-- стандартные массивы SELECT * FROM staff WHERE available_days @> '{1}' -- должен включать Пн AND available_days && '{6, 7}' -- должен включать Сб ИЛИ Вс AND NOT (available_days @> '{2}'); -- НЕ должен включать Вт -- с расширением intarray SELECT * FROM staff WHERE available_days @@ '1 & (6 | 7) & !2';
Единственный подвох — ограничение по типу. intarray жестко привязан к знаковым 32-битным целым числам. Если ваши значения переваливают за 2 миллиарда, использовать его уже не получится.
ИИ с pgvector
В 2026 году сложно говорить о массивах и не упомянуть pgvector. Хотя он и позиционируется как «векторное хранилище», по сути это просто массив чисел с плавающей точкой, заточенный под другую математику.
Стандартные массивы работают по бинарному принципу: важны точные совпадения (пересечение &&, вхождение @>). Векторы же заточены на «размытое» расстояние (косинусное <=>, евклидово <->).
Если вы строите поиск или рекомендательную систему, pgvector позволяет рассматривать колонку с массивом не как список «фактов» (тег A, тег B), а как координаты в семантическом пространстве.
Тем не менее, архитектурное решение здесь ровно то же самое, что и при использовании стандартного массива: вы жертвуете строгой структурой ради удобства. Поскольку невозможно «сджойнить» две строки на основе того, насколько они похожи, вектор хранится прямо в строке. Вы соглашаетесь на больший размер таблицы в обмен на возможность находить похожие записи.
