PostgreSQL — один за всех? Как работать с нетипичными данными в реляционной системе

Смотрим, как вездесущий PostgreSQL справляется с нестандартными для реляционной системы управления базами данных (СУБД) ролями: хранением и поиском временных рядов, пар «ключ — значение», эмбеддингов для больших языковых моделей и многомерных кубов. Отвечаем на вопрос: действительно ли так нужно строить сложные архитектуры со множеством разнородных систем хранения данных — MongoDB, Redis, InfluxDB, Pinecone, ClickHouse, Apache Cassandra — или можно обойтись одним PostgreSQL?

Привет, Хабр! Меня зовут Александр Брейман, я доцент департамента программной инженерии факультета компьютерных наук НИУ ВШЭ и по совместительству эксперт Учебного центра IBS по управлению данными и архитектуре ПО. В прошлой статье я рассказывал о миграции с Oracle на PostgreSQL, а сегодня разберу, как последний работает с нетипичными видами данных.

Предыстория реляционных СУБД

Начну немного издалека. Разработка первой в истории базы данных началась в США в 1950-е годы. Как водится, заказчиком инновации был военно-промышленный комплекс. В ответ на появление в СССР дальних бомбардировщиков и ядерных бомб Пентагон решил разработать объединенную систему ПВО, в рамках которой актуальные данные обо всем, что происходит в небе, должны были собираться так, чтобы быть сразу доступными для принятия оперативных решений, например, о подъеме истребителей для перехвата цели. Вот это место, куда сразу поступают все имеющиеся данные, и назвали базой данных.

Схема проекта. Источник: http://www.fortwiki.com/File:Sage_Data_Flow.jpg

В основе такой базы данных лежали следующие принципы:

  • одна единая база данных на всю страну;

  • писатели вносят имеющиеся у них данные один раз;

  • читатели видят всегда актуальные данные;

  • для пользователей с разными задачами создаются отдельные представления (view).

Проект под кодовым названием SAGE сначала предложили IBM, но компания отказалась по причине нехватки программистов. На тот момент в IBM работало порядка 200 разработчиков, а проект, по их оценке, требовал привлечения тысяч специалистов. В результате под проект создали отдельную компанию — System Development Corporation. В разное время над SAGE работали до 7 000 человек.

К чему я завел этот разговор? Позже для разработки программ, обращающихся к базам данных, пришлось нанимать программистов, далеко не все из которых обладали глубоким математическим бэкграундом. Первые СУБД — это библиотеки подпрограмм, умеющие перемещать данные между памятью и файлами. Структура хранения в них либо очень простая — текст, либо похожая на структуры данных в памяти — списки и указатели. Проблема в том, что разработчики вынуждены были писать низкоуровневый сложный и очень хрупкий код, крошечная ошибка в котором могла полностью разрушить весь набор данных, а не только ту запись, к которой он обращался. 

Собственно, именно эту проблему и были призваны решить реляционные СУБД (понятие «реляционный» основано на англ. relation — «отношение, зависимость, связь»). Эта концепция, скрывающая физический уровень хранения от разработчиков, которые теперь работают с более высокоуровневым и защищенным логическим представлением, была придумана в IBM в конце 1960-х математиком Эдгаром Коддом. Вместо указателей в реляционной модели — значения в столбцах таблицы. Связь строк идет только через совпадение значений, без каких-либо «физических» указателей.

Эдгар Кодд описал 12 правил, «Codd’s 12 rules», которым должна удовлетворять реляционная СУБД. Хотя на самом деле их 13, потому что исчисление начинается с нуля. Вот эти правила:

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

1. Вся информация в базе данных на логическом уровне должна быть явно представлена единственным способом: значениями в таблицах.

2. Каждое отдельное значение данных должно быть логически доступно с помощью комбинации имени таблицы, имени столбца и значения первичного ключа.

3. Неизвестные или отсутствующие значения NULL, должны поддерживаться для всех типов данных при выполнении любых операций.

4. Словарь данных должен сохраняться в форме реляционных таблиц, и СУБД должна поддерживать доступ к нему при помощи стандартных языковых средств.

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

6. Каждое представление (view) должно поддерживать все операции манипулирования данными: выборку, вставку, изменение и удаление.

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

8. Приложения не должны зависеть от используемых способов хранения данных на физических носителях.

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

10. Вся информация, необходимая для поддержания и контроля целостности, должна находиться в словаре данных.

11. На работу приложения не должно влиять физическое расположение базы данных.

12. Разноуровневые языки доступа к данным должны быть согласованы между собой.

Проще говоря, мы видим следующие ключевые принципы:

  • разделение логической и физической структуры базы данных;

  • логическая структура: никаких указателей и ссылок;

  • только таблицы, строки которых имеют одинаковую структуру;

  • связь строк в разных таблицах: одинаковое значение в соответствующих столбцах;

  • язык, позволяющий выразить сложные составные запросы;

  • физическая структура — НЕ копия логической структуры, может быть очень сложной.

Позже к требованиям Кодда добавили еще одно — транзакционность. Система управления базами данных должна уметь выполнять группы операций — транзакции — так, чтобы при любом сбое в этом процессе откатываться к исходному состоянию и чтобы никаких следов от попытки выполнения не оставалось.

Главная проблема реляционных СУБД

Как это часто бывает, проблемы проистекают из особенностей решения. Слабое место реляционных баз данных — это масштабирование. Транзакции реализуются в СУБД локальным журналом или версионированием записей, а делать его глобальным или распределенным довольно сложно.

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

Шардирование

Шардирование — это горизонтальное масштабирование кластера, когда отдельные части одной базы данных (шарды) размещаются на разных узлах.

5cad2b74d622e6db5e9844c7b1f50301.png

При таком подходе возможна параллельная запись данных в несколько шардов, а их независимое друг от друга расположение защищает от одновременного отказа «всего и сразу». Тем не менее при отказе даже одного узла хранившаяся на нем часть данных становится недоступной. С этим можно бороться другим механизмом — репликацией.

Репликация

Репликация — это автоматическое копирование данных между несколькими узлами. Реплика — это копия данных.

5a613979f07fc2188c78786b9194ec85.png

Репликация повышает отказоустойчивость системы и обеспечивает параллельное чтение данных. Ограничения подхода возникают в том случае, если запрашиваемый узел, на который планируется копирование данных, по какой-то причине не отвечает. Тогда другой узел может либо пытаться «достучаться» до него, и тогда время выполнения записи удлиняется на длительность такого ожидания, либо сохранить данные у себя и отрапортовать об успехе операции, рискуя несогласованностью реплик. Более строго и подробно эта идея описана в виде CAP-теоремы.

Шардирование и репликация

Обычно используются вместе:

c98b07163f92053414d5866b9426d65e.png

Сделать кластер из реляционных баз данных c шардированием и репликацией — не такая уж сложная задача, но ее приходилось реализовывать каждой компании самостоятельно. Например, Google с середины 1990-х годов хранил все свои данные — и поисковый индекс, и почту, и карты — в кластерах MySQL и полностью перешел на собственные технологии типа Spanner только после 2013 года. Сами реляционные системы стали предоставлять эти механизмы «из коробки» совсем недавно. Например, в Oracle шардирование появилось только в 2018 году, а в PostgreSQL оно полноценно реализовано только в платном Postgres Pro Shardman.

Альтернативные системы хранения данных

А до тех пор разработчики пытались найти замену «несовершенной» реляционной модели. Так, в конце 2000-х годов на арену вышли другие системы управления базами данных класса NoSQL, например MongoDB. Ее разработчики предложили автоматическое шардирование с репликацией, пусть и с предварительной настройкой, прямо «из коробки».

В класс NoSQL входят такие распространенные типы систем, как «ключ — значение», документные, графовые и объектно ориентированные системы. Все эти СУБД, а также временные ряды, поисковые движки и векторные базы данных были придуманы в свое время как способ нивелировать недостатки реляционных СУБД, в первую очередь — плохо выявляемые сбои в больших кластерах при использовании consumer-grade оборудования и открытого ПО без специальных требований к надежности, а также ошибки в архитектуре систем, использующих высоконормализованные реляционные модели данных.

Нетипичные данные и способы расширения PostgreSQL

Однако реляционные базы данных вообще и PostgreSQL в частности не стоят на месте. Сегодня различные расширения СУБД позволяют решать большинство задач, не прибегая к «зоопарку» из множества различных систем. Рассмотрим работу с отдельными видами нетипичных — то есть нетабличных — данных.

Как хранить и искать документы

В упомянутой выше модели «ключ — значение» часть данных мы считаем ключом, по которому можно искать и записывать определенный массив байтов. У такой модели максимально простой API с тремя базовыми операциями: put, get и delete.

Документ — это, по сути, вариант значения в модели «ключ — значение», но с дополнительными возможностями. К документу можно обращаться по его частям — полям, можно использовать специальные функции, например XPath для XML, автоматически валидировать данные, строить индексы по полям, а не только осуществлять поиск по ключу, и проводить специальные операции. Подобные возможности реализованы в том же MongoDB.

В PostgreSQL похожие вещи пытались сделать еще в конце 1990-х, но приличный результат получился только в 2014 году за счет реализации типа JSON. Сегодня Postgres предлагает для работы с документами JSONB и JSONPATH.

JSONB — это тип данных, он позволяет разобрать текстовый документ при записи и затем хранить его в бинарном формате. Примитивные типы при этом отображаются в типы PostgreSQL: text, numeric и boolean. JSONB предоставляет множество функций для создания и обработки документов, дополнительные операторы для обращения к ключам и вложенным элементам, а также возможность индексирования по всему документу или по отдельным ключам.

Что касается JSONPATH, это язык выборки частей документов, созданный по мотивам языка выделения частей XML-документов XPath, позволяющий декларативно описать маршрут внутри документа, по которому нужно пройти, чтобы получить нужные элементы. Например, можно выбрать поле с некоторым именем, не зная заранее, на каком уровне вложенности оно находится.

Первые релизы JSONB обладали некоторыми ограничениями, но в 2021 году схему хранения ощутимо улучшили, и операции теперь выполняются заметно быстрее. На сегодняшний день JSONB — полноценная рабочая замена MongoDB. Пожалуй, единственное преимущество MongoDB в данном случае — простота масштабирования, в первую очередь за счет шардирования. Если вы берете Postgres Pro Shardman, то для вас смысл работы с MongoDB пропадает совсем. Если же вы «сидите» на бесплатной версии PostgreSQL, то вопрос с документами «из коробки» получится решить только локально.

Как хранить и работать с временными рядами

Временной ряд — это последовательность измерений некоторого параметра. Речь может идти, например, о температуре, давлении, перемещениях такси, финансовых транзакциях, биржевых данных или звонках в кол-центр. Каждый элемент временного ряда содержит метку времени и значение. В отличие от многих других баз данных запросы к базам данных временных рядов очень редко обращаются к одной записи и, как правило, содержат агрегацию. Например: выведи среднее, максимальное и минимальное значение температуры с указанного датчика за каждую минуту последнего часа.

В PostgreSQL для работы с временными рядами применяется опенсорсное решение TimescaleDB. Суть подхода в том, что создается так называемая гипертаблица, то есть таблица, автоматически секционированная по столбцу с меткой времени в соответствии с заданным интервалом, например по часу на секцию. Каждая секция при этом хранится и индексируется отдельно, а по истечении заданного периода «холодные» секции, в которые уже больше не будут добавляться записи, сжимаются для более компактного хранения. Среди других удобных особенностей решения — политика автоматического удаления устаревших данных, распределенное хранение секций с репликацией и наличие специализированных гиперфункций.

Выглядит это примерно так:

CREATE TABLE conditions(
 tstamp timestamptz NOT NULL,
 device VARCHAR(32) NOT NULL,
 temperature FLOAT NOT NULL);

SELECT create_hypertable(
'conditions', 'tstamp',
chunk_time_interval => INTERVAL '1 day'
);

SELECT create_hypertable(
'conditions', 'tstamp',
partitioning_column => 'device',
number_partitions => 8,
chunk_time_interval => INTERVAL '1 day');

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

В целом TimescaleDB прекрасно работает с временными рядами. Вообще, баз данных с временными рядами не так много. Пожалуй, ближайший открытый конкурент TimescaleDB в этой области — это ClickHouse. Оба решения по-своему хороши, и чтобы понять, какое из них будет лучше работать на ваших данных и ваших запросах, лучше попробовать оба и принять решение по итогам пилота.

Как хранить эмбеддинги и быстро их искать

Еще один тип данных, плохо приспособленных для реляционных СУБД, — это векторы. Каждая запись в векторной базе данных — это большой массив из чисел, или вектор в многомерном пространстве. Типичный запрос к такой базе: по заданному вектору найти векторы, наиболее близкие к нему. Определить близость векторов можно разными способами, например через косинусную меру или евклидово расстояние. Полноценный ответ на запрос в неспециализированных системах требует перебора всех хранящихся векторов, поскольку традиционные индексы — B-tree и Bitmap — не в силах эффективно ускорить решение такой задачи.

Сама по себе история с векторами сейчас предельно популярна, поскольку она используется при работе с большими языковыми моделями типа ChatGPT в рамках подхода retrieval augmented generation (RAG). В больших языковых моделях заложено кодирование текста/токенов в такие вектора — эмбеддинги. Мы можем закодировать все документы в виде эмбеддингов и сохранить их в базе данных, а затем так же закодировать запрос и найти ближайшие к нему эмбеддинги. После извлечения ближайших по смыслу документов, мы добавляем их к промпту для обогащения контекста запроса и передаем его в большую языковую модель. На подходе RAG сейчас строится много расширений нейронных сетей, что привело к всплеску популярности векторных баз данных. Таким же образом, кстати, в векторных базах можно искать изображения или звуки, строить рекомендации, а также находить аномалии, то есть обнаруживать отсутствие достаточно близких векторов.

В PostgreSQL для всего этого есть простое расширение pgvector. Оно работает с типом данных vector и предоставляет два алгоритма поиска — IVFlat и HNSW, — из которых нужно выбрать при создании таблицы. Один из них более быстрый, менее требовательный к памяти, но менее точный, а другой — точнее, но медленнее и ресурсозатратнее. По сути, они кластеризируют вектора, а затем осуществляют перебор в небольших кластерах.

Синтаксически это выглядит так:

select text, embedding <=> (
select embedding from t where text = ‘??'
) distance
from t
order by distance;

Стоит сказать, что векторных баз сегодня представлено множество, включая такие многомиллионные стартапы, как Pinecone и Qdrant. PostgreSQL с расширением pgvector, — вполне достойный вариант. Основным ограничением будет все то же масштабирование.

Как строить многомерные кубы

Последняя специфическая история, не предназначенная для реляционных баз данных, — это многомерные кубы. Они были придуманы как инструмент для построения отчетов в хранилище данных, рассчитанный на НЕпрограммистов. Чтобы непрограммисты не напортачили в базе данных, им предоставили специальные витрины на многомерных кубах. Технология интерактивной аналитической обработки данных OLAP (online analytical processing) заключается в подготовке агрегированной информации на основе больших массивов данных, структурированных по многомерному принципу. Витрины данных позволяют пользователям самостоятельно описывать запросы и вид отчетов с помощью простого интерфейса и снижают риск перегрузки базы неоптимальными запросами.

В языке SQL для реализации OLAP-сценариев в реляционных базах данных стандартные расширения были добавлены уже четверть века назад. Это GROUP BY ROLLUP, GROUP BY CUBE и GROUP BY GROUPING SETS. Расширения позволяют построить витрину по исходным данным и дальше сделать интерфейс.

Вердикт

Реляционные базы были ответом на очень важную потребность индустрии — отвязать физический уровень хранения от логического и набрать сотрудников, которые смогут писать запросы к базе данных на логическом уровне, не рискуя ее поломать. Эту простоту логики нам и подарили реляционные базы, и отказываться от нее на сегодняшний день было бы странно. Добавление NoSQL-решений, которые справлялись с нетипичными для реляционных баз данными, но не отличались простотой языка, имело смысл в нулевых годах. Но сегодня реляционные СУБД, включая PostgreSQL, уже откликнулись на волну NoSQL и внедрили ряд инструментов, которые позволяют справляться с большинством задач, включая полнотекстовый поиск, работу с временными рядами и даже векторными данными. Конечно, в устройстве этих инструментов придется сначала разобраться, но это определенно проще, чем использовать новую СУБД под каждый тип задачи и нанимать штат специалистов, разбирающихся в их устройстве.

© Habrahabr.ru