Глава 3. Модели данных и языки запросов

Перевод из книги «Designing Data-Intensive Applications, 2nd Edition» подготовлен автором сайта

Table of Contents

Глава 3. Модели данных и языки запросов

Границы моего языка означают границы моего мира.
Людвиг Витгенштейн, Логико-философский трактат (1922)


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

Большинство приложений строится путем наложения одной модели данных поверх другой. Для каждого слоя ключевой вопрос звучит так: как он представлен с точки зрения следующего нижележащего слоя?

Например:

Как разработчик приложения, вы смотрите на реальный мир (в котором есть люди, организации, товары, действия, денежные потоки, сенсоры и т. д.) и моделируете его в терминах объектов или структур данных, а также API, которые манипулируют этими структурами. Эти структуры часто специфичны для вашего приложения.

Когда вы хотите сохранить эти структуры данных, вы выражаете их в терминах модели данных общего назначения, например, JSON- или XML-документов, таблиц в реляционной базе данных или вершин и рёбер в графе. Эти модели данных и являются темой данной главы.

Инженеры, которые разработали ваше ПО для баз данных, выбрали способ представления этих документных/реляционных/графовых данных в виде байтов в памяти, на диске или в сети. Такое представление может позволять выполнять запросы к данным, искать их, изменять и обрабатывать различными способами.

На ещё более низких уровнях инженеры-«железячники» выяснили, как представлять байты в терминах электрических токов, импульсов света, магнитных полей и т.п.

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

На практике широко применяются несколько различных моделей данных, зачастую для разных целей. Для некоторых типов данных и некоторых запросов одна модель подходит естественно, а другая выглядит громоздкой. В этой главе мы исследуем такие компромиссы, сравнив реляционную модель, документную модель, графовые модели данных, event sourcing и dataframes. Мы также кратко рассмотрим языки запросов, которые позволяют работать с этими моделями. Это сравнение поможет вам понять, когда использовать ту или иную модель.

ТЕРМИНОЛОГИЯ: ДЕКЛАРАТИВНЫЕ ЯЗЫКИ ЗАПРОСОВ

Многие языки запросов, рассматриваемые в этой главе (такие как SQL, Cypher, SPARQL или Datalog), являются декларативными. Это означает, что вы указываете шаблон данных, который хотите получить, — какие условия должны удовлетворяться результатами и каким образом данные должны быть трансформированы (например, отсортированы, сгруппированы или агрегированы), — но не описываете, как достичь этой цели. Оптимизатор запросов в СУБД может сам решить, какие индексы и какие алгоритмы соединений использовать, и в каком порядке выполнять различные части запроса.

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

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

Реляционная модель против документной модели

Наиболее известная модель данных сегодня — это, вероятно, SQL, основанная на реляционной модели, предложенной Эдгаром Коддом в 1970 году: данные организованы в отношения (в SQL они называются таблицами), где каждое отношение — это неупорядоченное множество кортежей (строк в SQL).

Изначально реляционная модель была теоретическим предложением, и многие тогда сомневались, можно ли её эффективно реализовать. Однако к середине 1980-х системы управления реляционными базами данных (СУБД) и SQL стали основными инструментами для большинства, кому требовалось хранить и запрашивать данные с какой-либо регулярной структурой. Многие сценарии управления данными десятилетиями остаются доминируемой областью реляционных данных — например, бизнес-аналитика.

Со временем появилось множество конкурирующих подходов к хранению и запросу данных. В 1970-х и начале 1980-х основными альтернативами были сетевая и иерархическая модели, но реляционная модель вытеснила их. Объектные базы данных появились и снова ушли в конце 1980-х и начале 1990-х. XML-базы появились в начале 2000-х, но получили лишь нишевое распространение. Каждый конкурент реляционной модели в своё время вызывал огромный хайп, но он никогда не был долговечным. Вместо этого SQL расширился, включив в себя другие типы данных помимо реляционного ядра — например, добавив поддержку XML, JSON и графовых данных.

В 2010-х NoSQL стал новым модным словом, пытавшимся свергнуть доминирование реляционных баз данных. NoSQL не означает одну конкретную технологию, а представляет собой набор идей вокруг новых моделей данных, гибкости схем, масштабируемости и движения в сторону моделей лицензирования open source. Некоторые базы позиционировали себя как NewSQL, так как они стремились обеспечить масштабируемость систем NoSQL вместе с моделью данных и транзакционными гарантиями традиционных реляционных баз данных. Идеи NoSQL и NewSQL сильно повлияли на дизайн систем данных, но по мере того как принципы были широко восприняты, использование этих терминов сошло на нет.

Одним из долгосрочных эффектов движения NoSQL стала популярность документной модели, которая обычно представляет данные в виде JSON. Эта модель изначально получила распространение благодаря специализированным документным базам данных, таким как MongoDB и Couchbase, хотя большинство реляционных баз данных теперь также добавили поддержку JSON.

По сравнению с реляционными таблицами, которые часто воспринимаются как имеющие жёсткую и негибкую схему, JSON-документы считаются более гибкими.
Плюсы и минусы документных и реляционных данных активно обсуждаются; давайте рассмотрим некоторые ключевые моменты этой дискуссии.

Объектно-реляционный разрыв

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

Примечание

Термин impedance mismatch заимствован из электроники. Каждая электрическая цепь имеет определённый импеданс (сопротивление переменному току) на своих входах и выходах. Когда вы соединяете выход одной цепи со входом другой, передача мощности через соединение максимальна, если выходной и входной импедансы этих двух цепей совпадают. Несогласованность импедансов может привести к отражению сигнала и другим проблемам.

Object-relational mapping (ORM)

Фреймворки объектно-реляционного отображения (ORM), такие как ActiveRecord и Hibernate, уменьшают количество шаблонного кода, необходимого для этого слоя трансляции, но они часто подвергаются критике. Наиболее часто упоминаемые проблемы:

  • ORM сложны и не могут полностью скрыть различия между двумя моделями, поэтому разработчикам всё равно приходится думать и о реляционном, и об объектном представлении данных.
  • ORM, как правило, используются только для разработки OLTP-приложений; инженерам данных, подготавливающим данные для аналитики, всё равно приходится работать с базовым реляционным представлением, поэтому дизайн реляционной схемы остаётся важным даже при использовании ORM.
  • Многие ORM работают только с реляционными OLTP-базами. Организации с разнообразными системами данных, такими как поисковые движки, графовые базы данных и системы NoSQL, могут столкнуться с нехваткой поддержки ORM.
  • Некоторые ORM автоматически генерируют реляционные схемы, но они могут оказаться неудобными для пользователей, которые работают с реляционными данными напрямую, и неэффективными для базовой базы данных. Кастомизация схемы и генерации запросов ORM может быть сложной и свести на нет выгоду от использования ORM.
  • ORM упрощают написание неэффективных запросов, например, проблему N+1-запроса. Допустим, вы хотите отобразить список комментариев пользователей на странице, поэтому выполняете один запрос, который возвращает N комментариев, каждый из которых содержит ID его автора. Чтобы показать имя автора комментария, нужно найти этот ID в таблице пользователей. В написанном вручную SQL вы бы, скорее всего, выполнили join в запросе и вернули имя автора вместе с каждым комментарием, но с ORM вы можете в итоге сделать отдельный запрос к таблице пользователей для каждого из N комментариев, чтобы найти его автора, в результате получится N+1 запрос к базе данных, что медленнее, чем выполнение join в самой базе. Чтобы избежать этой проблемы, вам, возможно, придётся указать ORM забирать информацию об авторе одновременно с выборкой комментариев.

Тем не менее, у ORM также есть преимущества:

  • Для данных, которые хорошо подходят для реляционной модели, какой-то вид трансляции между постоянным реляционным и объектным представлением в памяти неизбежен, и ORM уменьшают количество шаблонного кода, необходимого для этой трансляции. Сложные запросы, возможно, всё равно придётся обрабатывать вне ORM, но ORM могут помочь в простых и повторяющихся случаях.
  • Некоторые ORM помогают с кэшированием результатов запросов к базе данных, что может снизить нагрузку на базу данных.
  • ORM также могут помочь в управлении миграциями схемы и других административных задачах.

Документная модель данных для связей «один-ко-многим»

Не все данные хорошо поддаются реляционному представлению; рассмотрим пример, чтобы исследовать ограничение реляционной модели. На рисунке 3-1 показано, как резюме (профиль LinkedIn) может быть выражено в реляционной схеме. Весь профиль может быть идентифицирован уникальным идентификатором user_id. Поля, такие как first_name и last_name, встречаются ровно один раз на пользователя, поэтому они могут быть смоделированы как столбцы в таблице users.

У большинства людей было больше одной работы за карьеру (positions), также у людей может быть разное количество периодов обучения и любое количество контактной информации. Один из способов представления таких связей «один-ко-многим» — поместить позиции, образование и контактную информацию в отдельные таблицы со ссылкой внешнего ключа на таблицу users, как на рисунке 3-1.

Рисунок 3-1. Представление профиля LinkedIn с использованием реляционной схемы

Другой способ представления той же информации, который, возможно, выглядит более естественным и ближе к объектной структуре в коде приложения, — это JSON-документ, показанный в примере 3-1.

Пример 3-1. Представление профиля LinkedIn как JSON-документа

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

  • Отсутствие схемы часто называют преимуществом; мы обсудим это в разделе «Гибкость схемы в документной модели».
  • Представление JSON обладает лучшей локальностью, чем многотабличная схема на рисунке 3-1 (см. «Локальность данных для чтения и записи»).
  • Если вы хотите получить профиль в реляционном примере, вам нужно либо выполнить несколько запросов (запрашивая каждую таблицу по user_id), либо выполнить громоздкий многотабличный join между таблицей users и её подчинёнными таблицами. В JSON-представлении вся релевантная информация находится в одном месте, что делает запрос одновременно быстрее и проще.

Связи «один-ко-многим» из профиля пользователя к позициям, истории образования и контактной информации подразумевают древовидную структуру данных, и представление JSON делает эту структуру дерева явной (см. рисунок 3-2).

Рисунок 3-2. Связи «один-ко-многим», формирующие древовидную структуру

Примечание

Такой тип связи иногда называют «один-к-немногим» (one-to-few), а не «один-ко-многим» (one-to-many), поскольку резюме обычно содержит небольшое количество позиций. В ситуациях, когда может быть действительно большое количество связанных элементов — например, комментарии к посту знаменитости в соцсети, которых могут быть тысячи, — встраивание их всех в один документ может оказаться слишком громоздким, поэтому предпочтительнее реляционный подход, показанный на рисунке 3-1.

Нормализация, денормализация и join’ы

В примере 3-1 в предыдущем разделе region_id приведён в виде ID, а не в виде текстовой строки «Washington, DC, United States». Почему?

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

  • Единообразие стиля и написания в разных профилях
  • Избежание неоднозначности, если существует несколько мест с одинаковым названием (если строка была бы просто «Washington», то шла бы речь о DC или о штате?)
  • Простота обновления — имя хранится только в одном месте, поэтому его легко обновить везде, если это когда-либо потребуется (например, смена названия города по политическим причинам)
  • Поддержка локализации — при переводе сайта на другие языки стандартизованные списки могут быть локализованы, так что регион можно отобразить на языке пользователя
  • Более качественный поиск — например, поиск людей на Восточном побережье США может сопоставить этот профиль, так как список регионов может закодировать факт, что Вашингтон находится на Восточном побережье (что не очевидно из строки «Washington, DC»)

Храните ли вы ID или текстовую строку — это вопрос нормализации. Когда вы используете ID, ваши данные более нормализованы: информация, имеющая значение для человека (например, текст Washington, DC), хранится только в одном месте, и всё, что на неё ссылается, использует ID (который имеет смысл только внутри базы данных). Когда вы храните текст напрямую, вы дублируете человеко-понятную информацию в каждой записи, которая её использует; такое представление является денормализованным.

Преимущество использования ID в том, что, поскольку он не имеет смысла для человека, его никогда не нужно менять: ID может оставаться тем же самым, даже если изменяется информация, которую он идентифицирует. Всё, что имеет значение для человека, может потребовать изменения в будущем — и если эта информация продублирована, все избыточные копии придётся обновлять. Это требует большего объёма кода, большего числа операций записи, больше места на диске и несёт риск несогласованностей (когда часть копий информации обновлена, а часть — нет).

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

В реляционной модели данных это делается с помощью join, например:

Документные базы данных могут хранить как нормализованные, так и денормализованные данные, но их часто связывают с денормализацией — отчасти потому, что модель данных JSON упрощает хранение дополнительных денормализованных полей, а отчасти потому, что слабая поддержка join’ов во многих документных базах данных делает нормализацию неудобной.

Некоторые документные базы данных вообще не поддерживают join’ы, поэтому их приходится выполнять в коде приложения — то есть сначала вы получаете документ, содержащий ID, а затем выполняете второй запрос, чтобы разрешить этот ID в другой документ. В MongoDB также можно выполнить join с использованием оператора $lookup в конвейере агрегации:

Компромиссы нормализации

В примере с резюме, хотя поле region_id является ссылкой на стандартизованный набор регионов, имя организации (компании или госучреждения, где работал человек) и school_name (где он учился) — это просто строки. Такое представление является денормализованным: многие люди могли работать в одной и той же компании, но нет ID, который бы связывал их.

Возможно, организация и школа тоже должны быть сущностями, а профиль должен ссылаться на их ID вместо имён? Те же аргументы, которые применимы к ссылке на ID региона, применимы и здесь. Например, предположим, что мы хотим включить логотип школы или компании в дополнение к их названию:

  • В денормализованном представлении мы бы включали URL изображения логотипа в профиль каждого человека; это делает JSON-документ самодостаточным, но создаёт проблему, если когда-либо потребуется изменить логотип, так как нам придётся найти все вхождения старого URL и обновить их.
  • В нормализованном представлении мы бы создали сущность, представляющую организацию или школу, и хранили её имя, URL логотипа и, возможно, другие атрибуты (описание, новостную ленту и т. д.) один раз в этой сущности. Каждое резюме, упоминающее организацию, просто ссылалось бы на её ID, и обновить логотип было бы легко.

В общем случае нормализованные данные обычно быстрее записывать (так как существует только одна копия), но медленнее читать (так как требуются join’ы); денормализованные данные, как правило, быстрее читать (меньше join’ов), но дороже записывать (больше копий для обновления, больше используемого дискового пространства). Вам может быть полезно рассматривать денормализацию как форму производных данных («Системы учёта и производные данные»), поскольку необходимо настроить процесс обновления избыточных копий данных.

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

Нормализация, как правило, лучше подходит для OLTP-систем, где и чтение, и обновления должны быть быстрыми; аналитические системы часто лучше работают с денормализованными данными, так как они выполняют обновления пакетами, а производительность запросов только для чтения является доминирующей задачей. Более того, в системах малого и среднего масштаба нормализованная модель данных часто является лучшей, потому что не нужно беспокоиться о поддержании нескольких копий данных в согласованности друг с другом, а стоимость выполнения join’ов приемлема. Однако в системах очень большого масштаба стоимость join’ов может стать проблемной.

Денормализация в примере с социальной сетью

В разделе «Кейс: домашние таймлайны социальной сети» мы сравнивали нормализованное представление (рисунок 2-1) и денормализованное (предвычисленные, материализованные таймлайны): здесь join между постами и подписками оказался слишком дорогим, и материализованный таймлайн является кэшем результата этого join’а. Процесс fan-out, вставляющий новый пост в таймлайны подписчиков, был нашим способом поддерживать денормализованное представление в согласованности.

Однако реализация материализованных таймлайнов в X (ранее Twitter) не хранит фактический текст каждого поста: каждая запись фактически хранит только ID поста, ID пользователя, который его опубликовал, и немного дополнительной информации для идентификации репостов и ответов.

Другими словами, это предвычисленный результат (приблизительно) следующего запроса:

Это означает, что каждый раз при чтении таймлайна сервису всё равно нужно выполнять два join’а: искать ID поста, чтобы получить фактическое содержимое поста (а также статистику, например количество лайков и ответов), и искать профиль отправителя по ID (чтобы получить его имя пользователя, аватар и другие детали). Этот процесс поиска человеко-читаемой информации по ID называется гидратацией ID (hydrating the IDs), и это по сути join, выполняемый в коде приложения.

Причина хранения только ID в предвычисленном таймлайне заключается в том, что данные, на которые они ссылаются, быстро меняются: количество лайков и ответов может изменяться несколько раз в секунду у популярного поста, и некоторые пользователи регулярно меняют своё имя или фото профиля. Так как таймлайн должен показывать актуальное количество лайков и аватар в момент просмотра, не имело бы смысла денормализовать эту информацию в материализованный таймлайн. Более того, стоимость хранения значительно возросла бы при такой денормализации.

Этот пример показывает, что необходимость выполнения join’ов при чтении данных не является, как иногда утверждается, препятствием для создания высокопроизводительных, масштабируемых сервисов. Гидратация ID поста и ID пользователя на самом деле является достаточно лёгкой операцией для масштабирования, так как она хорошо параллелится, и её стоимость не зависит от количества аккаунтов, на которые вы подписаны, или количества ваших подписчиков.

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

Связи «многие-к-одному» и «многие-ко-многим»

Хотя позиции и образование на рисунке 3-1 являются примерами связей «один-ко-многим» или «один-к-немногим» (одно резюме имеет несколько позиций, но каждая позиция принадлежит только одному резюме), поле region_id является примером связи «многие-к-одному» (многие люди живут в одном регионе, но предполагается, что каждый человек живёт только в одном регионе в любой момент времени).

Если мы введём сущности для организаций и школ и будем ссылаться на них по ID из резюме, то у нас также появятся связи «многие-ко-многим» (один человек работал в нескольких организациях, и у организации есть несколько прошлых или текущих сотрудников). В реляционной модели такая связь обычно представляется как ассоциативная таблица или join-таблица, как показано на рисунке 3-3: каждая позиция связывает один ID пользователя с одним ID организации.

Рисунок 3-3. Связи многие-ко-многим в реляционной модели

Связи многие-к-одному и многие-ко-многим не так просто уместить в один автономный JSON-документ; они больше подходят для нормализованного представления. В документной модели возможное представление показано в примере 3-2 и проиллюстрировано на рисунке 3-4: данные внутри каждого пунктирного прямоугольника можно сгруппировать в один документ, но связи с организациями и учебными заведениями лучше всего представлять как ссылки на другие документы.

Пример 3-2. Резюме, которое ссылается на организации по ID.

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

Связи многие-ко-многим часто нужно запрашивать «в обоих направлениях»: например, найти все организации, в которых работал конкретный человек, и найти всех людей, которые работали в конкретной организации. Один из способов обеспечить такие запросы — хранить ссылки на ID с обеих сторон, то есть резюме включает ID каждой организации, где работал человек, а документ организации включает ID резюме, которые её упоминают. Такое представление денормализовано, поскольку связь хранится в двух местах, что может привести к несоответствиям.

Нормализованное представление хранит связь только в одном месте и полагается на вторичные индексы, чтобы обеспечить эффективные запросы в обоих направлениях. В реляционной схеме на рисунке 3-3 мы сказали бы базе данных создать индексы по столбцам user_id и org_id таблицы positions.

В документной модели из примера 3-2 базе данных нужно индексировать поле org_id внутри объектов массива positions. Многие документные базы данных и реляционные СУБД с поддержкой JSON умеют создавать такие индексы по значениям внутри документа.

Звёзды и снежинки: схемы для аналитики

Хранилища данных (см. «Хранилище данных») обычно реляционные, и есть несколько широко используемых соглашений для структуры таблиц в хранилище данных: звёздная схема, снежинка, dimensional modeling (измерительное моделирование) и одна большая таблица (OBT). Эти структуры оптимизированы под нужды бизнес-аналитиков. ETL-процессы преобразуют данные из операционных систем в эту схему.

Рисунок 3-5 показывает пример звёздной схемы, которая может использоваться в хранилище данных продуктового ритейлера.

В центре схемы находится так называемая факт-таблица (в этом примере она называется fact_sales). Каждая строка факт-таблицы представляет событие, произошедшее в определённое время (здесь каждая строка — это покупка продукта клиентом). Если бы мы анализировали веб-трафик, а не розничные продажи, каждая строка могла бы представлять просмотр страницы или клик пользователя.

Звёздная или снежинка-схема в основном состоит из отношений «многие-к-одному» (например, многие продажи происходят для одного конкретного продукта, в одном конкретном магазине), представленных как факт-таблица с внешними ключами на таблицы измерений или измерения на подизмерения. В принципе, могут существовать и другие типы отношений, но их часто денормализуют, чтобы упростить запросы. Например, если покупатель приобретает несколько разных продуктов за раз, эта многопозиционная транзакция явно не представляется; вместо этого в факт-таблице есть отдельная строка для каждого купленного продукта, и все эти факты просто имеют одинаковый ID клиента, ID магазина и временную метку.

Некоторые схемы хранилищ данных идут ещё дальше в денормализации и полностью исключают таблицы измерений, сворачивая информацию измерений в денормализованные столбцы самой факт-таблицы (по сути, предвычисляя соединение между факт-таблицей и таблицами измерений). Такой подход называется «одна большая таблица» (one big table, OBT), и хотя он требует больше места для хранения, иногда он позволяет выполнять запросы быстрее.

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

Когда использовать какую модель

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

Если данные в вашем приложении имеют документоподобную структуру (т.е. дерево отношений «один-ко-многим», где обычно всё дерево загружается целиком), тогда, вероятно, разумно использовать документную модель. Реляционная техника «измельчения» — разбиение документоподобной структуры на несколько таблиц (как positions, education и contact_info на Рисунке 3-1) — может приводить к громоздким схемам и излишне усложнённому коду приложения.

Документная модель имеет ограничения: например, вы не можете напрямую ссылаться на вложенный элемент внутри документа, а вместо этого должны говорить что-то вроде «второй элемент в списке должностей для пользователя 251». Если нужно ссылаться на вложенные элементы, лучше работает реляционный подход, так как можно напрямую обращаться к любому элементу по его ID.

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

Гибкость схемы в документной модели

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

Документные базы иногда называют «безсхемными», но это вводит в заблуждение, так как код, читающий данные, обычно предполагает некоторую структуру — т.е. существует неявная схема, но она не навязывается базой. Более точный термин — schema-on-read (схема при чтении: структура данных неявная и интерпретируется только при чтении), в отличие от schema-on-write (схема при записи: традиционный подход реляционных баз, где схема явная, и база гарантирует, что все данные соответствуют ей при записи).

Schema-on-read похож на динамическую (в runtime) проверку типов в языках программирования, тогда как schema-on-write похож на статическую (на этапе компиляции) проверку типов. Как сторонники статической и динамической проверки типов спорят об их относительных достоинствах, так и валидация схем в базах данных является спорной темой, и в целом здесь нет правильного или неправильного ответа.

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

Недостаток такого подхода в том, что каждая часть вашего приложения, читающая из базы, теперь должна уметь работать с документами старых форматов, которые могли быть записаны очень давно. С другой стороны, в базе с schema-on-write вы бы обычно выполнили миграцию примерно такого вида:

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

Существуют различные инструменты, позволяющие выполнять такого рода изменения схемы в фоновом режиме без простоя, но проведение таких миграций на больших базах остаётся операционно сложной задачей. Сложных миграций можно избежать, добавив только столбец first_name со значением NULL по умолчанию (что быстро), а заполнять его при чтении, как вы бы делали в документной базе.

Schema-on-read подход выгоден, если элементы в коллекции по каким-то причинам имеют разную структуру (т.е. данные гетерогенны) — например:

  • существует множество разных типов объектов, и нецелесообразно помещать каждый тип объекта в отдельную таблицу;
  • структура данных определяется внешними системами, над которыми у вас нет контроля, и которые могут изменяться в любой момент.

В таких случаях схема может скорее мешать, чем помогать, и «безсхемные» документы могут быть гораздо более естественной моделью данных. Но в случаях, когда ожидается, что все записи будут иметь одинаковую структуру, схемы являются полезным механизмом документирования и обеспечения этой структуры. Мы подробнее рассмотрим схемы и их эволюцию в Главе 5.

Локальность данных при чтении и записи

Документ обычно хранится как одна непрерывная строка, закодированная в JSON, XML или двоичном варианте этих форматов (например, BSON в MongoDB). Если вашему приложению часто требуется доступ ко всему документу (например, для отображения его на веб-странице), то в такой локальности хранения есть преимущество в производительности. Если данные разделены между несколькими таблицами, как на рисунке 3-1, для их извлечения требуется несколько обращений к индексам, что может потребовать больше обращений к диску и занять больше времени.

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

Однако идея хранения связанных данных вместе ради локальности не ограничивается документной моделью. Например, база данных Spanner от Google предоставляет те же свойства локальности в реляционной модели данных, позволяя схеме указывать, что строки таблицы должны быть вложены (вложенные) внутри родительской таблицы. Oracle позволяет то же самое, используя функцию под названием multi-table index cluster tables. Модель широких столбцов, популяризованная Google Bigtable и используемая, например, в HBase и Accumulo, имеет концепцию семейств столбцов, которые служат той же цели управления локальностью.

Языки запросов для документов

Ещё одно различие между реляционной и документной базой данных — это язык или API, используемый для их запросов. Большинство реляционных баз данных запрашиваются с помощью SQL, но документные базы данных более разнообразны. Некоторые позволяют только доступ «ключ-значение» по первичному ключу, другие также предлагают вторичные индексы для поиска значений внутри документов, а некоторые предоставляют богатые языки запросов.

XML-базы данных часто запрашиваются с помощью XQuery и XPath, которые позволяют выполнять сложные запросы, включая соединения между несколькими документами, а также форматировать результаты как XML. JSON Pointer и JSONPath предоставляют аналог XPath для JSON. Конвейер агрегации MongoDB, оператор $lookup которого для соединений мы видели в разделе «Нормализация, денормализация и соединения», является примером языка запросов к коллекциям JSON-документов.

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

Функция date_trunc('month', timestamp) определяет календарный месяц, содержащий метку времени, и возвращает другую метку времени, представляющую начало этого месяца. Другими словами, она округляет метку времени вниз до ближайшего месяца.

Этот запрос сначала фильтрует наблюдения так, чтобы показывались только виды из семейства акул (Sharks), затем группирует наблюдения по календарному месяцу, в котором они произошли, и, наконец, суммирует количество животных, увиденных во всех наблюдениях за этот месяц.

Тот же запрос можно выразить с помощью конвейера агрегации MongoDB следующим образом:

Язык конвейера агрегации по выразительности похож на подмножество SQL, но использует JSON-подобный синтаксис вместо синтаксиса в стиле английских предложений; различие, пожалуй, вопрос вкуса.

Сближение документных и реляционных баз данных

Документные базы данных и реляционные базы данных начинали как очень разные подходы к управлению данными, но со временем они стали всё более похожими. Реляционные базы данных добавили поддержку типов JSON и операторов запросов, а также возможность индексировать свойства внутри документов. Некоторые документные базы данных (такие как MongoDB, Couchbase и RethinkDB) добавили поддержку соединений, вторичных индексов и декларативных языков запросов.

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

ПРИМЕЧАНИЕ

Оригинальное описание реляционной модели Коддом фактически допускало нечто подобное JSON внутри реляционной схемы. Он называл это несложные домены (nonsimple domains). Идея заключалась в том, что значение в строке не обязательно должно быть примитивным типом данных, таким как число или строка, но оно также может быть вложенным отношением (таблицей) — то есть вы можете иметь произвольно вложенную древовидную структуру в качестве значения, очень похожую на поддержку JSON или XML, добавленную в SQL более чем через 30 лет.

Графоподобные модели данных

Мы уже видели, что тип связей является важной отличительной особенностью между различными моделями данных. Если в вашем приложении в основном встречаются связи типа «один-ко-многим» (древовидные данные) и мало других связей между записями, то модель документа подходит.

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

Граф состоит из двух видов объектов: вершины (также называемые узлами или сущностями) и рёбра (также называемые связями или дугами). Многие виды данных можно смоделировать как граф.

Типичные примеры:

  • Социальные графы
    Вершины — это люди, а рёбра показывают, кто с кем знаком.
  • Граф интернета
    Вершины — это веб-страницы, а рёбра показывают HTML-ссылки на другие страницы.
  • Дорожные или железнодорожные сети
    Вершины — это перекрёстки, а рёбра представляют дороги или железнодорожные линии между ними.

Существуют хорошо известные алгоритмы, которые могут работать с такими графами: например, приложения навигации ищут кратчайший путь между двумя точками в дорожной сети, а PageRank может использоваться для веб-графа, чтобы определить популярность веб-страницы и, соответственно, её рейтинг в результатах поиска.

Графы можно представлять несколькими различными способами. В модели списков смежности каждая вершина хранит идентификаторы соседних вершин, находящихся на расстоянии одного ребра. В качестве альтернативы можно использовать матрицу смежности — двумерный массив, где каждая строка и каждый столбец соответствуют вершине, значение равно нулю, если между вершинами строки и столбца нет ребра, и единице, если есть. Список смежности хорошо подходит для обхода графа, а матрица — для задач машинного обучения (см. раздел «Dataframes, Matrices, and Arrays»).

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

Facebook поддерживает единый граф с множеством различных типов вершин и рёбер: вершины представляют людей, места, события, отметки посещений и комментарии пользователей; рёбра показывают, кто с кем дружит, какая отметка посещения относится к какому месту, кто прокомментировал какой пост, кто посетил какое событие и т. д.

Графы знаний используются поисковыми системами для записи фактов о сущностях, часто встречающихся в поисковых запросах, таких как организации, люди и места. Эта информация получается путём обхода и анализа текста на веб-сайтах; некоторые сайты, такие как Wikidata, также публикуют графовые данные в структурированной форме.

Существует несколько разных, но связанных способов структурирования и запросов данных в графах. В этом разделе мы рассмотрим модель property graph (реализованную в Neo4j, Memgraph, KùzuDB и других) и модель triple-store (реализованную в Datomic, AllegroGraph, Blazegraph и других). Эти модели достаточно похожи по возможностям выражения, и некоторые графовые базы данных (например, Amazon Neptune) поддерживают обе модели.

Мы также рассмотрим четыре языка запросов для графов (Cypher, SPARQL, Datalog и GraphQL), а также поддержку SQL для запросов к графам. Существуют и другие языки запросов к графам, например Gremlin, но выбранные дадут нам репрезентативный обзор.

Чтобы проиллюстрировать эти разные языки и модели, в этом разделе используется граф, показанный на рисунке 3-6, в качестве сквозного примера. Его можно было бы взять из социальной сети или генеалогической базы: он показывает двух людей — Люси из Айдахо и Алена из Сент-Ло, Франция. Они женаты и живут в Лондоне. Каждый человек и каждое место представлены вершинами, а связи между ними — рёбрами. Этот пример поможет продемонстрировать некоторые запросы, которые легко выполнять в графовых базах данных, но трудно — в других моделях.

Рисунок 3-6. Пример данных графовой структуры (прямоугольники представляют вершины, стрелки представляют рёбра)

Property Graphs

В модели property graph (также известной как labeled property graph) каждая вершина состоит из:

  • Уникального идентификатора
  • Метки (строка), описывающей, какой тип объекта представляет эта вершина
  • Набора исходящих рёбер
  • Набора входящих рёбер
  • Коллекции свойств (пары ключ-значение)

Каждое ребро состоит из:

  • Уникального идентификатора
  • Вершины, из которой ребро начинается (хвостовая вершина)
  • Вершины, в которой ребро заканчивается (головная вершина)
  • Метки, описывающей тип связи между двумя вершинами
  • Коллекции свойств (пары ключ-значение)

Графовое хранилище можно представить как две реляционные таблицы: одну для вершин и одну для рёбер, как показано в примере 3-3 (эта схема использует тип данных jsonb PostgreSQL для хранения свойств каждой вершины или ребра). Для каждого ребра хранится головная и хвостовая вершина; если вы хотите получить набор входящих или исходящих рёбер для вершины, вы можете запросить таблицу рёбер по head_vertex или tail_vertex соответственно.

Пример 3-3. Представление property graph с использованием реляционной схемы

Некоторые важные аспекты этой модели:

  • Любая вершина может иметь ребро, соединяющее её с любой другой вершиной. Нет схемы, которая ограничивает, какие типы объектов можно или нельзя связывать.
  • Для любой вершины можно эффективно найти как её входящие, так и исходящие рёбра, и таким образом обходить граф — то есть следовать по пути через цепочку вершин — как вперёд, так и назад. (Именно поэтому в Примере 3-3 есть индексы как по столбцам tail_vertex, так и по head_vertex.)
  • Используя разные метки для разных типов вершин и связей, вы можете хранить несколько различных видов информации в одном графе, сохраняя при этом чистую модель данных.
  • Таблица рёбер похожа на таблицу связей «многие-ко-многим»/join-таблицу, которую мы видели в разделе «Связи многие-к-одному и многие-ко-многим», но обобщённую так, чтобы можно было хранить много разных типов связей в одной таблице. Также могут существовать индексы по меткам и свойствам, что позволяет эффективно находить вершины или рёбра с определёнными свойствами.

ПРИМЕЧАНИЕ

Ограничение графовых моделей заключается в том, что ребро может связывать только две вершины друг с другом, тогда как реляционная join-таблица может представлять трёхсторонние или даже более сложные связи, имея несколько внешних ключей в одной строке. Такие связи могут быть представлены в графе путём создания дополнительной вершины, соответствующей каждой строке join-таблицы, и рёбер к/от этой вершины, или с помощью гиперграфа.

Эти особенности дают графам большую гибкость при моделировании данных, как показано на Рисунке 3-6. На рисунке изображены несколько вещей, которые было бы сложно выразить в традиционной реляционной схеме, например разные виды региональных структур в разных странах (во Франции есть департаменты и регионы, а в США — округа и штаты), особенности истории вроде страны внутри страны (пока что игнорируя тонкости суверенных государств и наций), а также различная детализация данных (текущее место жительства Люси указано на уровне города, а место рождения — только на уровне штата).

Можно представить, что граф расширяется и включает многие другие факты о Люси и Алене или других людях. Например, его можно использовать для обозначения пищевых аллергий (добавив вершину для каждого аллергена и ребро между человеком и аллергеном, указывающее на аллергию), а также связать аллергены с набором вершин, показывающих, какие продукты содержат какие вещества. Затем можно написать запрос, чтобы выяснить, что безопасно есть каждому человеку. Графы хороши для эволюционности: по мере того как вы добавляете функции в приложение, граф легко расширяется, чтобы учесть изменения в структурах данных приложения.

Язык запросов Cypher

Cypher — это язык запросов для графов свойств, изначально созданный для базы данных Neo4j, а затем развитый в открытый стандарт openCypher. Помимо Neo4j, Cypher поддерживают Memgraph, KuzuDB, Amazon Neptune, Apache AGE (с хранением в PostgreSQL) и другие. Его название происходит от персонажа фильма Матрица и не связано с шифрами в криптографии.

Пример 3-4 показывает запрос Cypher для вставки левой части рисунка 3-6 в графовую базу данных. Остальная часть графа может быть добавлена аналогично. Каждой вершине даётся символическое имя, например usa или idaho. Это имя не сохраняется в базе данных, а используется только внутри запроса для создания рёбер между вершинами с помощью стрелочной нотации:

создаёт ребро с меткой WITHIN, где idaho является начальной вершиной, а usa — конечной.

Пример 3-4. Подмножество данных с Рисунка 3-6, представленное в виде запроса Cypher

Когда все вершины и рёбра с Рисунка 3-6 добавлены в базу данных, можно начинать задавать интересные вопросы: например, найти имена всех людей, которые эмигрировали из США в Европу. То есть найти все вершины, которые имеют ребро BORN_IN к локации в пределах США и одновременно ребро LIVING_IN к локации в пределах Европы, и вернуть свойство name каждой из этих вершин.

Пример 3-5 показывает, как выразить этот запрос на языке Cypher. Та же стрелочная нотация используется в предложении MATCH для поиска шаблонов в графе:

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

Пример 3-5. Запрос Cypher для поиска людей, эмигрировавших из США в Европу

Запрос можно читать так:
Найти любую вершину (person), которая удовлетворяет обоим условиям:

  • У person есть исходящее ребро BORN_IN к некоторой вершине. Из этой вершины можно следовать по цепочке исходящих рёбер WITHIN, пока в итоге не достигнется вершина типа Location, у которой свойство name равно «United States».
  • У той же вершины person также есть исходящее ребро LIVES_IN. Следуя по этому ребру и затем по цепочке исходящих рёбер WITHIN, в итоге достигается вершина типа Location, у которой свойство name равно «Europe».
  • Для каждой такой вершины person вернуть свойство name.

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

Но равноценно можно начать с двух вершин Location и двигаться назад. Если есть индекс по свойству name, можно эффективно найти вершины, представляющие США и Европу. Затем можно найти все локации (штаты, регионы, города и т.д.) в США и Европе, следуя по всем входящим рёбрам WITHIN. Наконец, можно найти людей, которые соединены входящим ребром BORN_IN или LIVES_IN с одной из этих вершин-локаций.

Запросы к графам в SQL

Пример 3-3 предполагал, что графовые данные могут быть представлены в реляционной базе данных. Но если мы поместим графовые данные в реляционную структуру, можем ли мы также выполнять к ним запросы с помощью SQL?

Ответ — да, но с определёнными сложностями. Каждое ребро, которое вы проходите в графовом запросе, по сути является join’ом с таблицей рёбер. В реляционной базе данных вы обычно заранее знаете, какие именно join’ы нужны в вашем запросе. С другой стороны, в графовом запросе вам может понадобиться пройти переменное количество рёбер, прежде чем вы найдёте вершину, которую ищете, — то есть количество join’ов не фиксировано заранее.

В нашем примере это происходит в шаблоне () -[:WITHIN*0..]-> () в Cypher-запросе. Ребро LIVES_IN у человека может указывать на любой тип локации: улицу, город, район, регион, штат и т.д. Город может находиться WITHIN региона, регион WITHIN штата, штат WITHIN страны и так далее. Ребро LIVES_IN может указывать непосредственно на вершину-локацию, которую вы ищете, либо находиться на несколько уровней выше в иерархии локаций.

В Cypher конструкция :WITHIN*0.. выражает эту идею очень лаконично: она означает «следуй по ребру WITHIN ноль или более раз». Это похоже на оператор * в регулярных выражениях.

Начиная с SQL:1999, эту идею переменной длины путей обхода в запросе можно выразить с помощью так называемых рекурсивных общих табличных выражений (синтаксис WITH RECURSIVE). В примере 3-6 показан тот же запрос — поиск имён людей, которые эмигрировали из США в Европу — записанный на SQL с использованием этой техники. Однако синтаксис получается очень громоздким по сравнению с Cypher.

Пример 3-6. Тот же запрос, что и в примере 3-5, но записанный на SQL с использованием рекурсивных общих табличных выражений

  1. Сначала найдите вершину, у которой свойство name имеет значение «United States», и сделайте её первым элементом множества вершин in_usa.
  2. Следуйте по всем входящим рёбрам within от вершин из множества in_usa и добавляйте их в то же множество до тех пор, пока все входящие рёбра within не будут посещены.
  3. Сделайте то же самое, начиная с вершины, у которой свойство name имеет значение «Europe», и постройте множество вершин in_europe.
  4. Для каждой вершины из множества in_usa следуйте по входящим рёбрам born_in, чтобы найти людей, которые родились в каком-либо месте в пределах United States.
  5. Аналогично, для каждой вершины из множества in_europe следуйте по входящим рёбрам lives_in, чтобы найти людей, которые живут в Europe.
  6. Наконец, пересеките множество людей, рождённых в USA, с множеством людей, живущих в Europe, выполнив их join.

Тот факт, что 4 строки Cypher-запроса требуют 31 строку в SQL, показывает, насколько выбор правильной модели данных и языка запросов может иметь значение. И это только начало; есть ещё больше деталей, которые необходимо учитывать, например, работа с циклами и выбор между обходом в ширину или в глубину. Oracle использует другое SQL-расширение для рекурсивных запросов, которое оно называет hierarchical. Однако ситуация может улучшиться: на момент написания планируется добавить язык запросов к графам под названием GQL в стандарт SQL, который обеспечит синтаксис, вдохновлённый Cypher, GSQL и PGQL.

Triple-Stores и SPARQL

Модель triple-store в основном эквивалентна модели property graph, используя разные слова для описания одних и тех же идей. Тем не менее стоит её обсудить, так как существует множество инструментов и языков для triple-store, которые могут стать ценным дополнением к вашему инструментарию для построения приложений.

В triple-store вся информация хранится в виде очень простых трёхчастных утверждений: (subject, predicate, object). Например, в триплете (Jim, likes, bananas) Jim является subject, likes — predicate (глагол), а bananas — object.

Subject триплета эквивалентен вершине в графе. Object может быть одним из двух:
Значением примитивного типа данных, например строкой или числом. В этом случае predicate и object триплета эквивалентны ключу и значению свойства вершины subject. Используя пример с рисунка 3-6, (lucy, birthYear, 1989) — это как вершина lucy со свойствами {«birthYear»: 1989}.
Другой вершиной в графе. В этом случае predicate — это ребро в графе, subject — это вершина-источник, а object — вершина-назначение. Например, в (lucy, marriedTo, alain) и subject, и object (lucy и alain) являются вершинами, а predicate marriedTo — это метка ребра, соединяющего их.

ПРИМЕЧАНИЕ

Чтобы быть точным, базы данных, предлагающие triple-подобную модель данных, часто должны хранить дополнительные метаданные для каждой кортежа. Например, AWS Neptune использует quads (4-элем. кортежи), добавляя graph ID к каждому триплету; Datomic использует 5-элем. кортежи, расширяя каждый триплет идентификатором транзакции и булевым значением, указывающим на удаление. Так как эти базы данных сохраняют базовую структуру subject-predicate-object, описанную выше, в этой книге они тем не менее называются triple-stores.

В примере 3-7 показаны те же данные, что и в примере 3-4, записанные в виде триплетов в формате, называемом Turtle, подмножестве Notation3 (N3).

Пример 3-7. Подмножество данных с рисунка 3-6, представленное в виде Turtle-триплетов

В этом примере вершины графа записаны как _:someName. Имя не имеет никакого значения за пределами этого файла; оно существует только потому, что иначе мы не знали бы, какие триплеты ссылаются на одну и ту же вершину. Когда предикат представляет ребро, объект является вершиной, как в _:idaho :within _:usa. Когда предикат является свойством, объект — строковый литерал, как в _:usa :name «United States».

Довольно утомительно повторять один и тот же subject снова и снова, но, к счастью, можно использовать точки с запятой, чтобы сказать несколько вещей об одном и том же subject. Это делает формат Turtle довольно удобочитаемым: см. пример 3-8.

Пример 3-8. Более компактный способ записи данных из примера 3-7

СЕМАНТИЧЕСКАЯ СЕТЬ

Некоторая часть исследовательских и разработческих усилий вокруг triple-store была мотивирована Семантической Сетью — инициативой начала 2000-х, целью которой было упростить обмен данными в масштабах интернета путём публикации данных не только в виде веб-страниц, читаемых человеком, но и в стандартизированном, машиночитаемом формате. Хотя Семантическая Сеть в её изначально задуманном виде не состоялась, наследие проекта Semantic Web живёт в ряде конкретных технологий: стандартах связанных данных, таких как JSON-LD, онтологиях, используемых в биомедицинской науке, протоколе Open Graph от Facebook (который используется для link unfurling), графах знаний, таких как Wikidata, и стандартизированных словарях для структурированных данных, поддерживаемых schema.org.

Triple-store — это ещё одна технология Semantic Web, которая нашла применение за пределами своего первоначального сценария: даже если у вас нет интереса к Semantic Web, триплеты могут быть хорошей внутренней моделью данных для приложений.

Модель данных RDF

Язык Turtle, который мы использовали в примере 3-8, на самом деле является способом кодирования данных в Resource Description Framework (RDF), модели данных, которая была разработана для Semantic Web. RDF-данные также могут быть закодированы другими способами, например (более многословно) в XML, как показано в примере 3-9. Инструменты вроде Apache Jena могут автоматически конвертировать между различными RDF-кодировками.

Пример 3-9. Данные из примера 3-8, представленные с использованием синтаксиса RDF/XML

У RDF есть несколько особенностей из-за того, что он был разработан для обмена данными в масштабе интернета. Subject, predicate и object триплета часто являются URI. Например, предикат может быть URI, таким как http://my-company.com/namespace#within или http://my-company.com/namespace#lives_in, а не просто WITHIN или LIVES_IN. Логика этого дизайна заключается в том, что вы должны иметь возможность комбинировать свои данные с чьими-то ещё, и если они придают другое значение слову within или lives_in, то конфликта не будет, так как их предикаты на самом деле http://other.org/foo#within и http://other.org/foo#lives_in.

URL http://my-company.com/namespace не обязательно должен что-либо разрешать — с точки зрения RDF это просто namespace. Чтобы избежать возможной путаницы с http:// URL, примеры в этом разделе используют неразрешаемые URI, такие как urn:example:within. К счастью, вы можете просто один раз указать этот префикс в начале файла, а затем забыть о нём.

Язык запросов SPARQL

SPARQL — это язык запросов для triple-store, использующих модель данных RDF. (Это акроним от SPARQL Protocol and RDF Query Language, произносится как “sparkle”.) Он появился раньше Cypher, и так как сопоставление паттернов в Cypher заимствовано из SPARQL, они выглядят весьма похоже.

Тот же самый запрос, что и раньше — поиск людей, которые переехали из US в Europe — столь же лаконичен в SPARQL, как и в Cypher (см. пример 3-10).

Пример 3-10. Тот же запрос, что и в примере 3-5, выраженный на SPARQL

Структура очень похожа. Следующие два выражения эквивалентны (в SPARQL переменные начинаются со знака вопроса):

Так как RDF не различает свойства и рёбра, а просто использует предикаты для обоих случаев, вы можете применять один и тот же синтаксис для сопоставления свойств. В следующем выражении переменная usa связывается с любой вершиной, у которой есть свойство name со значением строки «United States»:

SPARQL поддерживается Amazon Neptune, AllegroGraph, Blazegraph, OpenLink Virtuoso, Apache Jena и различными другими triple-store.

Datalog: рекурсивные реляционные запросы

Datalog — гораздо более старый язык, чем SPARQL или Cypher: он возник из академических исследований в 1980-х. Он менее известен среди разработчиков ПО и не имеет широкой поддержки в мейнстримных базах данных, но заслуживает большего внимания, так как это очень выразительный язык, особенно мощный для сложных запросов. Несколько нишевых баз данных, включая Datomic, LogicBlox, CozoDB и LIquid от LinkedIn, используют Datalog в качестве языка запросов.

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

Содержимое базы данных Datalog состоит из фактов, и каждый факт соответствует строке в реляционной таблице. Например, скажем, у нас есть таблица location, содержащая локации, и в ней три колонки: ID, name и type. Тот факт, что US является страной, может быть записан как location(2, «United States», «country»), где 2 — это ID для US. В общем случае выражение table(val1, val2, …) означает, что таблица содержит строку, где первый столбец содержит val1, второй столбец содержит val2 и так далее.

В примере 3-11 показано, как записать данные с левой части рисунка 3-6 в Datalog. Рёбра графа (within, born_in и lives_in) представлены как двухколоночные join-таблицы. Например, Lucy имеет ID 100, а Idaho имеет ID 3, поэтому отношение “Lucy родилась в Idaho” представлено как born_in(100, 3).

Пример 3-11. Подмножество данных с рисунка 3-6, представленное в виде фактов Datalog

Теперь, когда мы определили данные, мы можем записать тот же запрос, что и раньше, как показано в примере 3-12. Он выглядит немного иначе, чем эквиваленты в Cypher или SPARQL, но не позволяйте этому вас смутить. Datalog — это подмножество Prolog, языка программирования, который вы могли встречать раньше, если изучали информатику.

Пример 3-12. Тот же запрос, что и в примере 3-5, выраженный в Datalog

Cypher и SPARQL начинают сразу же с SELECT, но Datalog делает небольшие шаги. Мы определяем правила, которые выводят новые виртуальные таблицы из базовых фактов. Эти производные таблицы похожи на (виртуальные) SQL-представления: они не сохраняются в базе данных, но вы можете запрашивать их так же, как таблицу, содержащую сохранённые факты.

В примере 3-12 мы определяем три производные таблицы: within_recursive, migrated и us_to_europe. Имя и колонки виртуальных таблиц определяются тем, что стоит перед символом :- в каждом правиле. Например, migrated(PName, BornIn, LivingIn) — это виртуальная таблица с тремя колонками: имя человека, название места, где он родился, и название места, где он живёт.

Содержимое виртуальной таблицы определяется частью правила после символа :-, где мы пытаемся найти строки, соответствующие определённому паттерну в таблицах. Например, person(PersonID, PName) соответствует строке person(100, «Lucy»), где переменная PersonID связывается со значением 100, а переменная PName связывается со значением «Lucy». Правило применяется, если система может найти соответствие для всех паттернов в правой части оператора :- . Когда правило применяется, это как если бы левая часть :- была добавлена в базу данных (с заменой переменных на значения, которым они соответствуют).

Один из возможных способов применения правил следующий (и как показано на рисунке 3-7):

  • location(1, "North America", "continent") существует в базе данных, поэтому правило 1 применяется. Оно генерирует within_recursive(1, "North America").
  • within(2, 1) существует в базе данных, и предыдущий шаг сгенерировал within_recursive(1, "North America"), поэтому правило 2 применяется. Оно генерирует within_recursive(2, "North America").
  • within(3, 2) существует в базе данных, и предыдущий шаг сгенерировал within_recursive(2, "North America"), поэтому правило 2 применяется. Оно генерирует within_recursive(3, "North America").

Путём многократного применения правил 1 и 2 виртуальная таблица within_recursive может показать нам все локации в North America (или любой другой локации), содержащиеся в нашей базе данных.

Рисунок 3-7. Определение того, что Idaho находится в North America, с использованием правил Datalog из примера 3-12

Теперь правило 3 может найти людей, которые родились в некоторой локации BornIn и живут в некоторой локации LivingIn. Правило 4 вызывает правило 3 с BornIn = ‘United States’ и LivingIn = ‘Europe’ и возвращает только имена людей, которые соответствуют поиску. Запрашивая содержимое виртуальной таблицы us_to_europe, система Datalog наконец получает тот же ответ, что и в предыдущих запросах на Cypher и SPARQL.

Подход Datalog требует иного мышления по сравнению с другими языками запросов, обсуждёнными в этой главе. Он позволяет строить сложные запросы шаг за шагом, правило за правилом, где одно правило ссылается на другие, подобно тому, как вы разбиваете код на функции, вызывающие друг друга. И так же, как функции могут быть рекурсивными, правила Datalog также могут вызывать сами себя, как правило 2 в примере 3-12, что делает возможным обход графа в запросах Datalog.

GraphQL

GraphQL — это язык запросов, который по замыслу гораздо более ограниченный, чем другие языки запросов, рассмотренные в этой главе. Цель GraphQL — позволить клиентскому ПО, работающему на устройстве пользователя (например, мобильному приложению или JavaScript-фронтенду веб-приложения), запрашивать JSON-документ с определённой структурой, содержащий поля, необходимые для отображения его пользовательского интерфейса. Интерфейсы GraphQL позволяют разработчикам быстро изменять запросы в клиентском коде без изменения серверных API.

Гибкость GraphQL имеет свою цену. Организациям, которые принимают GraphQL, часто требуется инструментарий для преобразования GraphQL-запросов в запросы к внутренним сервисам, которые часто используют REST или gRPC. Авторизация, ограничение частоты запросов и проблемы с производительностью — это дополнительные вопросы. Язык запросов GraphQL также ограничен, так как GraphQL поступает из недоверенного источника. Язык не позволяет выполнять ничего такого, что могло бы быть дорогостоящим в исполнении, иначе пользователи могли бы провести denial-of-service атаку на сервер, запустив множество дорогих запросов. В частности, GraphQL не допускает рекурсивные запросы (в отличие от Cypher, SPARQL, SQL или Datalog), и он не позволяет произвольные условия поиска, такие как “найти людей, которые родились в US и сейчас живут в Europe” (если только владельцы сервиса специально не решат предоставить такую возможность поиска).

Тем не менее GraphQL полезен. В примере 3-13 показано, как можно реализовать групповое чат-приложение вроде Discord или Slack с использованием GraphQL. Запрос получает все каналы, к которым имеет доступ пользователь, включая имя канала и 50 последних сообщений в каждом канале. Для каждого сообщения запрашиваются временная метка, содержимое сообщения, а также имя и URL аватара отправителя. Более того, если сообщение является ответом на другое сообщение, запрос также получает имя отправителя и содержимое сообщения, на которое был дан ответ (которое может отображаться более мелким шрифтом над ответом, чтобы дать немного контекста).

Пример 3-13. Пример GraphQL-запроса для группового чат-приложения

Пример 3-14 показывает, как может выглядеть ответ на запрос из примера 3-13. Ответ — это JSON-документ, который отражает структуру запроса: он содержит ровно те атрибуты, которые были запрошены, ни больше, ни меньше. Такой подход имеет то преимущество, что серверу не нужно знать, какие атрибуты требуются клиенту для отображения пользовательского интерфейса; вместо этого клиент может просто запросить то, что ему нужно. Например, этот запрос не запрашивает URL аватара для отправителя сообщения replyTo, но если бы пользовательский интерфейс изменили так, чтобы добавить этот аватар, клиенту было бы легко добавить требуемый атрибут imageUrl в запрос без изменения сервера.

Пример 3-14. Возможный ответ на запрос из примера 3-13

В примере 3-14 имя и URL аватара отправителя сообщения встроены непосредственно в объект сообщения. Если один и тот же пользователь отправляет несколько сообщений, эта информация повторяется в каждом сообщении. В принципе, можно было бы уменьшить это дублирование, но GraphQL делает выбор в пользу увеличения размера ответа, чтобы упростить отображение пользовательского интерфейса на основе данных.

Поле replyTo устроено аналогично: в примере 3-14 второе сообщение является ответом на первое, и содержимое (“Hey!…”) и отправитель Aaliyah дублируются внутри replyTo. Можно было бы вместо этого вернуть ID сообщения, на которое дан ответ, но тогда клиенту пришлось бы делать дополнительный запрос к серверу, если этот ID не входит в 50 последних возвращённых сообщений. Дублирование содержимого делает работу с данными значительно проще.

База данных сервера может хранить данные в более нормализованной форме и выполнять необходимые соединения для обработки запроса. Например, сервер может хранить сообщение вместе с ID пользователя-отправителя и ID сообщения, на которое дан ответ; получив такой запрос, как выше, сервер затем разрешает эти ID, чтобы найти соответствующие записи. Однако клиент может попросить сервер выполнить только такие соединения, которые явно предусмотрены в схеме GraphQL.

Хотя ответ на запрос GraphQL выглядит похожим на ответ от документной базы данных и хотя в названии есть слово “graph”, GraphQL может быть реализован поверх любой базы данных — реляционной, документной или графовой.

Event Sourcing и CQRS

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

Мы уже видели эту идею в разделе “Системы учёта и производные данные”, а ETL (см. “Хранилище данных”) — это один из примеров такого процесса преобразования. Теперь мы пойдём дальше. Если мы всё равно собираемся выводить одно представление данных из другого, мы можем выбрать разные представления, соответственно оптимизированные для записи и для чтения. Как бы вы смоделировали свои данные, если бы хотели оптимизировать их только для записи, а эффективность запросов не имела бы значения?

Возможно, самый простой, быстрый и выразительный способ записи данных — это журнал событий: каждый раз, когда вы хотите записать какие-то данные, вы кодируете их в виде самодостаточной строки (например, в формате JSON), включая временную метку, а затем добавляете в последовательность событий. События в этом журнале неизменяемы: вы никогда не изменяете и не удаляете их, вы только добавляете новые события в журнал (которые могут заменить более ранние события). Событие может содержать произвольные свойства.

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

Рисунок 3-8. Использование журнала неизменяемых событий как источника истины и вывод из него материализованных представлений

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

Идея использования событий как источника истины и выражения каждого изменения состояния в виде события называется event sourcing. Принцип поддержания отдельных представлений, оптимизированных для чтения, и их вывода из представления, оптимизированного для записи, называется command query responsibility segregation (CQRS). Эти термины появились в сообществе domain-driven design (DDD), хотя подобные идеи существуют уже давно, например в репликации конечных автоматов.

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

При моделировании данных в стиле event sourcing рекомендуется называть события в прошедшем времени (например, “места были забронированы”), потому что событие — это запись факта о том, что что-то произошло в прошлом. Даже если пользователь позже решит изменить или отменить бронирование, остаётся фактом, что у него ранее была бронь, а изменение или отмена — это отдельное событие, которое добавляется позже.

Сходство между event sourcing и таблицей фактов в звёздной схеме, как обсуждалось в разделе “Звёзды и снежинки: схемы для аналитики”, заключается в том, что и то и другое — это коллекции событий, которые произошли в прошлом. Однако строки в таблице фактов имеют одинаковый набор колонок, тогда как в event sourcing может существовать множество различных типов событий, каждое со своими свойствами. Более того, таблица фактов — это неупорядоченная коллекция, а в event sourcing порядок событий важен: если сначала бронирование было сделано, а потом отменено, обработка этих событий в неправильном порядке не имела бы смысла.

Event sourcing и CQRS имеют несколько преимуществ:

  • Для разработчиков системы события лучше передают намерение, почему что-то произошло. Например, проще понять событие “бронирование было отменено”, чем “в таблице bookings в строке 4001 колонка active была установлена в false, три строки, связанные с этим бронированием, были удалены из таблицы seat_assignments, и строка с возвратом была добавлена в таблицу payments”. Эти изменения строк всё равно могут произойти, когда материализованное представление обработает событие отмены, но когда они вызваны событием, причина обновлений становится гораздо яснее.
  • Ключевой принцип event sourcing заключается в том, что материализованные представления выводятся из журнала событий воспроизводимым образом: вы всегда должны иметь возможность удалить материализованные представления и пересоздать их, обработав те же события в том же порядке, используя тот же код. Если в коде поддержки представлений была ошибка, вы можете просто удалить представление и пересоздать его с новым кодом. Ошибку также проще найти, потому что вы можете запускать код поддержки представлений столько раз, сколько нужно, и проверять его поведение.
  • У вас может быть несколько материализованных представлений, оптимизированных для конкретных запросов, необходимых вашему приложению. Они могут храниться либо в той же базе данных, что и события, либо в другой, в зависимости от ваших нужд. Они могут использовать любую модель данных и могут быть денормализованы для быстрых чтений. Вы даже можете хранить представление только в памяти и не сохранять его, если можно пересоздать представление из журнала событий при каждом перезапуске сервиса.
  • Если вы решите представить существующую информацию новым способом, легко построить новое материализованное представление из уже существующего журнала событий. Вы также можете развивать систему для поддержки новых функций, добавляя новые типы событий или новые свойства в существующие типы событий (старые события при этом остаются неизменными). Вы также можете связывать новые поведения с существующими событиями (например, когда участник конференции отменяет бронь, его место можно предложить следующему в списке ожидания).
  • Если событие было записано по ошибке, вы можете удалить его и затем пересобрать представления без этого события. С другой стороны, в базе данных, где данные обновляются и удаляются напрямую, зафиксированную транзакцию часто трудно отменить. Event sourcing таким образом может уменьшить количество необратимых действий в системе, облегчая внесение изменений (см. “Эволюционируемость: делаем изменения лёгкими”).
  • Журнал событий также может служить журналом аудита всего, что произошло в системе, что ценно в регулируемых отраслях, где требуется такая возможность аудита.

Однако у event sourcing и CQRS есть и недостатки:

  • Нужно быть осторожным, если вовлечена внешняя информация. Например, пусть событие содержит цену в одной валюте, а для одного из представлений её нужно конвертировать в другую валюту. Так как обменный курс может колебаться, было бы проблематично запрашивать курс из внешнего источника при обработке события, потому что при пересоздании представления в другой день вы получите другой результат. Чтобы сделать логику обработки событий детерминированной, нужно либо включить курс валюты прямо в событие, либо иметь способ запрашивать исторический курс валюты на временную метку, указанную в событии, гарантируя, что этот запрос всегда возвращает один и тот же результат для одной и той же временной метки.
  • Требование неизменяемости событий создаёт проблемы, если события содержат персональные данные пользователей, так как пользователи могут воспользоваться своим правом (например, по GDPR) потребовать удаления своих данных. Если журнал событий ведётся на уровне одного пользователя, вы можете просто удалить весь журнал этого пользователя, но это не сработает, если журнал содержит события, относящиеся к нескольким пользователям. Можно попробовать хранить персональные данные вне самого события или шифровать их ключом, который позже можно удалить, но это также усложняет пересоздание производных состояний при необходимости.
  • Повторная обработка событий требует осторожности, если есть внешние видимые побочные эффекты — например, вы, вероятно, не захотите повторно отправлять письма с подтверждением каждый раз, когда пересобираете материализованное представление.
  • Вы можете реализовать event sourcing поверх любой базы данных, но существуют также системы, специально разработанные для поддержки этого паттерна, такие как EventStoreDB, MartenDB (на базе PostgreSQL) и Axon Framework. Также можно использовать брокеры сообщений, такие как Apache Kafka, для хранения журнала событий, а процессоры потоков могут поддерживать актуальность материализованных представлений.
  • Единственное важное требование состоит в том, что система хранения событий должна гарантировать, что все материализованные представления обрабатывают события в точно том же порядке, в каком они появляются в журнале; достичь этого в распределённой системе не всегда просто.

Dataframes, матрицы и массивы

Модели данных, которые мы рассмотрели до сих пор в этой главе, как правило, используются как для обработки транзакций, так и для аналитических целей (см. “Аналитические против операционных систем”). Существуют также некоторые модели данных, которые вы, вероятно, встретите в аналитическом или научном контексте, но которые редко используются в OLTP-системах: dataframes и многомерные числовые массивы, такие как матрицы.

Dataframes — это модель данных, поддерживаемая языком R, библиотекой Pandas для Python, Apache Spark, ArcticDB, Dask и другими системами. Это популярный инструмент для data scientists при подготовке данных к обучению моделей машинного обучения, но также он широко используется для исследования данных, статистического анализа данных, визуализации данных и аналогичных целей.

На первый взгляд dataframe похож на таблицу в реляционной базе данных или электронную таблицу. Он поддерживает операции, подобные реляционным, которые выполняют массовые операции над содержимым dataframe: например, применение функции ко всем строкам, фильтрация строк на основе некоторого условия, группировка строк по некоторым столбцам и агрегация других столбцов, объединение строк одного dataframe с другим dataframe по некоторому ключу (то, что реляционная база данных называет join, обычно называется merge в dataframes).

Вместо декларативного запроса, такого как SQL, dataframe обычно обрабатывается серией команд, которые изменяют его структуру и содержимое. Это соответствует типичному рабочему процессу data scientists, которые постепенно “wrangle” данные в форму, позволяющую находить ответы на интересующие вопросы. Эти манипуляции обычно происходят на приватной копии набора данных у data scientist, часто на локальной машине, хотя конечный результат может быть разделён с другими пользователями.

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

Простой пример такого преобразования показан на рисунке 3-9. Слева у нас есть реляционная таблица, в которой показано, как разные пользователи оценили различные фильмы (по шкале от 1 до 5), а справа данные преобразованы в матрицу, где каждый столбец соответствует фильму, а каждая строка — пользователю (аналогично сводной таблице в электронной таблице). Матрица разреженная, то есть для многих комбинаций пользователь–фильм нет данных, но это нормально. Эта матрица может иметь много тысяч столбцов и поэтому не очень хорошо впишется в реляционную базу данных, но dataframes и библиотеки, которые поддерживают разреженные массивы (например, NumPy для Python), могут легко работать с такими данными.

Рисунок 3-9. Преобразование реляционной базы данных рейтингов фильмов в матричное представление

Матрица может содержать только числа, и для преобразования нечисловых данных в числа в матрице используются различные техники. Например:
Даты (которые опущены в примерной матрице на рисунке 3-9) можно масштабировать так, чтобы они стали числами с плавающей точкой в пределах некоторого подходящего диапазона.
Для столбцов, которые могут принимать только одно из небольшого фиксированного набора значений (например, жанр фильма в базе фильмов), часто используется one-hot encoding: мы создаём столбец для каждого возможного значения (один для “комедии”, один для “драмы”, один для “ужасов” и т. д.), и для каждой строки, представляющей фильм, ставим 1 в столбце, соответствующем жанру этого фильма, и 0 во всех остальных. Такое представление также легко обобщается на фильмы, относящиеся сразу к нескольким жанрам.

Как только данные находятся в форме матрицы чисел, они становятся пригодными для операций линейной алгебры, которые лежат в основе многих алгоритмов машинного обучения. Например, данные на рисунке 3-9 могут быть частью системы рекомендаций фильмов, которые может понравиться пользователю. Dataframes достаточно гибки, чтобы позволить постепенно эволюционировать данные из реляционной формы в матричное представление, при этом предоставляя data scientist контроль над представлением, которое наиболее подходит для достижения целей анализа данных или процесса обучения модели.

Существуют также базы данных, такие как TileDB, которые специализируются на хранении больших многомерных массивов чисел; они называются array databases и чаще всего используются для научных наборов данных, таких как геопространственные измерения (растровые данные на регулярно расположенной сетке), медицинская визуализация или наблюдения с астрономических телескопов. Dataframes также используются в финансовой индустрии для представления временных рядов данных, таких как цены активов и сделки во времени.

Резюме по главе

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

Реляционная модель, несмотря на то, что ей больше полувека, остаётся важной моделью данных для многих приложений — особенно в области хранилищ данных и бизнес-аналитики, где реляционные схемы типа звезды или снежинки и SQL-запросы повсеместны. Однако в других доменах также стали популярными несколько альтернатив реляционным данным:
Модель документов нацелена на сценарии, где данные приходят в виде автономных JSON-документов, и где связи между одним документом и другим встречаются редко.
Графовые модели данных идут в противоположном направлении, ориентируясь на сценарии, где всё потенциально связано со всем, и где запросы потенциально должны проходить через несколько “хопов”, чтобы найти интересующие данные (что можно выразить с помощью рекурсивных запросов в Cypher, SPARQL или Datalog).
Dataframes обобщают реляционные данные на большое количество столбцов, тем самым предоставляя мост между базами данных и многомерными массивами, которые составляют основу значительной части машинного обучения, статистического анализа данных и научных вычислений.

В определённой степени одну модель можно эмулировать в терминах другой модели — например, графовые данные можно представить в реляционной базе данных — но результат может быть неудобным, как мы видели на примере поддержки рекурсивных запросов в SQL.

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

Ещё одна модель, которую мы обсудили, — это event sourcing, которая представляет данные в виде добавляемого только вперёд лога неизменяемых событий и которая может быть полезной для моделирования активностей в сложных бизнес-доменах. Append-only лог хорош для записи данных (как мы увидим в главе 4); чтобы поддерживать эффективные запросы, event log преобразуется в оптимизированные для чтения материализованные представления через CQRS.

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

Хотя мы рассмотрели много материала, остались ещё модели данных, которые мы не упомянули. Чтобы привести лишь несколько кратких примеров:
Исследователи, работающие с геномными данными, часто должны выполнять поиски по схожести последовательностей, что означает необходимость взять одну очень длинную строку (представляющую молекулу ДНК) и сопоставить её с большой базой строк, которые похожи, но не идентичны. Ни одна из описанных здесь баз данных не может справиться с таким использованием, поэтому исследователи написали специализированное программное обеспечение для геномных баз данных, например GenBank.
Многие финансовые системы используют бухгалтерские книги с двойной записью как модель данных. Эти данные могут быть представлены в реляционных базах данных, но также существуют базы данных, такие как TigerBeetle, которые специализируются на этой модели данных. Криптовалюты и блокчейны обычно основаны на распределённых бухгалтерских книгах, в которых также встроена передача стоимости в модель данных.
Полнотекстовый поиск, вероятно, можно рассматривать как своего рода модель данных, которая часто используется вместе с базами данных. Информационный поиск — это обширная специализированная тема, которую мы не будем подробно рассматривать в этой книге, но мы коснёмся поисковых индексов и векторного поиска в разделе “Полнотекстовый поиск”.

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

0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x