Статьи

Раскройте потенциал хранения JSON в Postgres

В статье Сары Мэй под названием « Почему вы никогда не должны использовать MongoDB » обсуждаются проблемы, с которыми вы столкнетесь, если попытаетесь использовать базу данных NoSQL, когда реляционная база данных будет намного лучше. Примером этого является случай, когда данные, которые, как считалось, находятся в бункере, должны пересекать границы (в чем хороши реляционные БД). Другой пример — когда вы храните имя пользователя в разных местах для легкого доступа, но когда пользователь обновляет свое имя, вы вынуждены найти все эти места, чтобы убедиться, что их информация актуальна.

Мой опыт создания веб-сайтов соответствовал этому мнению: если ваши объекты данных не живут в полной изоляции друг от друга (и вы уверены, что они будут такими в обозримом будущем), вам, вероятно, будет лучше использовать реляционные База данных, как Postgres .

До недавнего времени вам приходилось принимать это сложное решение заранее при моделировании ваших данных: документ или реляционная база данных ? Да, вы можете использовать две отдельные базы данных, используя каждый инструмент для достижения наилучших результатов. Однако вы бы увеличили сложность своего приложения, а также среды разработки и сервера.

Поддержка JSON в Postgres

У Postgres некоторое время была поддержка JSON, но, честно говоря, это было не так здорово из-за отсутствия методов индексации и извлечения ключей. С выпуском версии 9.2 Postgres добавил встроенную поддержку JSON. Наконец, вы можете использовать Postgres в качестве базы данных «NoSQL». В версии 9.3 Postgres усовершенствовал это, добавив дополнительные методы конструктора и экстрактора. 9.4 добавлена ​​возможность сохранять JSON как «Binary JSON» ( или JSONB ), который удаляет незначительные пробелы (не так уж и много), добавляет незначительные накладные расходы при вставке данных, но обеспечивает огромное преимущество при запросах: индексы .

С выпуском версии 9.4 служба поддержки JSON попыталась задать вопрос: «Использую ли я документ или реляционную базу данных?» ненужным. Почему бы не так?

«База данных документов? Реляционная? Почему бы не так?» через @codeship

Нажмите, чтобы чирикать

Я не собираюсь утверждать, что Postgres обрабатывает JSON так же, как MongoDB. MongoDB, в конце концов, был специально создан как хранилище документов JSON и обладает некоторыми довольно хорошими функциями, такими как конвейер агрегации . Но правда в том, что Postgres теперь довольно хорошо справляется с JSON.

Зачем мне даже данные JSON в моей БД?

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

  • Избегайте сложных объединений данных, которые разрознены или изолированы. Подумайте о чем-то вроде Trello , где они могут хранить всю информацию об одной карте (комментарии, задания и т. Д.) Вместе с самой картой. Денормализация данных позволяет получить карту и данные одним запросом.

Sample-Trello-карты

  • Сохраняйте данные, поступающие от внешней службы, в той же структуре и формате (как JSON), к которым они поступили. То, что заканчивается в базе данных, это именно то, что предоставил API. Посмотрите на объект отклика заряда от Stripe в качестве примера; он вложенный, имеет массивы и так далее. Вместо того, чтобы пытаться нормализовать эти данные в пяти или более таблицах, вы можете сохранить их как есть (и при этом запрашивать их).
  • Избегайте преобразования данных, прежде чем возвращать их через JSON API. Посмотрите на этот неприятный ответ JSON от FDA API о побочных эффектах наркотиков. Он глубоко вложен и имеет несколько массивов — построение этих данных в режиме реального времени по каждому запросу будет невероятно обременительным для системы.

Как использовать JSONB в Postgres

Теперь, когда мы рассмотрели некоторые преимущества и варианты использования для хранения данных JSON в Postgres, давайте посмотрим, как это на самом деле делается.

Определение столбцов

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

1
2
3
4
5
CREATE TABLE cards (
  id integer NOT NULL,
  board_id integer NOT NULL,
  data jsonb
);

Вставка данных JSON

Чтобы вставить данные JSON в базу данных, мы передаем все значение JSON в виде строки.

1
2
3
4
5
INSERT INTO cards VALUES (1, 1, '{"name": "Paint house", "tags": ["Improvements", "Office"], "finished": true}');
INSERT INTO cards VALUES (2, 1, '{"name": "Wash dishes", "tags": ["Clean", "Kitchen"], "finished": false}');
INSERT INTO cards VALUES (3, 1, '{"name": "Cook lunch", "tags": ["Cook", "Kitchen", "Tacos"], "ingredients": ["Tortillas", "Guacamole"], "finished": false}');
INSERT INTO cards VALUES (4, 1, '{"name": "Vacuum", "tags": ["Clean", "Bedroom", "Office"], "finished": false}');
INSERT INTO cards VALUES (5, 1, '{"name": "Hang paintings", "tags": ["Improvements", "Office"], "finished": false}');

Запрос данных

Данные, к которым у вас нет доступа, бесполезны. Далее мы рассмотрим, как запросить то, что мы ранее вставили в БД.

1
SELECT data->>'name' AS name FROM cards
1
2
3
4
5
6
7
8
name
----------------
Paint house
Wash dishes
Cook lunch
Vacuum
Hang paintings
(5 rows)

Фильтрация результатов

Очень часто фильтруют ваш запрос на основе столбца, и с помощью столбца JSONB мы можем фактически перейти к документу JSON и отфильтровать наши результаты на основе различных его свойств. В приведенном ниже примере у нашего столбца «data» есть свойство, называемое «Finished», и мы хотим получить результаты только в том случае, если «Finished» имеет значение true.

1
SELECT * FROM cards WHERE data->>'finished' = 'true';
1
2
3
4
id | board_id |                                     data
----+----------+--------------------------------------------------
 1 |        1 | {"name": "Paint house", "tags": ["Improvements", "Office"], "finished": true}
(1 row)

Проверка существования столбца

Здесь мы найдем количество записей, в которых столбец данных содержит свойство с именем «ингредиенты».

1
SELECT count(*) FROM cards WHERE data ? 'ingredients';
1
2
3
4
count
-------
    1
(1 row)

Расширение данных

Если вы некоторое время работали с реляционными базами данных, вы хорошо знакомы с методами агрегирования: count, avg, sum, min, max и т. Д. Теперь, когда мы имеем дело с данными JSON, единственной записью в нашей базе данных может содержать массив. Таким образом, вместо сокращения результатов в совокупность мы можем теперь расширить наши результаты.

1
2
3
4
SELECT
  jsonb_array_elements_text(data->'tags') as tag
FROM cards
WHERE id = 1;
1
2
3
4
5
tag
--------------
Improvements
Office
(2 rows)

В вышеприведенном примере я хотел бы отметить три вещи:

  1. Две строки были возвращены, даже если мы запросили одну строку из нашей базы данных. Это равно количеству тегов, содержащихся в этой строке.
  2. Я использовал форму метода jsonb вместо формы json . Используйте тот, который соответствует тому, как вы определили столбец.
  3. Я получил доступ к полю тегов, используя -> вместо — >>, как раньше. -> вернет атрибут в виде объекта JSON, тогда как — >> вернет свойство в виде целого числа или текста (проанализированная форма атрибута).

Реальная выгода JSONB: индексы

Мы хотим, чтобы наше приложение было быстрым. Без индексов база данных вынуждена переходить от записи к записи ( сканирование таблицы ), проверяя, выполняется ли условие. Это не отличается с данными JSON. На самом деле, это, скорее всего, еще хуже, так как Postgres также должен вмешиваться в каждый документ JSON.

Я увеличил наши тестовые данные с пяти записей до 10000. Таким образом, мы можем начать видеть некоторые последствия для производительности при работе с данными JSON в Postgres, а также способы их решения.

1
SELECT count(*) FROM cards WHERE data->>'finished' = 'true';
1
2
3
4
count
-------
4937
(1 row)
1
2
3
4
5
Aggregate (cost=335.12..335.13 rows=1 width=0) (actual time=4.421..4.421 rows=1 loops=1) -> Seq Scan on cards (cost=0.00..335.00 rows=50 width=0) (actual time=0.016..3.961 rows=4938 loops=1)
    Filter: ((data ->> 'finished'::text) = 'true'::text)
    Rows Removed by Filter: 5062
Planning time: 0.071 ms
Execution time: 4.465 ms

Теперь, это был не такой медленный запрос на 5 мс, но давайте посмотрим, сможем ли мы его улучшить.

1
CREATE INDEX idxfinished ON cards ((data->>'finished'));

Если мы выполним тот же запрос, у которого теперь есть индекс, мы сократим время вдвое.

1
2
3
4
count
-------
4937
(1 row)
1
2
3
4
5
6
7
Aggregate (cost=118.97..118.98 rows=1 width=0) (actual time=2.122..2.122 rows=1 loops=1) -> Bitmap Heap Scan on cards (cost=4.68..118.84 rows=50 width=0) (actual time=0.711..1.664 rows=4938 loops=1)
    Recheck Cond: ((data ->> 'finished'::text) = 'true'::text)
    Heap Blocks: exact=185
    -> Bitmap Index Scan on idxfinished (cost=0.00..4.66 rows=50 width=0) (actual time=0.671..0.671 rows=4938 loops=1)
        Index Cond: ((data ->> 'finished'::text) = 'true'::text)
Planning time: 0.084 ms
Execution time: 2.199 ms

Наш запрос теперь использует преимущества созданного нами индекса idxfinished , а время запроса сократилось примерно вдвое.

Более сложные индексы

Одна из замечательных особенностей поддержки JSON в Postgres заключается в том, что вы можете запрашивать, содержит ли массив определенное значение.

1
2
3
4
SELECT count(*) FROM cards
WHERE
  data->'tags' ? 'Clean'
  AND data->'tags' ? 'Kitchen';
1
2
3
4
count
-------
1537
(1 row)
1
2
3
4
5
6
7
8
Aggregate (cost=385.00..385.01 rows=1 width=0) (actual time=6.673..6.673 rows=1 loops=1) -> Seq Scan on cards (cost=0.00..385.00 rows=1 width=0) (actual time=0.021..6.500 rows=1537 loops=1)
    Filter: (((data -> 'tags'::text) ? 'Clean'::text) AND ((data -> 'tags'::text) ? 'Kitchen'::text))
    Rows Removed by Filter: 8463
Planning time: 0.063 ms
Execution time: 6.710 ms
(6 rows)
 
Time: 7.314 ms

Начиная с Postgres 9.4, наряду с типом данных JSONB появились индексы GIN (Generalized Inverted Index). С помощью индексов GIN мы можем быстро запрашивать данные, используя операторы JSON @>,?,? & И? |. Подробную информацию об операторах вы можете найти в документации Postgres .

1
CREATE INDEX idxgintags ON cards USING gin ((data->'tags'));
1
2
3
4
count
-------
1537
(1 row)
01
02
03
04
05
06
07
08
09
10
Aggregate (cost=20.03..20.04 rows=1 width=0) (actual time=2.665..2.666 rows=1 loops=1) -> Bitmap Heap Scan on cards (cost=16.01..20.03 rows=1 width=0) (actual time=0.788..2.485 rows=1537 loops=1)
    Recheck Cond: (((data -> 'tags'::text) ? 'Clean'::text) AND ((data -> 'tags'::text) ? 'Kitchen'::text))
    Heap Blocks: exact=185
    -> Bitmap Index Scan on idxgintags (cost=0.00..16.01 rows=1 width=0) (actual time=0.750..0.750 rows=1537 loops=1)
        Index Cond: (((data -> 'tags'::text) ? 'Clean'::text) AND ((data -> 'tags'::text) ? 'Kitchen'::text))
Planning time: 0.088 ms
Execution time: 2.706 ms
(8 rows)
 
Time: 3.248 ms

Снова мы видим удвоение скорости. Это было бы еще более явным, если бы наш набор данных был больше 10 000 записей. Вывод объяснения анализа также показывает нам, как он использует индекс idxgintags .

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

1
2
3
4
5
CREATE INDEX idxgindata ON cards USING gin (data);
 
SELECT count(*) FROM cards
WHERE
  data @> '{"tags": ["Clean", "Kitchen"]}';
1
2
3
4
5
6
count
-------
1537
(1 row)
 
Time: 2.837 ms

Как я могу сделать это в Rails?

Давайте рассмотрим, как создавать таблицы со столбцами JSONB в Rails, а также как запрашивать эти столбцы JSONB и обновлять данные. Для получения дополнительной информации вы можете обратиться к документации Rails по этому вопросу .

Определение столбцов JSONB

Перво-наперво нам нужно создать миграцию, которая создаст таблицу, столбец которой указан как JSONB.

1
2
3
4
create_table :cards do |t|
  t.integer :board_id, null: false
  t.jsonb :data
end

Запрос данных JSON из Rails

Давайте определим область, которая поможет нам найти «готовые» карты. Следует отметить, что, хотя готовый столбец является истинным значением JSON, при запросе к нему нам нужно будет использовать строковое значение true. Если мы посмотрим на готовый столбец в Rails, то увидим значение TrueClass, и оно также является истинным значением JSON при просмотре данных в psql, но, несмотря на это, его нужно будет запрашивать с помощью String.

1
2
card.data["finished"].class
# TrueClass

Вот код для добавления : готового контекста в наш класс Card. Мы не сможем использовать обычный синтаксис условия where, к которому мы привыкли, но нам придется полагаться на более специфичный синтаксис Postgres. Следует отметить, что готовый столбец также должен быть заключен в строку, как вы ссылаетесь на столбцы JSON в Postgres.

1
2
3
4
5
6
7
class Card < ActiveRecord::Base
 
  scope :finished, -> {
    where("cards.data->>'finished' = :true", true: "true")
  }
 
end
1
2
3
irb(main):001:0> Card.finished.count
   (2.8ms)  SELECT COUNT(*) FROM "cards" WHERE (cards.data->>'finished' = 'true')
=> 4937

Управление данными JSON в Rails

Любой столбец, определенный как JSON или JSONB, будет представлен как хэш в Ruby.

1
2
3
4
5
6
7
irb(main):001:0> card = Card.first
  Card Load (0.9ms)  SELECT  "cards".* FROM "cards"  ORDER BY "cards"."id" ASC LIMIT 1
=> #<Card id: 1, board_id: 1, data: {"name"=>"Organize Kitchen", "tags"=>["Fast", "Organize", "Carpet", "Cook", "Kitchen"], "finished"=>false}>
irb(main):002:0> card.data
=> {"name"=>"Organize Kitchen", "tags"=>["Fast", "Organize", "Carpet", "Cook", "Kitchen"], "finished"=>false}
irb(main):003:0> card.data.class
=> Hash

Обновление данных JSON в Rails

Обновление данных JSON довольно просто. Это просто вопрос изменения значения Hash и последующего вызова save для нашей модели. Чтобы обновить «готовое» поле к true, мы запустим это:

1
2
3
4
5
6
7
irb(main):004:0> card.data["finished"] = true
=> true
irb(main):005:0> card.save
   (0.2ms)  BEGIN
  SQL (0.9ms)  UPDATE "cards" SET "data" = $1 WHERE "cards"."id" = $2  [["data", "{\"name\":\"Organize Kitchen\",\"tags\":[\"Fast\",\"Organize\",\"Carpet\",\"Cook\",\"Kitchen\"],\"finished\":true}"], ["id", 1]]
   (6.6ms)  COMMIT
=> true

Вы заметите, что как Rails, так и Postgres не могут обновить только одно «законченное» значение в данных JSON. Это фактически заменяет все старое значение новым значением.

Вывод

Мы видели, что Postgres теперь содержит несколько очень мощных конструкций JSON. Смешивание возможностей реляционных баз данных (простое внутреннее объединение — прекрасная вещь, не правда ли?) С гибкостью типа данных JSONB дает множество преимуществ без сложности наличия двух отдельных баз данных.

Вы также можете избежать компромиссов, которые иногда присутствуют в базах данных документов (если вам когда-нибудь понадобится обновить ссылку на имя пользователя в пяти разных местах, вы поймете, о чем я говорю.) Попробуйте! Кто сказал, что вы не можете научить старую собаку новым трюкам?

«Раскройте возможности хранения JSON в Postgres» через @codeship

Нажмите, чтобы чирикать