Добрый день, меня зовут Павел Поляков, я Principal Engineer в каршеринг компании SHARE NOW, в Гамбурге в ???????? Германии. А еще я автор Telegram-канала Хороший разработчик знает, где рассказываю обо всем, что должен знать хороший разработчик.

Сегодня хочу поговорить о том стоит ли хранить данные в JSONB полях в PostgreSQL. Как это влияет на производительность?

Почему это важно

Новый сервис

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

Обычно мы используем PostgreSQL, а именно Amazon Aurora PostgreSQL. В этот раз решили тоже использовать ее. Я начал проектировать базу данных. Чтобы реализовать бизнес требования, нам нужно было записывать результат в табличку, назовем ее history. Каждый раз, когда наш сервис триггер он будет что-то делать и получать результат. На одно действие результатов может быть как 0 так и несколько, но обычно до пяти. Я решил, что можно сохранять их как массив в поле типа JSONB.

У команды есть вопросы

Я предложил это решение команде. В целом команда была не против, но среди нас не было и экспертов масштабном использовании JSONB полей. Мы знали, что результатов будет сотни тысяч. И что по ним, иногда, нужно будет искать. Мне задали вопрос — а такое решение точно не будет сильно медленнее чем обычная схема, где каждый результат сохранялся бы в отдельной записи? Я сказал, что скорее всего не будет, ведь JSONB уже давно на рынке и по полям в объекте, если надо, можно создать индекс.

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

Проверяем

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

  • Схема c JSONB. Одна таблица — history_a, где результаты для каждого запроса будут сохраняться как массив в JSONB колонке

  • Схема "обычная". Две таблицы — одна для запросов — history_b, а другая для результатов — history_b_results. Каждый результат записывается отдельно.

Чтобы было интереснее, предположим, что результаты содержат информацию про начисленные очки для какого-то пользователя. Эти очки истекают где-то в будущем. Поэтому каждый результат имеет три свойства: type, points и expiration_date.

Сначала я создал обе схемы, просто три таблицы в одной базе данных:

Схема базы данных
Схема базы данных

Написал два генератора данных, для JSONB схемы и для "обычной", и залил эти данные в базу данных. В результате в таблице history_a получилось 1.000.000 записей и по три записи результатов в JSONB колонке для каждой записи. В таблице history_b тоже 1.000.000 записей, на каждую из которых в таблице history_b_results приходится по 3 записи результатов, то есть 3.000.000.

Пришло время выполнять SQL запросы и измерять скорость. Я решил проверить два варианта — выборка по всем данным и выборка с фильтром по customer_uuid, что более приближенно к реальности. Чтобы усложнить, будем еще и фильтровать по expiration_date.

Если хотите повторить эксперимент, то репозиторий найдете здесь.

Выборка по всем данным

  • Схема c JSONB

explain analyze
select
    count(*)
from
    history_a,
    jsonb_to_recordset(results) as results(type text,
    points int,
    "expirationDate" text)
where
    TO_DATE("expirationDate", 'YYYY-MM-DD') > CURRENT_DATE - 10

Execution Time: 1393.279 ms

  • "Обычная" схема

explain analyze
select
    count(*)
from
    history_b hb
join history_b_results hbr on
    hb.id = hbr.history_b_id
where
    expiration_date > CURRENT_DATE - 10

Execution Time: 208.923 ms

Выборка с фильтром по customer_uuid

  • Схема с JSONB

explain analyze
select
    count(*)
from
    history_a,
    jsonb_to_recordset(results) as results(type text,
    points int,
    "expirationDate" text)
where
    customer_uuid = 'dfce725c-f88e-411f-aa21-4e97a311a25a' AND
    TO_DATE("expirationDate", 'YYYY-MM-DD') > CURRENT_DATE - 10

Execution Time: 103.321 ms

  • "Обычная" схема

explain analyze
select
    count(*)
from
    history_b hb
join history_b_results hbr on
    hb.id = hbr.history_b_id
where
    customer_uuid = '04f7ebcd-04f1-4213-ab3f-43986e33c176' and
    expiration_date > CURRENT_DATE - 10

Execution Time: 196.466 ms

В виде графика

Сравнение времени запросов к обоим схемам
Сравнение времени запросов к обоим схемам

Детали про измерение времени

Я не использовал никакой benchmark движок и не писал свой. Все запросы просто выполнял пару раз на MacBook Pro (15-inch, 2019). Результаты всегда были более менее одинаковые, смысл здесь не в миллисекундах, а в разнице которую видно невооруженным глазом и она не меняется.

Выводы

Результат меня удивил.

С одной стороны, если делать запрос по всему объему данных — миллионам записей, то "обычная" схема выигрывает. Но не разгромно. Разница во времени — секунда, для нашего случая это не критично.

Но если делать запрос приближенный к реальной жизни, то вариант с JSONB выигрывает! Изначально, я предполагал что результат с JSONB будет незначительно медленнее, но произшло обратное. Я рад, что я ошибался.

В результате мы уже год используем схему с JSONB и все работает отлично. Очевидно что ситуация с JSONB колонками будет только улучшаться. И это отлично, что в PostgreSQL можно совмещать реляционную и документоориентированную модель базы данных.

A еще...

В конце еще раз приглашу вас в свой Telegram-канал. На канале Хороший разработчик знает я минимум три раза в неделю простым языком рассказываю про свой опыт, хард скиллы и софт скиллы. Я 15+ лет в IT, мне есть чем поделиться. Все это нужно разработчику, чтобы делать свою работу хорошо, быть востребованным на рынке и получать высокую компенсацию.

Спасибо ????

Комментарии (25)


  1. censor2005
    21.10.2021 13:10
    +1

    Всё-таки интересен план проведения эксперимента. Возможно ли, что PostgreSQL кэширует результаты предыдущих запросов? На обе схемы каждый раз выполнялся один и тот же запрос, или разные? Данные в обеих таблицах одинаковые или разные? Может, распределение данных оказалось неудачным


    1. PavloPoliakov Автор
      21.10.2021 14:17

      Я пробовал с разными входными данными, не только теми что записал. Разное количество дней и разные customer_uuid. Результаты были такие же. Я думаю этот эксперимент позволяет посмотреть на картину "в целом".


  1. akhkmed
    21.10.2021 13:17
    +3

    Давайте индексы добавим. И данных побольше.


    1. PavloPoliakov Автор
      21.10.2021 14:20
      +1

      В моем случае "побольше данных" это был бы тест ради теста, не коррелирует с нашей бизнес задачей. А про индексы снизу написала @Insolita- будет еще лучше.


      1. akhkmed
        21.10.2021 14:28

        Не пытаюсь вас отговорить от вашего выбора, но когда данных станет чуть больше (раз в 10-100), то ожидание результата уже не оправдает ваши ожидания. А если сразу не прикинуть, как именно индексировать, то есть риск переделывать. Данные ведь только добавляются?


        1. PavloPoliakov Автор
          21.10.2021 14:32

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


  1. Insolita
    21.10.2021 13:40
    +3

    Да, если индексы добавить - можно и еще большей эффективности добиться, я тоже около года назад проверяла https://donnainsolita.medium.com/postgres-jsonb-vs-property-value-table-5f5b9f183a9d


    1. PavloPoliakov Автор
      21.10.2021 14:18

      Круто, спасибо за статью.


  1. LborV
    21.10.2021 14:26

    Разница во времени — секунда, для нашего случая это не критично.

    Секунда на самом деле это колоссально много, пока условный "воркер" крутит эту секунду запрос, остальные запросы стакаются. Как итог - вермя ожидания на выполнение будет расти коскадно


    1. PavloPoliakov Автор
      21.10.2021 14:30

      Это верное предположение, если это OLTP вариант использования. В нашем случае - нет, такие запросы только для аналитики бы вызывались. Поэтому секунда это мало.


  1. Kazzman
    21.10.2021 14:32
    +1

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


    1. PavloPoliakov Автор
      21.10.2021 14:35

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

      Вот полный EXPLAIN реляционного запроса:

      Finalize Aggregate  (cost=45949.77..45949.78 rows=1 width=8) (actual time=191.621..191.621 rows=1 loops=1)                                              |
        ->  Gather  (cost=45949.56..45949.77 rows=2 width=8) (actual time=191.490..196.400 rows=3 loops=1)                                                    |
              Workers Planned: 2                                                                                                                              |
              Workers Launched: 2                                                                                                                             |
              ->  Partial Aggregate  (cost=44949.56..44949.57 rows=1 width=8) (actual time=171.319..171.319 rows=1 loops=3)                                   |
                    ->  Nested Loop  (cost=0.42..44949.56 rows=1 width=0) (actual time=171.314..171.314 rows=0 loops=3)                                       |
                          ->  Parallel Seq Scan on history_b_results hbr  (cost=0.00..43934.00 rows=124 width=4) (actual time=171.313..171.313 rows=0 loops=3)|
                                Filter: (expiration_date > (CURRENT_DATE - 10))                                                                               |
                                Rows Removed by Filter: 1000000                                                                                               |
                          ->  Index Scan using history_b_pkey on history_b hb  (cost=0.42..8.19 rows=1 width=4) (never executed)                              |
                                Index Cond: (id = hbr.history_b_id)                                                                                           |
                                Filter: ((customer_uuid)::text = '7e240a94-261b-4eeb-b0d3-8c6de47d9557'::text)                                                |
      Planning Time: 0.148 ms                                                                                                                                 |
      Execution Time: 196.466 ms                                                                                                                              |


      1. Kazzman
        21.10.2021 14:55
        +4

        Тогда не понятно что вы хотели оценить. В случае с одной (индексированной) таблицей это явно оверхед на работу с jsonb, связанный также с полным извлечением данных jsonb. В случае с двумя таблицами всё упирается в просмотр бОльшей таблицы сканом. У вас даже время выполнения сопоставимое получилось для всех записей и по одному клиенту. Без индекса такой подзапрос не делают же. Без индекса просто ожидаемый результат.

        Вывод с выигрышность-невыигрышностью мне кажется не верный.

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


        1. PavloPoliakov Автор
          21.10.2021 15:16
          -1

          Я отвечал на такой вопрос - если мы данные храним эстетически красиво и компактно (для меня это вариант с JSONB), будет ли это драматически влиять на производительность? Ответ оказался - нет, не будет.


          1. Kilor
            22.10.2021 09:11
            +3

            Чтобы хоть как-то сравнить эффективность запросов, надо смотреть их планы при решении одной и той же задачи - иначе вот так и может получиться, что там спрятался Seq Scan, и сравнение JSON-варианта идет с не самой эффективной альтернативой. И на ее фоне результаты могут выглядеть приемлемо, хотя "на самом деле" могут проигрывать кратно.


  1. RajaKajiev
    21.10.2021 16:53

    WTF moments:
    1) зачем 100500 раз вызывать TO_DATE() для данных вместо одного вызова для параметра (CURRENT_DATE - 10) ?
    2) если требуется дописывать значения в history_b , то проблемой будет не сколько формат поля results, а сколько конфликты при update => нужно ещё поле version (хотя на время select это не должно драматически повлиять)

    P.S. 3) если нужен только Array - мы уверены, что хотим JSONB не из желания поэкспериментировать с JSONB?


    1. PavloPoliakov Автор
      21.10.2021 16:58

      Спасибо за фидбэк. Отвечая на ваши вопросы:

      1. Какая альтернатива? Это может быть частью jsonb_to_recordset?

      2. Наверное вы имеете в виду history_a(где JSONB). Верно дописывать было бы не слишком удобно, но у нас данные immutable.


      1. novoselov
        21.10.2021 17:45
        +1

        Если я правильно понял алтернатива поменять правую часть на константу

        expirationDate > TO_CHAR(CURRENT_DATE - 10, 'YYYY-MM-DD')

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


  1. vagon333
    21.10.2021 17:36
    +3

    Не могу сказать что тест аккуратный: JSON payload мелкий.
    Увеличьте размер JSON payload и посмотрите на реакцию DBMS на парсинг более крупных JSON полей.

    Результаты логичны:
    - в первом случае (Выборка по всем данным) вы парсите JSON и за это платите временем.
    - во втором случае (Выборка с фильтром по customer_uuid) вы добавляете поиск по GUID, что сужает поиск, и начитываете из одной таблицы, а в нормальной форме читаете из 2х таблиц с JOIN. Небольшая задержка логична.

    Повозившись с XML и JSON в базе данных с неконтролиуемой структурой XML/JSON, я вернулся к классической реляционной модели т.к. DBMS лучше заточены на них.


    1. PavloPoliakov Автор
      21.10.2021 22:05

      Спасибо за фидбэк


  1. aleksandy
    21.10.2021 20:24
    +2

    Тынц для глубокого погружения в тему от корифея.


  1. Asazio
    21.10.2021 22:04
    +2

    Совсем недавно вышло видео о JSON в Postgres, так хорошо рассказаны текущие проблемы и как и решили или собераются решить.

    https://www.youtube.com/watch?v=CPoNZRpcHf4


  1. edo1h
    31.10.2021 16:53

    а причём тут вообще jsonb? я не вижу, чтобы вы в запросах с ним работали, хотя изначально про это речь шла:


    Я сказал, что скорее всего не будет, ведь JSONB уже давно на рынке и по полям в объекте, если надо, можно создать индекс.


    1. PavloPoliakov Автор
      01.11.2021 11:19

      Я разворачиваю jsonb в рекордсет при запросе jsonb_to_recordset, согласно документации (https://www.postgresql.org/docs/9.4/functions-json.html) это JSON Processing Function.


      1. edo1h
        02.11.2021 03:01

        был невнимателен, извиняюсь