Перейти к содержимому
TelegramGitHub

Designing Data-Intensive Applications, глава 2: Модели данных и языки запросов

Главная мысль: модель данных определяет, что в приложении просто, а что неудобно. Relational, document и graph модели подходят для разных типов связей между данными.

Выбор модели данных по типу связей: document, relational, graph

Приложения строятся слоями. Каждый слой выражает данные через слой ниже:

  1. Разработчик моделирует реальный мир через объекты и структуры данных.
  2. Их хранят в универсальной модели: JSON/XML, таблицы или граф.
  3. Авторы базы решают, как представить это в байтах на диске и в памяти.
  4. Ещё ниже инженеры по железу.

Каждая модель встраивает предположения об использовании. Одни операции быстрые, другие неудобные. Поэтому модель нужно выбирать осознанно.

Relational model (Codd, 1970): данные лежат в отношениях (таблицах), то есть в наборах строк. Она захватила рынок бизнес-обработки: транзакции и пакетные отчёты. Позже она обобщилась на веб, e-commerce и SaaS.

NoSQL — не одна технология. Слово появилось как хештег для встречи в 2009 году. Причины выбора NoSQL:

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

Идея polyglot persistence: relational базы будут жить рядом с разными нереляционными хранилищами.

Объекты в коде и таблицы в базе не совпадают. Это называют impedance mismatch. ORM (ActiveRecord, Hibernate) уменьшают шаблонный код, но не скрывают разницу полностью.

Пример: резюме. Один пользователь имеет много мест работы, учёбы и контактов (one-to-many). Варианты хранения:

  • нормализованные таблицы с foreign key;
  • типы XML и JSON в самой строке (PostgreSQL, DB2, Oracle, SQL Server);
  • JSON как текст в одном столбце (внутри не искать).

JSON-документ хранит всё дерево в одном месте. У него лучше locality: одним запросом без JOIN читается весь профиль.

Хранить название региона как ID, а не как строку, полезно. Нет дублирования. Нет рассинхрона при переименовании. Есть единый стиль, локализация и лучший поиск. Это и есть идея normalization.

Но ссылки на общие сущности — это many-to-one и many-to-many. Для документных баз это слабое место: JOIN-ы часто отсутствуют или слабые. Join приходится делать в коде приложения. Данные со временем становятся связаннее: организации как сущности, рекомендации между пользователями. Тогда документная модель становится неудобной.

В 1970-х господствовала IMS от IBM с hierarchical model: дерево записей. Она, как документные базы, хорошо справлялась с one-to-many, но плохо с many-to-many и без JOIN. Появились два решения:

МодельКак устроенаИтог
Network (CODASYL)Запись может иметь несколько родителей. Доступ только по access path, как указатели. Разработчик вручную помнит путиКод сложный и хрупкий. Менять модель данных трудно. Модель исчезла
RelationalТаблицы открыты. Query optimizer сам выбирает путь и индексыНовый индекс не требует менять запросы. Модель победила

Хронология: hierarchical, network, relational, document

Документные базы вернулись к иерархии для вложенных записей. Для ссылок на другие документы они используют identifier (document reference), как foreign key. Он разрешается при чтении через join или дополнительные запросы. Путь CODASYL они не повторяют.

КритерийDocumentRelational
ДанныеДерево, документ читается целикомMany-to-one, many-to-many
JoinsСлабые или нетСильные
СхемаГибкая (schema-on-read)Явная (schema-on-write)
LocalityХорошая, если нужен весь документТребует нескольких lookup или JOIN

Правило книги: для сильно связанных данных документная модель неудобна, relational приемлема, graph естественна.

Schema flexibility. “Schemaless” вводит в заблуждение. Структура всё равно предполагается кодом. Точнее: schema-on-read (структура неявная, проверяется при чтении, как динамическая типизация) и schema-on-write (база проверяет данные, как статическая типизация). Schema-on-read выгодна, если данные разнородны или структура задаётся внешней системой. Если у всех записей одинаковая структура, схема полезна как документация и защита. Смена схемы в SQL (ALTER TABLE) обычно быстрая. Исключение, по книге, MySQL: он копирует таблицу, и это может занять часы. UPDATE на большой таблице медленный в любой базе.

Locality. Документ хранится как одна строка (JSON, BSON). Это выгодно, когда нужен почти весь документ. Но база читает документ целиком, даже если нужна малая часть. При изменении документ обычно перезаписывается целиком. Поэтому документы лучше держать небольшими. Locality есть и в других системах: Spanner (interleaved tables), Oracle (multi-table index cluster), column family в Bigtable (Cassandra, HBase).

Конвергенция. Relational базы добавляют JSON и XML (PostgreSQL с 9.3, DB2). Документные добавляют joins (RethinkDB). Гибрид хорош: модели дополняют друг друга.

Declarative язык (SQL) описывает, какие данные нужны. Imperative код описывает, как их получить по шагам. Преимущества declarative:

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

Книга показывает ту же идею на CSS: селектор li.selected > p декларативен и сам обновляется, а код на DOM API длиннее и хрупок.

MapReduce — модель между declarative и imperative. Логика задаётся двумя функциями, map и reduce, которые framework вызывает сам. Они должны быть pure functions: используют только входные данные, не делают запросов, без побочных эффектов. Тогда их можно запускать где угодно, в любом порядке и повторять при сбое. Подробнее в главе 10.

Недостаток: нужны две согласованные функции, и у optimizer меньше возможностей. Поэтому MongoDB добавила декларативный aggregation pipeline. Вывод книги: NoSQL-система может случайно изобрести SQL заново.

Если many-to-many связи частые, естественна graph model. Граф состоит из vertices (узлы) и edges (рёбра). Примеры: социальные сети, веб-граф, дорожные сети. Графы также хранят разнородные объекты в одном хранилище (Facebook: люди, места, события, комментарии). Алгоритмы: кратчайший путь, PageRank.

Vertex: id, входящие и исходящие рёбра, свойства (key-value). Edge: id, tail и head vertex, label, свойства. Реализации: Neo4j, Titan, InfiniteGraph. Это можно хранить в двух таблицах (vertices и edges) с индексами по tail_vertex и head_vertex.

Свойства модели:

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

Графы хороши для evolvability. Они легко выражают разные структуры (страны с разными регионами), разную детализацию (город в одном случае, штат в другом) и новые факты.

Cypher (Neo4j) — декларативный язык с паттерном со стрелками. Запрос “кто родился в США и живёт в Европе” использует путь переменной длины :WITHIN*0.. (“ноль и более рёбер”). В SQL число JOIN обычно известно заранее. Для переменной длины нужны recursive common table expressions (WITH RECURSIVE). В книге тот же запрос занимает 4 строки на Cypher и 29 на SQL. Вывод: разные модели рассчитаны на разные задачи.

Graph и CODASYL не одно и то же:

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

В triple-store всё хранится как (subject, predicate, object). Если object — значение, это свойство. Если object — другая вершина, это ребро. Это почти то же, что property graph, другими словами. Реализации: Datomic, AllegroGraph. Формат Turtle, стандарт RDF. Semantic web не оправдал надежд, но триплеты полезны как внутренняя модель. Predicate в RDF часто URI, чтобы данные разных источников не конфликтовали.

SPARQL предшествует Cypher, паттерны Cypher заимствованы оттуда. Запрос на нём ещё короче.

Datalog старше SPARQL и Cypher (исследовался в 1980-х). Данные записываются как predicate(subject, object). Вы определяете rules, которые выводят новые predicates из данных и других правил. Правила можно комбинировать и использовать повторно, даже рекурсивно. Используется в Datomic и Cascalog (запросы к Hadoop). Сложнее для разовых запросов, но лучше справляется со сложными данными.

Graph модели и языки запросов: property graph, triple-store, Datalog

  • Данные начинались с иерархии. Для many-to-many придумали relational model.
  • NoSQL пошёл в двух направлениях: document (связи редки) и graph (всё связано со всем).
  • Document и graph базы обычно не навязывают схему. Это облегчает изменения.
  • Одна модель может эмулировать другую, но неудобно. Поэтому нет универсального решения.
  • Моделей больше, чем здесь: геномные базы, полнотекстовый поиск, физика частиц.
Термин (EN)Что значит
Data modelСпособ представить данные: таблицы, документы, граф
Impedance mismatchНесовпадение объектов в коде и таблиц в базе
ORMБиблиотека, которая переводит между объектами и таблицами
NormalizationУбрать дублирование: значение хранится в одном месте, остальное ссылки
DenormalizationНамеренное дублирование данных ради скорости чтения
One-to-many / many-to-manyТипы связей между записями
Polyglot persistenceРазные хранилища для разных задач в одной системе
Hierarchical / network modelСтарые модели: дерево и граф с указателями (IMS, CODASYL)
Access pathПуть от записи к записи, по которому надо идти вручную
Query optimizerЧасть базы, сама выбирающая план запроса
Schema-on-read / schema-on-writeСхема проверяется при чтении / при записи
LocalityСвязанные данные лежат рядом
Declarative / imperativeОписать результат / описать шаги
MapReduceМодель пакетной обработки из функций map и reduce
Property graphГраф из вершин и рёбер со свойствами
Triple-storeХранение фактов как (subject, predicate, object)
Cypher, SPARQL, DatalogЯзыки запросов к графам
Recursive CTEWITH RECURSIVE: рекурсивный запрос в SQL
  • Выбирайте модель по связям в данных. Дерево: документ. Много общих справочников и many-to-many: relational. Сложные цепочки связей: graph.
  • Schema-on-read не отменяет схему. Она просто живёт в коде. Для пайплайнов фиксируйте контракт полей, иначе ломаются потребители.
  • Нормализуйте справочники и храните ID. Денормализуйте осознанно и решите, кто поддерживает согласованность копий.
  • Для аналитики с JSON-событиями полезны полуструктурированные типы в хранилище (JSON в PostgreSQL и аналогах). Но пересчитывайте в колонки то, по чему фильтруете.
  • Предпочитайте declarative запросы (SQL): их оптимизирует движок и параллелит. Пишите imperative код, только если декларации не хватает.
  • Рекурсивные иерархии (категории, оргструктура, lineage) в SQL делайте через recursive CTE. Если обходов много, подумайте о graph базе.
  1. Почему документная модель хорошо хранит резюме, но плохо справляется с рекомендациями между пользователями?
  2. Чем schema-on-read отличается от schema-on-write? Когда нужна первая?
  3. Почему declarative запрос проще оптимизировать и выполнять параллельно?
  4. Почему запрос с путём переменной длины в SQL намного длиннее, чем в Cypher?

Предыдущая: Надёжность, масштабируемость, сопровождаемость. Следующая: Хранение и извлечение.