PostgreSQL — один за всех? Как работать с нетипичными данными в реляционной системе
Смотрим, как вездесущий PostgreSQL справляется с нестандартными для реляционной системы управления базами данных (СУБД) ролями: хранением и поиском временных рядов, пар «ключ — значение», эмбеддингов для больших языковых моделей и многомерных кубов. Отвечаем на вопрос: действительно ли так нужно строить сложные архитектуры со множеством разнородных систем хранения данных — MongoDB, Redis, InfluxDB, Pinecone, ClickHouse, Apache Cassandra — или можно обойтись одним PostgreSQL?
Привет, Хабр! Меня зовут Александр Брейман, я доцент департамента программной инженерии факультета компьютерных наук НИУ ВШЭ и по совместительству эксперт Учебного центра IBS по управлению данными и архитектуре ПО. В прошлой статье я рассказывал о миграции с Oracle на PostgreSQL, а сегодня разберу, как последний работает с нетипичными видами данных.
Предыстория реляционных СУБД
Начну немного издалека. Разработка первой в истории базы данных началась в США в 1950-е годы. Как водится, заказчиком инновации был военно-промышленный комплекс. В ответ на появление в СССР дальних бомбардировщиков и ядерных бомб Пентагон решил разработать объединенную систему ПВО, в рамках которой актуальные данные обо всем, что происходит в небе, должны были собираться так, чтобы быть сразу доступными для принятия оперативных решений, например, о подъеме истребителей для перехвата цели. Вот это место, куда сразу поступают все имеющиеся данные, и назвали базой данных.
В основе такой базы данных лежали следующие принципы:
одна единая база данных на всю страну;
писатели вносят имеющиеся у них данные один раз;
читатели видят всегда актуальные данные;
для пользователей с разными задачами создаются отдельные представления (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. Разноуровневые языки доступа к данным должны быть согласованы между собой.
Проще говоря, мы видим следующие ключевые принципы:
разделение логической и физической структуры базы данных;
логическая структура: никаких указателей и ссылок;
только таблицы, строки которых имеют одинаковую структуру;
связь строк в разных таблицах: одинаковое значение в соответствующих столбцах;
язык, позволяющий выразить сложные составные запросы;
физическая структура — НЕ копия логической структуры, может быть очень сложной.
Позже к требованиям Кодда добавили еще одно — транзакционность. Система управления базами данных должна уметь выполнять группы операций — транзакции — так, чтобы при любом сбое в этом процессе откатываться к исходному состоянию и чтобы никаких следов от попытки выполнения не оставалось.
Главная проблема реляционных СУБД
Как это часто бывает, проблемы проистекают из особенностей решения. Слабое место реляционных баз данных — это масштабирование. Транзакции реализуются в СУБД локальным журналом или версионированием записей, а делать его глобальным или распределенным довольно сложно.
Чтобы решить эту проблему, а заодно и повысить отказоустойчивость, были придуманы специальные механизмы — шардирование и репликация.
Шардирование
Шардирование — это горизонтальное масштабирование кластера, когда отдельные части одной базы данных (шарды) размещаются на разных узлах.
При таком подходе возможна параллельная запись данных в несколько шардов, а их независимое друг от друга расположение защищает от одновременного отказа «всего и сразу». Тем не менее при отказе даже одного узла хранившаяся на нем часть данных становится недоступной. С этим можно бороться другим механизмом — репликацией.
Репликация
Репликация — это автоматическое копирование данных между несколькими узлами. Реплика — это копия данных.
Репликация повышает отказоустойчивость системы и обеспечивает параллельное чтение данных. Ограничения подхода возникают в том случае, если запрашиваемый узел, на который планируется копирование данных, по какой-то причине не отвечает. Тогда другой узел может либо пытаться «достучаться» до него, и тогда время выполнения записи удлиняется на длительность такого ожидания, либо сохранить данные у себя и отрапортовать об успехе операции, рискуя несогласованностью реплик. Более строго и подробно эта идея описана в виде CAP-теоремы.
Шардирование и репликация
Обычно используются вместе:
Сделать кластер из реляционных баз данных 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 и внедрили ряд инструментов, которые позволяют справляться с большинством задач, включая полнотекстовый поиск, работу с временными рядами и даже векторными данными. Конечно, в устройстве этих инструментов придется сначала разобраться, но это определенно проще, чем использовать новую СУБД под каждый тип задачи и нанимать штат специалистов, разбирающихся в их устройстве.