9. Работа с массивами
Нарушая правила: Зачем массивы в реляционной БД
Классическая реляционная теория баз данных, сформулированная Эдгаром Коддом, бескомпромиссна: фундаментом модели является первая нормальная форма (10. Первая нормальная форма 1NF), требующая абсолютной атомарности значений в каждой ячейке строки. Никаких списков, никаких повторяющихся групп. Если у сущности «статья» есть несколько меток (тегов), академический канон обязывает создать отдельную таблицу связей article_tags и соединять данные через классический JOIN (6. JOIN. INNER, LEFT, RIGHT).
Однако в реальных высоконагруженных промышленных системах догматическая нормализация нередко превращается в главный инфраструктурный затор (Bottleneck). Каждый JOIN требует вычислений на CPU сервера базы данных. Но что еще критичнее — при соединении отношений «один-ко-многим» (1:M) реляционный движок дублирует поля родительской сущности на каждой строке дочерней записи, в разы раздувая сетевой трафик (Network IO) и нагрузку на сетевые сокеты приложений.
Современные зрелые СУБД (в первую очередь PostgreSQL и колоночная ClickHouse) предлагают прагматичный инженерный выход — нативные массивы. Массивы позволяют хранить однородные множества значений прямо в ячейке таблицы, осознанно нарушая 1NF ради радикального сокращения I/O, уменьшения сетевого оверхеда и упрощения архитектуры.
Базовый синтаксис и операторы (на примере PostgreSQL)
Объявление колонки с массивом в DDL выглядит лаконично:
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
tags TEXT[] -- Массив строк переменной длины
);
Вставка данных в массив может выполняться в двух форматах:
-- Формат конструктора ARRAY (предпочтительный, типобезопасный и читаемый)
INSERT INTO articles (title, tags)
VALUES ('Golang GC', ARRAY['golang', 'runtime', 'gc']);
-- Формат строкового литерала (исторический синтаксис)
INSERT INTO articles (title, tags)
VALUES ('PostgreSQL Tuning', '{"sql", "postgres"}');
Фильтрация и поиск
Для эффективного поиска по массивам стандартные операторы = и LIKE не подходят. В PostgreSQL реализован богатый набор специализированных операторов работы с множествами:
-- 1. Содержит ли массив конкретный элемент? (Оператор ANY)
SELECT title FROM articles WHERE 'golang' = ANY(tags);
-- 2. Содержит ли массив ВСЕ указанные элементы? (Оператор @> - contains)
SELECT title FROM articles WHERE tags @> ARRAY['golang', 'runtime'];
-- 3. Есть ли хотя бы одно пересечение? (Оператор && - overlap / intersects)
SELECT title FROM articles WHERE tags && ARRAY['golang', 'java'];
Индексирование массивов: GIN индекс
Если вы попытаетесь отфильтровать таблицу на 10 миллионов строк условием 'golang' = ANY(tags) без специализированного индекса, СУБД будет вынуждена выполнить последовательное сканирование (Sequential Scan). Классический B-Tree индекс бессилен: он умеет сравнивать только значение ячейки целиком, но не способен заглянуть внутрь массива.
Для индексации элементов массивов применяется GIN (Generalized Inverted Index) — инвертированный индекс.
-- Создание инвертированного GIN-индекса по массиву тегов
CREATE INDEX idx_articles_tags ON articles USING GIN (tags);
Mechanical Sympathy: Как GIN устроен изнутри?
В отличие от B-Tree, где каждой строке таблицы соответствует один ключ в индексе, GIN «разворачивает» массив на атомарные составляющие. Для каждого уникального значения (например, строки 'golang') создается узел в поисковом B-дереве, а в листовой странице хранится компактный сжатый список ссылок — Posting List (список идентификаторов строк таблицы — TID, где данный тег встречается). Операторы @> и && выполняют пересечение и объединение битовых масок этих списков на уровне регистров CPU с колоссальной скоростью.
Магия трансформаций: UNNEST и ARRAY_AGG
В арсенале backend-разработчика есть две фундаментальные функции, превращающие работу с массивами в мощный конвейер трансформаций данных: распаковка (Explode) и свертка (Fold).
UNNEST (Разворачивание)
Функция unnest() превращает элементы массива в независимые физические строки результирующего набора. Это необходимо, когда требуется выполнить классическое реляционное соединение (JOIN) по элементам массива.
SELECT id, title, unnest(tags) AS tag
FROM articles;
Результат: Если у статьи с id = 1 было три тега, функция сгенерирует три отдельные строки с дублированием id и title, но уникальным значением tag в каждой.
ARRAY_AGG (Агрегация)
Функция array_agg() выполняет строго обратное действие: она собирает множество строк (обычно после группировки GROUP BY или из связанной таблицы JOIN) и компактно упаковывает их в единый массив.
Это канонический паттерн для устранения 13. N+1 проблема и колоссальной экономии Network I/O.
Рассмотрим классическую связь «один-ко-многим» (1:M): статья и привязанные к ней комментарии (comments).
❌ Наивный реляционный подход (избыточный трафик):
SELECT a.id, a.title, c.text
FROM articles a
LEFT JOIN comments c ON a.id = c.article_id;
Если у популярной статьи 1 000 комментариев, СУБД передаст по сети 1 000 строк. При этом тяжелые поля родительской статьи (id, title, длинный текст) будут продублированы ровно 1 000 раз, впустую забивая сетевую полосу пропускания и буферы TCP-сокетов.
✅ Высокопроизводительный подход (ARRAY_AGG):
SELECT
a.id,
a.title,
array_agg(c.text) AS comments_array
FROM articles a
LEFT JOIN comments c ON a.id = c.article_id
GROUP BY a.id, a.title;
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
DB["СУБД PostgreSQL"]:::process
Net["TCP Network"]:::network
Go["Go Backend"]:::process
subgraph Классический JOIN
DB -->|"ID: 1, Title: A, Comm: X<br>ID: 1, Title: A, Comm: Y"| Net
end
subgraph ARRAY_AGG
DB -->|"ID: 1, Title: A, Comms: X, Y"| Net
end
Net --> Go
СУБД выполняет упаковку данных в локальной памяти и отправляет по сети ровно одну компактную строку на каждую статью. Нагрузка на сеть и системные вызовы чтения в сервисе на Go сокращается на порядки.
Работа с массивами в Go
При использовании стандартного интерфейса database/sql драйвер базы данных изначально не знает, как отобразить бинарный массив СУБД в нативный срез Go []string или []int64. Для этого исторически применялась вспомогательная обертка драйвера pq.Array:
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/lib/pq"
)
type Article struct {
ID int64
Title string
Tags []string
}
func GetArticle(ctx context.Context, db *sql.DB, id int64) (Article, error) {
query := `SELECT id, title, tags FROM articles WHERE id = $1`
var a Article
// Оборачиваем целевой срез в pq.Array для корректной десериализации
err := db.QueryRowContext(ctx, query, id).Scan(&a.ID, &a.Title, pq.Array(&a.Tags))
if err != nil {
return a, fmt.Errorf("failed to scan article: %w", err)
}
return a, nil
}
TOAST: Цена больших массивов
Массивы открывают свободу хранить сотни килобайт связанных данных в одной строке. Однако за этим удобством стоит строгая физика подсистемы хранения СУБД.
Физическая дисковая страница (Page) в PostgreSQL имеет фиксированный размер — 8 Килобайт. Кортеж строки не может превышать размер страницы. Если поместить в массив TEXT[] массив логов или телеметрии размером более 2 КБ, СУБД принудительно задействует системный механизм TOAST (The Oversized-Attribute Storage Technique).
База данных сжимает массив (алгоритмами PGLZ или LZ4), делит его на блоки и складывает в скрытую системную таблицу TOAST. В основной строке таблицы остается лишь числовой указатель (Oid) на эти физические блоки.
Mechanical Sympathy (Скрытый I/O):
Когда разработчик неосмотрительно выполняет запрос SELECT * FROM articles, СУБД считывает основную страницу, обнаруживает указатель TOAST и вынуждена выполнить серию дополнительных случайных чтений с диска (Random I/O) для выгрузки и декомпрессии чанков массива. Если массив тегов или комментариев на самом деле не требовался в вызывающем коде, вы впустую нагружаете дисковый массив и прожигаете процессорные такты на декомпрессию.
Итог
- Массивы в реляционной СУБД — это осознанный архитектурный компромисс и контролируемое отступление от 1NF, позволяющее устранить избыточные
JOINи сэкономить Network I/O между сервисом на Go и базой данных. - В отличие от
JSONB, массивы строго типизированы (TEXT[],INT[]), компактнее на диске и не требуют синтаксического разбора текстовых деревьев. - Для эффективного поиска по массивам (операторы
@>,&&,= ANY(...)) создавайте инвертированные GIN-индексы. Помните о цене: GIN существенно снижает скорость вставок (INSERT) в Write-Heavy сценариях. - Функция
array_agg()служит мощным оружием против проблемы N+1, сворачивая дочерние строки в единый компактный массив на стороне СУБД. - В экосистеме Go используйте современный драйвер
pgx, напрямую читающий массивы в срезы Go без дополнительных аллокаций. - Помните о TOAST: массивы объемом свыше 2 КБ вызывают декомпрессию и случайный ввод-вывод (Random I/O). Никогда не делайте
SELECT *над таблицами с тяжелыми массивами.
Мы детально освоили продвинутые декларативные возможности SQL. Но что делать, если бизнес-логика транзакции становится настолько сложной и тесно связанной с данными, что пересылать промежуточные результаты между Go-сервисом и СУБД становится непозволительно дорого? В таких случаях код переезжает жить непосредственно внутрь ядра базы данных. В следующей статье мы разберем: 10. Хранимые процедуры и функции.