8. JSON в SQL

JSON в SQL: Когда реляционности не хватает

Долгое время между миром реляционных баз данных и движением NoSQL шла непримиримая идеологическая война. Реляционные СУБД стояли на страже строгой нормализации, жестких схем данных и транзакционной целостности ACID. Документоориентированные хранилища (вроде MongoDB) предлагали разработчикам свободу от миграций схемы и возможность сохранять произвольные иерархические структуры данных прямо из кода на лету.

Сегодня эта граница окончательно стерта. Современные реляционные СУБД (в первую очередь PostgreSQL и MySQL) превратились в мощные гибридные платформы, умеющие хранить, индексировать и эффективно запрашивать полуструктурированные JSON-документы прямо внутри классических реляционных таблиц.

Для backend-разработчика на Go это открывает колоссальные возможности: вы можете сохранить «сырой» ответ от внешнего платежного шлюза, гибко расширять метаданные пользовательских профилей или конфигурировать виджеты интерфейса без выполнения болезненных DDL-миграций схемы на проде. Однако работа с JSON в реляционной базе таит в себе серьезные аппаратные ловушки, если не понимать физики его размещения на дисковых страницах.


JSON vs JSONB в PostgreSQL

В PostgreSQL исторически поддерживаются два типа данных для работы с форматом JSON: текстовый json и бинарный jsonb. Выбор между ними — это фундаментальный инженерный компромисс между скоростью записи и эффективностью чтения:

  • json (Текстовый формат): Хранит точную посимвольную копию входной текстовой строки, включая исходные пробелы, переносы строк и порядок ключей. При каждом обращении вида SELECT data->>'key' движок СУБД вынужден на лету вызывать текстовый парсер, строить абстрактное синтаксическое дерево (AST) в оперативной памяти процесса, находить нужное поле и немедленно уничтожать AST. На интенсивном потоке чтений текстовый json превращается в беспощадного пожирателя тактов CPU.
  • jsonb (Декомпилированный бинарный формат): Преобразует входящую строку в оптимизированное бинарное представление непосредственно в момент записи. Парсинг происходит ровно один раз. На диск сохраняется не текст, а структурированное бинарное дерево. Запись обходится чуть дороже по тактам CPU, но чтение, фильтрация и извлечение атрибутов происходят с наносекундной скоростью. Самое главное: тип jsonb поддерживает индексацию через инвертированные индексы GIN.

Mechanical Sympathy: TOAST и огромные документы

Физическая архитектура PostgreSQL построена вокруг дисковых страниц фиксированного размера — ровно 8 КБ. Если размер строки таблицы вместе со всеми ее атрибутами превышает четверть страницы (около 2 КБ), активируется системный механизм TOAST (The Oversized-Attribute Storage Technique).

Когда прикладной сервис сохраняет в колонку объемный JSONB-документ (например, массив событий телеметрии на 500 КБ), СУБД физически не способна разместить его на основной странице таблицы:

  1. Движок сжимает документ встроенным алгоритмом компрессии pglz (или современным LZ4 в актуальных версиях PostgreSQL).
  2. Сжатый бинарный блоб разрезается на чанки фиксированного размера (обычно по 2 КБ).
  3. Чанки записываются в скрытую системную таблицу pg_toast_<OID_таблицы>, а в основной строке остается лишь компактный указатель (TOAST pointer) размером в несколько байт.
flowchart TD
    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
    A["Запрос data->>'key'"]:::entry --> B["Чтение строки из Main Table"]
    B --> C{"Данные TOASTированы?"}
    C -- "Нет, меньше 2 КБ" --> D["Прямое чтение из страницы в RAM"]
    D --> G["Обход JSONB AST"]
    C -- "Да, большой JSON" --> E["Чтение кусков из pg_toast<br>Множественные IO"]
    E --> F["Декомпрессия pglz в work_mem"]
    F --> G
    G --> H["Возврат значения"]
  

Что происходит в железе при запросе SELECT data->>'key' FROM users WHERE id = 1:

  • Исполнитель мгновенно находит строку в основной таблице по индексу первичного ключа.
  • Обнаружив TOAST-указатель, движок инициирует серию дополнительных дисковых обращений (Random IO) для последовательного считывания всех чанков из таблицы pg_toast.
  • В оперативной памяти процесса выделяется буфер work_mem, куда алгоритм декомпрессии распаковывает исходный бинарник.
  • Процессор выполняет обход дерева jsonb в поисках ключа key.

Если такой запрос выполняется с частотой в сотни раз в секунду, дисковая подсистема мгновенно захлебывается в состоянии IO wait, а ядра CPU утилизируются декомпрессией.

Золотое правило архитектора: Храните в JSONB-колонках только компактные метаданные, непосредственно участвующие в бизнес-логике. Гигантские сырые пейлоады и логи выносите в объектные хранилища (S3 / MinIO), сохраняя в реляционной таблице лишь строковый URL.


Операторы и индексация

Для навигации по структурам JSONB в SQL предусмотрен набор специализированных операторов:

  • -> извлекает поле объекта или элемент массива, сохраняя тип jsonb. Это позволяет строить цепочки вызовов: data->'address'->'city'.
  • ->> извлекает значение поля в виде скалярного текста text (примитив SQL).

Критический нюанс: операторы -> и ->> являются функциями. Попытка выполнить фильтрацию WHERE data->>'status' = 'paid' по неиндексированной таблице вызовет последовательное чтение всей таблицы (Seq Scan) с поштучной распаковкой каждого JSONB-документа на каждой строке.

Для индексации документов jsonb применяется специализированный инвертированный индекс GIN (Generalized Inverted Index). Инвертированный индекс хранит ссылки вида «элемент JSON $\rightarrow$ список строк, где он встречается».

По умолчанию GIN-индекс (jsonb_ops) индексирует каждый отдельный ключ и значение. Но в реальных сервисах поиск ведется по целостным путям. Для минимизации размера индекса применяют класс операторов jsonb_path_ops, который хэширует полные пути:

SQL
-- Создание легковесного и быстрого GIN индекса
CREATE INDEX idx_orders_metadata ON orders USING GIN (metadata jsonb_path_ops);

-- Этот запрос ЭФФЕКТИВНО ИСПОЛЬЗУЕТ GIN-индекс (оператор @> - "содержит")
SELECT * FROM orders WHERE metadata @> '{"status": "paid"}';

-- Этот запрос НЕ МОЖЕТ ИСПОЛЬЗОВАТЬ данный GIN-индекс!
SELECT * FROM orders WHERE metadata->>'status' = 'paid';

Взаимодействие с Go: Проблема двойного парсинга

При интеграции Go-сервисов с JSON-колонками базы данных разработчики регулярно попадают в классическую архитектурную ловушку — двойной парсинг (Double Unmarshal).

Проследим цепочку трансформаций при наивном подходе:

  1. PostgreSQL считывает бинарный jsonb со страниц диска.
  2. Драйвер БД (pgx) преобразует бинарник в строку и передает ее в сетевой сокет.
  3. Сервис на Go вызывает json.Unmarshal, разбирая текст в структуру в куче (Heap).
  4. Сервис отдает данные клиенту, вызывая json.Marshal, превращая структуру обратно в срез байтов!

Если микросервис выступает в роли транзитного шлюза и не модифицирует поля JSON, эти двойные циклы сериализации и десериализации впустую сжигают гигагерцы CPU и порождают миллионы паразитных аллокаций памяти.

Паттерн 1: json.RawMessage (Сквозное прокидывание)

Для транзитной отдачи JSON наружу используйте тип json.RawMessage из стандартного пакета encoding/json (под капотом это обычный срез байтов []byte). Драйвер базы данных запишет сырые байты прямо в структуру Go, а вспомогательный хэндлер вроде http.Json отдаст их прямо в сокет, минуя вызов Unmarshal.

Go
package repository

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/jmoiron/sqlx"
)

// UserRaw представляет модель со сквозным JSON
type UserRaw struct {
    ID      int64           `db:"id" json:"id"`
    Profile json.RawMessage `db:"profile" json:"profile"` // Хранит сырые байты, исключая парсинг в Go
}

const getUserSQL = `SELECT id, profile FROM users WHERE id = $1`

// GetUser сканирует JSON без полного парсинга в Go
func GetUser(ctx context.Context, db *sqlx.DB, userID int64) (*UserRaw, error) {
    var u UserRaw
    // Драйвер pgx запишет сырые байты JSON в u.Profile
    if err := db.GetContext(ctx, &u, getUserSQL, userID); err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    return &u, nil
}

Паттерн 2: Строгая типизация (Бизнес-логика)

Если над полями документа производятся вычисления в коде (проверка статусов, расчет скидок), документ необходимо десериализовать в строгую типизированную структуру. Использование нетипизированных map[string]any в Go — это антипаттерн, приводящий к неконтролируемым паникам рантайма и утечкам памяти.

Go
// Строгая структура для бизнес-логики
type UserProfile struct {
    Email string `json:"email"`
    Age   int    `json:"age"`
}

type UserStrict struct {
    ID      int64       `db:"id" json:"id"`
    Profile UserProfile `db:"profile" json:"profile"`
}

Паттерн 3: Частичное обновление (jsonb_set)

Типичная ошибка новичков: выгрузить весь 100-килобайтный JSON в Go-сервис, изменить одно поле в структуре, сериализовать документ целиком и выполнить тяжелый UPDATE всей колонки. Помимо неэффективного сетевого трафика, такой подход порождает гонки данных (Race Conditions) при конкурентных обновлениях соседних полей.

Для точечных модификаций используйте встроенную функцию СУБД jsonb_set:

SQL
-- Обновляет только поле status, не затрагивая остальные атрибуты
UPDATE orders 
SET metadata = jsonb_set(metadata, '{status}', '"shipped"'::jsonb)
WHERE id = $1;


Итог

  1. В PostgreSQL всегда выбирайте тип jsonb: однократный парсинг при записи окупается наносекундным доступом на чтении.
  2. Mechanical Sympathy: Документы объемом более 2 КБ вытесняются в системную таблицу TOAST, порождая случайные операции ввода-вывода (IO wait) и нагрузку на CPU при декомпрессии (pglz/LZ4).
  3. Для ускорения поиска создавайте GIN-индексы с классом jsonb_path_ops и используйте оператор включения @>.
  4. В коде на Go устраняйте «двойной парсинг»: если пейлоад транзитный, применяйте json.RawMessage для прямой пересылки байтов из БД в сетевой сокет.
  5. Для модификации документов применяйте функцию jsonb_set на стороне СУБД, защищаясь от Race Conditions и разгружая сеть.

Формат JSON стирает границы между реляционной строгостью и гибкостью документов. Но наряду с объектами в SQL есть еще одна полуструктурированная возможность — нативные массивы значений. В следующей статье мы разберем: 9. Работа с массивами.