Full Stack Blog – JSON in PostgreSQL

10 April 2023

JSON in PostgreSQL

Чуть больше подробностей про внутренности PostgreSQL можно найти в других статьях.

Мне показалось это полезным:


"JSON" типы данных

Так исторически сложилось, что в PostgreSQL есть несколько типов данных для JSON.

hstore - Это не JSON. Позволяет хранить набор пар key:value внутри одного поля базы данных. Поддерживает индексирование. Нет массивов и вложенности.

json - Хранит JSON в виде текста. Не оптимально для часто меняющихся данных или если выполняется много запросов к полям JSON. Не поддерживает индексирование.

jsonb - (binary json) Наследник hstore - "вложенный" hstore. Бинарное хранение. Поддерживает индексирование. В отличии от hstore поддерживает вложенные объекты и массивы. Не сохраняет форматирование (удаляет пробелы) и поддерживает только уникальные ключи в JSON (сохранен будет последний). Ключи отсортированы.

number в jsonb реализован как numeric. Это позволяет обойти ограничения 8 байт.

jsonpath - введен для поддержки jsonpath. Это двоичное представление разобранного jsonpath выражения.

Главная проблема JSON в PostgreSQL

json и jsonb реализации хранят данные в поле таблицы опираясь на стандартные механизм TOAST в случае большого значения ( больше 2kb ).

  • TOAST перезаписывает и читает данные целиком

  • TOAST был сделан для атомарных типов данных. Нет информации про внутреннюю структуру

Как результат:

  • Любое обновление JSON приведет в полной перезаписи поля целиком.

  • Повышенная нагрузка на VACUUM и WAL при частых изменениях в JSON.

  • Линейная зависимость времени доступа к любому ключу от размера json. Из-за того что нужно читать и разжимать все TOAST блоки.

Алгоритм применения обновлений если JSON не попал в TOAST:

  • в WAL будут записаны операции обновления для таблицы
  • будет создана новая строка с обновленными данными в таблице
  • старая строка будет помечена как "dead tuples" для VACUUM

Алгоритм применения обновлений если JSON находится в TOAST:

  • в WAL будут записаны операции обновления для таблицы и для TOAST, включая весь JSON
  • В TOAST будет создана полная копия всего JSON с обновлениями
  • Старые версии JSON в TOAST будут помечены как "dead tuples"
  • будет создана новая строка с обновленными данными в таблице
  • старая строка будет помечена как "dead tuples"

Маленькое обновление в больших JSON приводит к потере производительности, распуханию как таблиц так и WAL, а также к большой нагрузке на VACUUM.

Проблемы совместимости с SQL стандартом

SQL:16 - вводит описание для:

  • SQL/JSON data model - описание JSON модели
  • JSON Path language - язык запросов для JSON
  • SQL/JSON function - функции работы с JSON

но пока не вводится конкретный тип данных

SQL:19 - вводит тип дынных json. Для PostgreSQL это проблема так как json уже был реализован в нём и этот тип данных очень медленный (json хранится в веде строк).

На данный момент в PostgreSQL реализован JSON Path language (в 13й версии).

предложения PostgresPro

В этом разделе описаны предложения компании PostgresPro для обеспечения совместимости с SQL стандартом и ряд оптимизаций, делающих использование JSON более привлекательным.

Предложения не приняты и находятся в разработке.

Pluggable toaster был отклонен.

GSON

Для совместимости с типом данных json из SQL стандарта и с предыдущими реализациями json и jsonb предлагается внутренний механизм GSON.

GSON (Generic JSON Api) - это слой поверх json и jsonb. Он наследует функции из json & jsonb. Также становится доступным индексирование для json (json text).

Реализация конкретного способа хранения json или jsonb является backend-ом для GSON, а json and jsonb функции - это frontend доступный в SQL. В результате введения слоя GSON станет возможным реализовать SQL/JSON функции (функции из стандатра) как frontend для GSON backend-а.

Так как GSON скрывает детали реализации работы с json / jsonb, то можно реализовать дополнительные методы хранения, добавив их как backend для GSON.

совместимость с SQL стандартом

Для решения проблем совместимости (или для создания новых) в PostgreSQL предлагается использовать mapping (aliasing) типов данных.

По умолчанию мы имеем sql_json = json

  • тип json - алиас на json (т.е. json text, который хранится в виде текста)
  • тип jsonb - алиас на jsonb

но у нас есть возможность изменить это

SET sql_json = jsonb;

и теперь мы имеем

  • тип json - алиас на jsonb. Это дает возможность заявить о поддержке SQL стандарта и "быстром" типе данных.
  • тип json text - алиас на json. Для тех кому все еще нужен json, который хранится как текст.

оптимизации производительности

  • Частичная декомпрессия Jsonb - частичная декомпрессия - читается и распаковывается все от начала до запрашиваемого значения.

    Данные хранятся в таком виде: (key1,key2,key3,value1,value2,valu3)

    • запрос значения для key1 - будет прочитано и распаковано только [key1,key2,key3,value1
    • запрос значения для key2 - будет прочитано и распаковано только [key1,key2,key3,value1,value2
  • Сортировка ключей по длине значения - сортировка не по тексту ключа а по длине значения (т.е. большее значение будет последним).

  • Частичный deTOASTing - читать только нужные блоки для TOAST, не поднимать все с файловой системы.

  • Inline TOAST - если json попадает в TOAST, то в тупле таблицы помимо ссылки на TOAST хранится и начало json-a (осатльное в TOAST). Вместе с сортировкой это позволяетне не обращаться в TOAST для "маленьких" значений.

  • Shared TOAST - не перезаписывать весь json при обновлении. копируются только chunk-и которые содержит обновленное значение. Снижается нагрузка на VACUUM и WAL.


Создание, редактирование, запросы

Этот раздел достаточно подробно :) описан в документации (или так), поэтому обойдемся кратким перечисление того что мы можем сделать с JSON в PostgreSQL

Конструкторы

У нас есть возможность создавать JSON из любых данных в SQL

SELECT '{"id": "uuid-1", "plantId": "uuid-2"}'::jsonb;

-- или из таблицы
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- это включение поддержки uuid
DROP TABLE IF EXISTS plant_properties;
CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID,
    plant_color character(13)
);
INSERT INTO plant_properties (id, plant_id, plant_color) VALUES 
(uuid_generate_v4(), uuid_generate_v4(), 'green'),
(uuid_generate_v4(), uuid_generate_v4(), 'blue'),
(uuid_generate_v4(), uuid_generate_v4(), 'green')
;

SELECT json_agg(plant_properties)
FROM plant_properties
WHERE plant_color = 'blue'
;

-- или

SELECT jsonb_agg(plant_prop) AS property
FROM (
    SELECT id, plant_id AS "plantId" FROM plant_properties WHERE plant_color = 'blue'
) AS plant_prop
;

in Postgres 15 JSON_TABLE lets you work with a JSON structure in Postgres as if it was a regular Postgres table.

Таблицы и добавление записей

Создадим таблицу для хранения свойств растений

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
DROP TABLE IF EXISTS plant_properties;

CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    payload jsonb NOT NULL -- тут будем хранить описание свойства в формате JSON
);

поля id и plant_id вынесены из JSON для того, чтобы можно было быстро найти требуемую запись по ним не заставляя PostgreSQL обращаться к JSON так как это наложет свой отпечаток на производительность.

кроме полей из JSON иногда полезно вынести какой-нибудь агрегат. Например, количество свойств или наличие того или иного свойства в JSON.

Клиенты хотят видеть вот такой JSON, чтобы отрисовать UI или что-нибудь проанализировать

{
    "plantId": "...",
    "properties": [
        {"type": "color", "value": "green"},
        {"type": "perennial", "value": true},
        {"type": "max_height", "value": 3.3, "units": "metre"},
        {"type": "flowering_period", "from": 5, "to": 6, "units": "month"}
    ]
}

Если мы будем складывать все свойства одного растения в один JSON так:

CREATE TABLE IF NOT EXISTS plant_properties (
    plant_id UUID NOT NULL PRIMARY KEY,
    payload jsonb NOT NULL
);

INSERT INTO plant_properties (plant_id, payload) VALUES
('a03a347f-8435-4caa-aeee-3eaaa38983ba', '{
    "plantId": "...",
    "properties": [
        {"type": "color", "value": "green"},
        {"type": "perennial", "value": true},
        {"type": "max_height", "value": 1.1, "units": "metre"},
        {"type": "flowering_period", "from": 5, "to": 6, "units": "month"}
    ]
}')
;

это увеличит вероятность попасть в TOAST и при обновлении одного свойства PostgreSQL будет перезаписывать все свойства растения.

Лучше разложить так: одно свойство - одна запись

CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL
);

INSERT INTO plant_properties (id, plant_id, property) VALUES
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "color", "value": "green"}'),
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "perennial", "value": true}'),
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "max_height", "value": 1.1, "units": "metre"}'),
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "flowering_period", "from": 5, "to": 6, "units": "month"}'),

(uuid_generate_v4(), '8b315f91-de7f-4f98-9e90-40e7a6532f69', '{"type": "color", "value": "green"}'),
(uuid_generate_v4(), '8b315f91-de7f-4f98-9e90-40e7a6532f69', '{"type": "perennial", "value": false}'),
(uuid_generate_v4(), '8b315f91-de7f-4f98-9e90-40e7a6532f69', '{"type": "max_height", "value": 2.4, "units": "metre"}'),
(uuid_generate_v4(), '8b315f91-de7f-4f98-9e90-40e7a6532f69', '{"type": "flowering_period", "from": 5, "to": 6, "units": "month"}'),

(uuid_generate_v4(), '7036a94c-6b9a-46ac-b536-47bf780bcedb', '{"type": "color", "value": "blue"}'),
(uuid_generate_v4(), '7036a94c-6b9a-46ac-b536-47bf780bcedb', '{"type": "perennial", "value": true}'),
(uuid_generate_v4(), '7036a94c-6b9a-46ac-b536-47bf780bcedb', '{"type": "max_height", "value": 3.6, "units": "metre"}')
;

поле property удовлетворяет ограничениям и не попадет в TOAST. Такая схема не будет оказывать избыточного влияния на WAL и VACUUM при редактировании данных.

Запросы

расширение JsQuery

JsQuery – is a language to query jsonb data type, introduced in PostgreSQL release 9.4.

Jsquery is released as jsquery data type (similar to tsquery) and @@ match operator for jsonb.

Это расширение от Postgres Pro, предоставляющее дополнительную функциональность для jsonb. В стандартной поставке его нет, а для установку нужен бубен, поэтому подробностей про него не будет. Пару слов стоило сказать так как про него часто вспонимают.

Подробности в readme тут: https://github.com/postgrespro/jsquery.

операторы для json и jsonb

В документации описано множество операторов и функций работы с JSON. Рассмотрим только некоторые из них.

достать список всех типов свойст растения с помощью оператора можно вот так:

SELECT 
    distinct(properties->'type') as types
FROM plant_properties
;

здесь ->'type' - это взять значение для свойства с ключем type

Для первого варианта схемы, когда все свойства в одной записи:

SELECT 
    distinct(payload->'properties'->0->'type') as types
FROM plant_properties
;

->0 это взять нулевой элемент из списка.

Второй вариант можно переписать с другим оператором

SELECT 
    distinct(payload #> '{properties,0,type}') as types
FROM plant_properties
;

здесь мы извлекаем значение по пути '{properties,0,type}'

оператор ->> всегда возвращает text, а оператор -> всегда json или jsonb

Пример: получить все записи (свойства растений) с типом "color":

SELECT *
FROM plant_properties
WHERE properties #> '{type}' = '"color"'
;

Пример: получить идентификаторы всех растений max_height которых больше 2:

SELECT plant_id
FROM plant_properties
WHERE properties #> '{type}' = '"max_height"' AND (properties -> 'value')::integer > 2
;

Пример: вывести plantId и plantColor для всех многолетних растений:

SELECT 
    plant_id as plantId, properties ->> 'value' as plantColor
FROM plant_properties, (
    SELECT plant_id as pid
    FROM plant_properties
    WHERE properties #> '{type}' = '"perennial"' AND properties #> '{value}' = 'true'
) as perennial_plant_ids
WHERE plant_id = pid AND properties #> '{type}' = '"color"'
;
--> 
-- "a03a347f-8435-4caa-aeee-3eaaa38983ba"   "green"
-- "7036a94c-6b9a-46ac-b536-47bf780bcedb"   "blue"

SQL/JSON Path Language

Из документации

similar to XPath expressions used for SQL access to XML

Получить все уникальные типы из JSON. Для первого варианта схемы:

SELECT 
    DISTINCT(jsonb_path_query(payload, '$.properties[*].type'))
FROM plant_properties
;

Получить все уникальные типы из JSON. Для второго варианта схемы:

SELECT 
    DISTINCT(jsonb_path_query(properties, '$.type'))
FROM plant_properties
;

В "SQL/JSON Path Language" используются следующие конструкции

$. - корень документа

.type - взять значения свойства с ключом type

.properties[3] - взять объект из массива по индексу 3

.properties[*] - для любого объекта из массива

"SQL/JSON Path Language" может использоваться для фильтрации записей таблицы по полям JSON:

Например, получить все записи (свойства растений) с типом "color" можно вот так:

SELECT 
    id, plant_id, jsonb_path_query(properties, '$ ? (@.type == "color")')
FROM plant_properties
;

здесь

$ - селектор jsonpath. проверка выполняется для каждого узла по этому селектору. В данном случае мы будем корень. Если бы в корне находился массив то каждый элемент этого массива можно было бы взять использовав $[*]

jsonpath ? (condition) - формат фильтра. может быть несколько фильтров jsonpath ? (condition) ? (condition)

@.type == "color" - условие фильтрации. Поле type для каждого элемента из списка предоставленного jsonpath проверяется type == "color"

'Regular Expressions', 'string starts with' и другие операторы и методы для "SQL/JSON Path Language" смотрите в документации

Еще несколько примеров с использованием "SQL/JSON Path Language" в запросах

Получить все plant_id многолетников. Выполняется поиск вхождений содержащих пару {"type": "perennial"}.

SELECT DISTINCT(plant_id)
FROM plant_properties
WHERE property @> '{"type": "perennial"}'
;

Получить все plant_id для которых определено совйство "max_height". Проверка значения поля type.

SELECT DISTINCT(plant_id)
FROM plant_properties
WHERE property @? '$.type ? (@ == "max_height")'
;

Редактирование

Объелинение двух JSON:

SELECT 
'[{"type": "color", "value": "green"}]'::jsonb || '[{"type": "perennial", "value": false}]'::jsonb
;
--> [{"type": "color", "value": "green"},{"type": "perennial", "value": false}]

Удаление элементов из JSON

SELECT 
'[{"type": "color", "value": "green"}, {"type": "perennial", "value": false}]'::jsonb #- '{1}'
;
--> [{"type": "color", "value": "green"}]

Замена значения

SELECT 
jsonb_set('[{"type": "color", "value": "green"}, {"type": "perennial", "value": false}]'::jsonb, '{0,value}', '"black"')
;
--> [{"type": "color", "value": "black"}, {"type": "perennial", "value": false}]

Еще есть jsonb_insert, jsonb_extract_path, jsonb_pretty и другие. Полный список в документации.

Индексирование

Для индексирования доступен только тип jsonb.

B-tree индекс

Доступен стандартный B-tree индекс с указанием выражения для индексирования

CREATE INDEX property_type_idx ON plant_properties ((payload ->> 'type'));

GIN индекс для JSON

Существует два варианта использования GIN индекса

1) jsonb_ops - оп-класс по умолчанию. Поддерживает больше операций но занимает больше места и медленнее работает.

supports queries with the key-exists operators ?, ?| and ?&, the containment operator @>, and the jsonpath match operators @? and @@

CREATE INDEX property_type_idx ON plant_properties USING GIN (payload);

2) jsonb_path_ops - поддерживает меньше операций, создает индекс меньшкго размера, работает быстрее

not support the key-exists operators, but it does support @>, @? and @@

CREATE INDEX property_type_idx ON plant_properties USING GIN (payload jsonb_path_ops);

INCLUDE в индексах

Отдельно стоит сказать про INCLUDE фичу в индексах. Она позволит включить данные из JSON в индекс и выполнять критические запросы используя только индекс. Стоит осторожно использовать эту возможность, так как можно легко увеличить размер индекса на столько что он станет совсем бесполезен.

Constraints (Ограничения)

Мы можем использовать constraints для различных ограничений в JSON.

Например, в нашем примере есть обязательное поле в JSON, которое мы не вынесли в поле таблицы - это type. Выносить это поле не нужно, так как мы не используем его при запросах. Но вот требовать чтобы это поле было является хорошей идеей, ведь это поле - это главный признак по которому данные будут направлены в правильный обработчик.

Пример того как мы можем сделать обязательным поле type внутри jsonb поля таблицы:

DROP TABLE IF EXISTS plant_properties;
CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL CHECK (property -> 'type' IS NOT NULL)
);

INSERT INTO plant_properties (id, plant_id, property) VALUES
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "color", "value": "green"}'),
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "max_height", "value": 1.1, "units": "metre"}')
;

Если мы попытаемся добавить запись с JSON в котором не будет type то получим ошибку

INSERT INTO plant_properties (id, plant_id, property) VALUES
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"value": true}')
;

-- ERROR: ОШИБКА:  новая строка в отношении "plant_properties" нарушает ограничение-проверку "plant_properties_property_check"
-- DETAIL:  Ошибочная строка содержит (9f616311-0e42-445f-b200-b0f631c39edb, a03a347f-8435-4caa-aeee-3eaaa38983ba, {"value": true}).

еще можно ограничить тип значения в поле type

CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL CHECK (property -> 'type' IS NOT NULL AND jsonb_typeof(property -> 'type')='string')
);

-- При попытке добавить `{"type": 1, "value": true}`:
-- ERROR: ОШИБКА:  новая строка в отношении "plant_properties" нарушает ограничение-проверку "plant_properties_property_check"
-- DETAIL:  Ошибочная строка содержит (1b807e51-9704-4506-a4aa-3bfa711e1fd8, a03a347f-8435-4caa-aeee-3eaaa38983ba, {"type": 1, "value": true}).
-- SQL state: 23514

или можно огриничить множество значений, которое может принимать type

CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL CHECK ((property ->> 'type') IN ('color','perennial'))
);

И так далее, любые ограничения, чтобы обеспечить целостность данных.

JSON schema

С помошью Constraints мы можем организовать валидацию, но если хочется большего...

NOTE: Я не использовал описанные ниже расширения так как для установки они требуют магии make install && make installcheck. Эти расширения выглядят старыми и заброшенными. Если есть необходимость иметь валидацию с использованием JSON Schema эти расширения можно попробовать.

is_jsonb_valid

is_jsonb_valid is a native PostgreSQL extension to validate json schemas following Draft4. The extension exposes only one function is_jsonb_valid(schema jsonb, data jsonb) which returns a boolean depending on the success of the validation.

CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL CHECK (is_jsonb_valid('{"type": "string"}', property)
);

postgres-json-schema

postgres-json-schema allows validation of JSON schemas in PostgreSQL. It is implemented as a PL/pgSQL function and you can use it as a check constraint to validate the format of your JSON columns. postgres-json-schema supports the entire JSON schema draft v4 spec.

CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL CHECK (validate_json_schema('{"type": "string"}', property)
);

И другие подобные расширения для PostgreSQL.

Full-text search

На случай если имена растений - это свойство растения, которое помещено в JSON, который в PostgreSQL... И нам очень нужно сделать полнотекстовый поиск по именам растений.

Как работает полнотекстовый поиск кратко описано тут: Full-text search in PostgreSQL

Создадим таблицу и заполним ее данными

DROP TABLE IF EXISTS plant_properties;
CREATE TABLE IF NOT EXISTS plant_properties (
    id UUID NOT NULL PRIMARY KEY,
    plant_id UUID NOT NULL,
    property jsonb NOT NULL CHECK (property -> 'type' IS NOT NULL AND (property ->> 'type') IN ('color','perennial','max_height'))
);

INSERT INTO plant_properties (id, plant_id, property) VALUES
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "color", "value": "green"}'),
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "perennial", "value": true}'),
(uuid_generate_v4(), 'a03a347f-8435-4caa-aeee-3eaaa38983ba', '{"type": "max_height", "value": 1.1, "units": "metre"}')
;

Вариант 1: использовать GIN индекс для jsonb (jsonb_ops) поля

-- создадим индекс
CREATE INDEX property_type_idx ON plant_properties USING GIN (property);

-- выполним поиск
SELECT *
FROM plant_properties
WHERE property ->> 'type' = 'perennial'
;

Вариант 2: Full-text search

Этот вариант полностью покрывается индексом GIN (jsonb_ops) для jsonb и приведен здесь как иллюстрация того, что при необходимости реализовать очеь специфические алгоритмы можно использовать и его.

-- создадим индекс
CREATE INDEX property_type_idx
ON plant_properties
USING GIN (to_tsvector('english', property ->> 'type'))
;

-- выполним поиск
SELECT *
FROM plant_properties
WHERE to_tsvector('english', property ->> 'type') @@ plainto_tsquery('english', 'perennial')
;

Заключение

JSON в PostgreSQL стоит использовать в случаях если:

  • вы точно не знаете схему данных или она часто и сильно меняется. Например вы строите DWH
  • alter table может стать неразрешимой проблемой для вас
  • большая вложенность в данных. Ваши данные больше похожи на деревья чем на таблицы

В случае DWH, когда схема часто меняется, можно рассмотреть Data Vault или Anchor Modeling, что позволит получить выгоду от колоночно-ориентированных субд на больших данных.

JSON в PostgreSQL для OLTP назгрузки проигрывает в производительности реляционной схеме.

Вероятно, стоит потратить время на проектирование базы данных и получить хорошую производительность, отсутствие подводных камней и гарантии консистентности данных.