Содержание урока · 10 разделов
- jsonb и json: два типа с разной ценой
- Запись: структура или карта как аргумент
- Чтение: три приёмника на одну колонку
- Запросы по JSON-полям: ->, ->> и @>
- Приёмник в Go для jsonb-колонки
- Границы: когда jsonb, а когда колонки
- Правила jsonb в pgx одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Карточка товара в любом магазине — учебник по боли со схемой: у кроссовок есть размерная сетка, у смартфона — память и экран, у книжки вообще ничего интересного. Классический ответ — колонка на каждое свойство, и через месяц таких ответов миграция на двенадцать колонок с девятью NULL становится рутиной, а миграции — темой двадцать первого урока. JSONB предлагает другой контракт: тип-колонка, внутри которой лежит произвольный документ, разобранный и нормализованный сервером. Свойства добавляет код, а не DDL — «гибкие данные без миграций» перестают быть маркетинговым слоганом. В Go с pgx картина приятно скучная: специального типа для JSONB нет и не нужно — значения кодируются stdlib-пакетом encoding/json, поэтому структура с тегами или map[string]any едут аргументом запроса так же буднично, как строка из восьмого урока. Разберём оба направления: как записать структуру и карту, как прочитать обратно тремя разными приёмниками, как запросить по вложенному полю через ->> и @> и где проходит граница, за которой jsonb вредит. Всё на таблице products, которую дотянем из пятого урока одной ALTER TABLE.
jsonb и json: два типа с разной ценой#
Postgres держит два JSON-типа, и путать их дорого. Колонка json хранит точную копию присланного текста: пробелы, порядок ключей, даже дубли. Колонка jsonb при записи разбирает документ и хранит разобранное бинарное представление: лишние пробелы исчезают, дубликаты ключей схлопываются — побеждает последний, а порядок ключей не гарантируется вовсе. Плата за разбор на записи возвращается на чтении: jsonb не нужно парсить при каждом SELECT, и он умеет то, ради чего всё затевалось — операторы по вложенным полям, существование ключа, вхождение документа и индекс GIN. json умеет разве что стрелки -> и ->>, зато не умеет ни вхождение @>, ни существование ключа ?, ни индекс GIN — его оправдание — архив сырых документов, где важна точность исходного текста, например лог сторонней системы, который вы воспроизводите построчно. В 99% случаев выбор уже сделан за вас: jsonb. На стороне pgx разница почти не видна: за обоими типами стоят кодеки одного семейства, JSONCodec у json и JSONBCodec у jsonb, оба по умолчанию зарегистрированы с json.Marshal и json.Unmarshal из стандартной библиотеки. Единственное видимое отличие — у jsonb в бинарном формате у пакета есть байт версии, который драйвер отрезает и проверяет: чужой или неизвестный байт даёт ошибку unknown jsonb version number — из практики это значит ровно одно: в колонке лежит не то, что вы думали. Ещё одно следствие нормализации стоит проговорить заранее: два jsonb-значения сравниваются как разобранные документы, а не как текст, — сравнение в тестах станет надёжнее, чем сравнение строк, но и сюрпризы появятся там, где вы ожидали текстового равенства.
Запись: структура или карта как аргумент#
Правило записи в одну строку: любое значение, которое умеет json.Marshal, pgx сам превратит в JSON и отправит в jsonb-колонку. Структура с тегами json:"..." — самый честный путь: имена ключей зафиксированы в одном месте, поля экспортируемы, и ни один ключ не появится из воздуха. Карта map[string]any — путь гибкий: ключи собираются в рантайме, значения любых типов, но контракт становится неявным — о нём ниже, в «живом месте». Отдельная каста аргументов — те, что едут как есть, без повторного кодирования: string и []byte вставляются в колонку дословно, сервер проверит валидность и отвергнет кривой JSON своей ошибкой invalid input syntax for type json. json.RawMessage в кодеке выделен специальным планом именно потому, что повторный marshal мог бы его незаметно переписать — пример из исходников: {"foo": "bar"} после marshal превратился бы в {"foo":"bar"}; с RawMessage текст уезжает байт в байт. Это делает тип удобным мостом: получить сырой ответ где-то на границе и положить в базу, не трогая. Частичное обновление одного поля без чтения всей колонки — SQL-сторона: UPDATE products SET attrs = attrs || '{"color": "green"}'::jsonb — оператор конкатенации сливает документы, jsonb_set меняет точечно.
Структура и карта в одной вставке
GOpackage main
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
type Attrs struct {
Color string `json:"color"`
Warranty int `json:"warranty_months"`
}
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx,
"postgres://app:secret@localhost:5432/app")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
_, err = pool.Exec(ctx,
"INSERT INTO products (title, attrs) VALUES ($1, $2)",
"Кроссовки", Attrs{Color: "red", Warranty: 12})
if err != nil {
log.Fatal(err)
}
_, err = pool.Exec(ctx,
"INSERT INTO products (title, attrs) VALUES ($1, $2)",
"Чехол", map[string]any{"color": "black", "magnetic": true})
if err != nil {
log.Fatal(err)
}
fmt.Println("две вставки прошли")
}Аргумент $2 дважды имеет разный тип — Attrs и map[string]any, — но pgx одинаково прогоняет оба через json.Marshal и отправляет в колонку jsonb. Теги задают имена ключей: warranty_months вместо имени поля Warranty. Карта собирает ключи в рантайме, структура фиксирует их на компиляции — обе вставки валидны и взаимозаменяемы.
Чтение: три приёмника на одну колонку#
Обратно jsonb приезжает в три вида приёмников, и выбор зависит от того, сколько вы доверяете содержимому. Первый — своя структура: pgx вызывает json.Unmarshal, поля заполняются по тегам. Контракт противоположен сканированию колонок из седьмого урока: лишние поля в JSON просто игнорируются, недостающие остаются нулевыми — рассинхрон модели и документа ошибкой не становится, а превращается в тихие нули в данных. Второй — map[string]any: структура документа неизвестна заранее или различается между строками; объект на стороне Go станет картой, массив — []any, а числа по правилам encoding/json — float64, что для денежных значений уже знакомая опасность из десятого урока. Третий — сырой текст: string или []byte дают нормализованный JSON как есть, дальше свой парсинг; кодек копирует байты честно, отрезав служебный байт версии. Вложенность приёмника тоже работает: Attrs, *string — pgx развернёт и создаст указатель сам. И NULL: SQL NULL в jsonb-колонке ведёт себя как в шестом уроке — срезы и карты обнуляются без ошибки, указатель становится nil, а приёмник без указательности и без обёртки получает ошибку cannot scan NULL into — потому что некуда показать, что значения не было. Выбор приёмника — ещё и выбор производительности: разбор большого документа в карту на каждой строке выборки ощутим, тогда как сырой string почти бесплатен; правило простое — парсите только то, что собираетесь читать в этой операции.
Один SELECT, два приёмника
GOvar attrs Attrs
var raw string
err := pool.QueryRow(ctx,
"SELECT attrs, attrs::text FROM products WHERE title = $1",
"Кроссовки").
Scan(&attrs, &raw)
if err != nil {
log.Fatal(err)
}
fmt.Println(attrs.Warranty)
fmt.Println(raw)Колонка тянутся дважды: первый приёмник — структура Attrs, в неё pgx прогоняет байты через json.Unmarshal; второй — ::text, сырая нормализованная форма в string. raw показателен: пробелов после двоеточий не будет, ключи могут лечь не в том порядке, в котором вы их писали, — jsonb хранит разобранный документ, а не ваш текст.
Проверка: дубликаты ключей
В колонку jsonb вставили {"color": "red", "color": "blue"}. Что вернёт attrs->>'color'?
Запросы по JSON-полям: ->, ->> и @>#
Гибкость без возможности спросить бесполезна, поэтому половина веса jsonb — SQL-операторы. Стрелка -> возвращает вложенное значение как jsonb: attrs->'dimensions' — документ, с которым можно работать дальше вложенной стрелкой. Двойная стрелка ->> возвращает текст: attrs->>'color' — строка, которую можно сравнивать, кастовать и отдавать наружу: (attrs->>'warranty_months')::int — обычное приведение из десятого урока. Для массивов вместо ключа идёт индекс: attrs->>0 — первый элемент. В WHERE значения по-прежнему едут плейсхолдерами: WHERE attrs->>'color' = $1 — инъекции никуда не делись, склейка строк так же запрещена. Контейнмент @> спрашивает, содержит ли документ другой документ: WHERE attrs @> '{"color": "red"}'::jsonb — и это главная пара к GIN-индексу: CREATE INDEX ON products USING gin (attrs) после чего контейнмент и оператор существования ключа ? идут по индексу, а не сканом. Точечные индексы по выражению тоже работают: CREATE INDEX ON products ((attrs->>'color')) — если фильтруете всегда по одному полю, такой узкий индекс дешевле GIN. Планировщик и его планы — отдельная тема двадцать второго урока. Составное условие собирается из тех же операторов: WHERE attrs->>'color' = $1 AND (attrs->>'warranty_months')::int > $2 — читается легко, но помнит о кастах: текстовое '12' и числовое 12 в jsonb живут рядом, и без приведения вы получите текстовое сравнение с его сюрпризами.
Приёмник в Go для jsonb-колонки#
| Приёмник | Что получите | Когда брать |
|---|---|---|
| Своя структура | Поля по json-тегам, лишнее игнорируется | Контракт известен и стабилен |
| map[string]any | Объект как карта, числа float64 | Состав документа меняется от строки к строке |
| string / []byte | Нормализованный JSON текстом | Свой парсинг, пересылка дальше без разбора |
| json.RawMessage (запись) | Байты едут как есть, без re-marshal | Сырой ответ с границы, лень распаковывать |
| struct, *string | nil при SQL NULL | Колонка с NULL и нужна различимость |
| pgtype.Text и обёртки | Valid false при NULL | NULL без указателей, стиль шестого урока |
Что напечатает фрагмент?
Прочитайте код и выберите, что он выведет:
_, err := pool.Exec(ctx,
"INSERT INTO products (title, attrs) VALUES ($1, $2)",
"Фонарь", map[string]any{"color": "red", "color": "blue"})
if err != nil {
log.Fatal(err)
}
var color string
pool.QueryRow(ctx,
"SELECT attrs->>'color' FROM products WHERE title = $1",
"Фонарь").Scan(&color)
fmt.Println(color)Типичная ошибка: карта появляется как временный приёмник «пока не дошли руки», и через год вся логика читает attrs["warranty_months"] строкой — опечатка в ключе не ловится компилятором, отсутствие ключа и ноль неотличимы, а рефакторинг переименования поля не видит ни один инструмент. Что сломалось бы: переименование warranty_months в warranty — компиляция проходит, прод падает по нулям. Моё спорное мнение с условием: карта в слое данных — дефект контракта, кроме границы с внешним миром — произвольные метаданные от клиента или вебхук чужой системы, где схема действительно не ваша. Как только по ключу пошли ветвления бизнес-логики — заводите структуру с тегами.
Границы: когда jsonb, а когда колонки#
Соблазн «всё в jsonb — и никаких миграций» ломается о три вещи. Первая — типы: схема не проверяет содержимое, и 12 в одном документе спокойно уживается с "12" в другом, пока какой-нибудь отчёт не падает на приведении. Вторая — связи: вложенное значение не может быть внешним ключом, JOIN по нему не построить, а ссылочная целостность держится на честном слове. Третья — статистика: планировщик знает распределение по колонкам, но почти ничего не знает про вложенные поля, поэтому планы по JSON-условиям хуже прогнозируемы. Рабочее правило: то, по чему ищут, соединяют и считают — колонки; то, что переносится как багаж — свойства товара, настройки интеграции, полезная нагрузка вебхука — jsonb. Миграции при этом никуда не денутся: индекс GIN по attrs — это DDL, и он появляется через миграцию из двадцать первого урока, просто реже. Кстати про границы схожей гибкости: массивы Postgres из следующего урока решают другую задачу — много значений одного типа, без ключей вовсе.
Правила jsonb в pgx одним списком#
- Новая колонка с произвольным содержимым — jsonb, а не json
- Запись: структура с тегами, map[string]any или json.RawMessage как аргумент
- Чтение: структура по тегам, карта для меняющегося состава, string для сырого текста
- Числа из map[string]any приходят float64 — деньги мимо карты
- SQL NULL: карты и срезы обнуляются, обычная структура падает ошибкой
- Стрелки: -> даёт jsonb, ->> даёт текст; кастуйте при сравнении чисел
- Контейнмент @> и ? — под GIN-индекс; один фильтр — выражение-индекс
- Значения — через $1; ключ фиксируется в SQL, не в склейке строк
Проверка: NULL в jsonb
Запрос SELECT null::jsonb, приёмник — var m map[string]any. Что вернёт Scan?
Соберите запрос по JSON-полю
Восстановите INSERT структуры, фильтр по вложенному полю и индекс под контейнмент.
Соберите запись и чтение jsonb
Расставьте строки: карта, вставка с RETURNING, чтение сырого текста.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JSONB
Частичные обновления заслуживают отдельного абзаца, потому что здесь чаще всего пишут лишний код. Читая документ в Go, меняя одно поле и записывая обратно, вы создаёте классическую гонку читай-пиши: между вашим SELECT и UPDATE другой процесс успел изменить соседнее поле — и ваша запись затрёт его старым значением, привезённым в памяти. SQL-операторы jsonb обновляют точечно на стороне базы: attrs = attrs || '{"color": "green"}'::jsonb добавит и заменит ключи без чтения, jsonb_set(attrs, '{dimensions,width}', '20') изменит вложенное значение по пути, а оператор минус удалит ключ: attrs - 'color'. В транзакции из тринадцатого урока read-modify-write легален — блокировки защищают последовательность, — но одиночное обновление поля честнее и дешевле делать одним UPDATE. Разумная граница: несколько связанных полей разом или перестройка половины документа — путь через структуру в Go с полной записью; одиночный ключ или счётчик — SQL-оператор. Так же стоит относиться и к jsonb_path_query: когда запросы по документу становятся многословными, это не повод тащить их в Go — это повод взглянуть на jsonpath и на то, что за поля вы вообще храните.
Кодек — это две функции: JSONBCodec{Marshal, Unmarshal} регистрируется заново через conn.TypeMap().RegisterType, и на их место встаёт любой сериализатор — например, тот, что кодирует числа строками или сжимает документ. Это редкая потребность, но она показывает устройство: pgx не знает про JSON почти ничего, кроме байта версии, всё остальное делают ваши две функции.
Формат курса прежний: песочница урока покажет тематическую программу на чистом Go, а живые INSERT с jsonb — локально, Go и Postgres из первого урока; столбец добавьте ALTER TABLE products ADD COLUMN attrs jsonb. Дальше по курсу: в двенадцатом — массивы, второй вид гибких колонок, где вместо ключей просто порядок; в двадцать втором — как убедиться, что GIN действительно работает, глядя в план запроса; в двадцать пятом jsonb-атрибуты лягут в итоговый сервис заметок. Если сканирование структур в седьмом уроке осталось туманным — перечитайте его до домашнего задания: здесь то же самое, только приёмник разбирает encoding/json, а не драйвер по колонкам. И последнее про гибкость: она не бесплатна и не безальтернативна — массивы из следующего урока закрывают часть задач jsonb дешевле, а нормальные колонки — большую часть задач обоих; jsonb оставляйте там, где и правда не знаете структуру заранее.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- jsonb хранит разобранный документ: без пробелов и дублей ключей, порядок ключей не гарантирован.
- Записывайте структуры с json-тегами, карты или json.RawMessage — pgx кодирует сам через stdlib.
- Чтение: структура (лишнее игнорируется), map[string]any (числа float64), string для сырого текста.
- SQL NULL обнуляет карты, срезы и указатели; обычная структура падает cannot scan NULL into.
- ->> даёт текст, -> даёт jsonb, @> и ? идут под GIN-индексом.
- map[string]any — для границы с внешним миром; бизнес-логика по ключам — повод завести структуру.
- Ищут, соединяют, считают — в колонки; переносимый багаж — в jsonb.
Домашнее задание
Добавьте к таблице products колонку attrs jsonb и наполните её двумя разными составами свойств — структура и карта. Прочитайте один документ всеми тремя приёмниками урока и сравните вывод. Напишите запрос WHERE attrs @> с GIN-индексом и без него, глянув EXPLAIN ANALYZE — разницу по времени увидите даже на тысячах строк. Вставьте документ с дубликатом ключа и объясните в комментарии, что вернул оператор и почему.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно обойтись без encoding/json и передавать строку?
Да: string и []byte едут в jsonb как есть, сервер проверит валидность. Для прототипов этого хватает, но вы теряете компилятор: ключи и типы живут в текстовых константах, и опечатка проявится только на запросе. Структуры с тегами дешевле ровно на уровне типов.
Почему числа из map[string]any всегда float64?
Таково поведение json.Unmarshal без цельного приёмника: any-значения раскрываются в float64, bool, string, map и slice. Для отображения это не страшно, для арифметики с деньгами — знакомая ловушка десятого урока. Точный путь — приёмник-структура с правильными типами полей или pgtype.Numeric после каста в SQL.
Индексы по jsonb — сразу GIN?
GIN нужен для контейнмента @>, существования ключа ? и пересечений; если фильтр всегда один и тот же — attrs->>'color' — узкое b-дерево по выражению дешевле GIN и в размерах, и в обновлении. Начните с EXPLAIN из двадцать второго урока: индекс выбирается под операторы запроса, а не наоборот.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Массивы Postgres: []string и pgtype.Array
- Транзакции: Begin, Commit и Rollback
- Паттерн «с транзакцией»: функция-обёртка
- Контекст и таймауты запросов
- Типы Postgres: UUID, TIMESTAMPTZ, NUMERIC
- Интерфейс database/sql: pgx через StdLib
- Плейсхолдеры $1 и SQL-инъекции
- CollectRows и RowToStructByName
- Все курсы по Go
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup