Глава 1. Введение в документы
Содержание
- Табличное мышление
- Подход NoSQL
- Знакомство с документами
- Доводы в пользу документов
- Задачи, которые решают документы
- Слабые стороны документов
- Замечание о переоценке
- Почему Postgres
- Тайное преимущество SQL
В первой главе мы рассмотрим реляционные и документо-ориентированные базы данных. Мы обсудим их сильные и слабые стороны, сценарии, когда одному виду предпочитают другой и наоборот. Здесь же мы коснемся понятия документа: структуры данных, которая ввиду сложности плохо ложится на реляционные таблицы. Мы узнаем, для каких задач выбирают документы и чем может быть полезен Postgres.
Табличное мышление
Если вы бэкенд-разработчик, то скорее всего работали с реляционными базами данных. Наиболее известные их представители — это MySQL, ее форк MariaDB, PostgreSQL, Microsoft SQL Server, Oracle DB. Реляционные базы называются так из-за лежащей в их основе реляционной алгебры (она же алгебра отношений). Это математический аппарат, который строится на отношениях (множествах кортежей) и операций над ними (проекция, объединение, пересечение и другие).
Чтобы работать с базой на прикладном уровне, реляционная алгебра не требуется. Гораздо важнее следствия математической модели:
- любой массив данных является таблицей;
- любая операция над таблицей порождает таблицу.
В каждой базе найдется техника, которая не подходит под эти правила. Однако общую картину это не меняет: в основе реляционных баз лежат таблицы и операции над ними.
Предположим, имеется таблица users с тремя полями: id, name и age:
create table users (
id integer,
name text,
age integer
);
Добавим трех пользователей:
insert into users values
(1, 'Ivan', 14),
(2, 'John', 34),
(3, 'Juan', 51);
Выборка всех полей без условий вернет таблицу (отношение):
select id, name, age from users;
┌────┬──────┬─────┐
│ id │ name │ age │
├────┼──────┼─────┤
│ 1 │ Ivan │ 14 │
│ 2 │ John │ 34 │
│ 3 │ Juan │ 51 │
└────┴──────┴─────┘
Если выбрать только два столбца, получим таблицу, усеченную по горизонтали. В реляционной алгебре это называется проекцией:
select id, age from users;
┌────┬─────┐
│ id │ age │
├────┼─────┤
│ 1 │ 14 │
│ 2 │ 34 │
│ 3 │ 51 │
└────┴─────┘
Оператор WHERE накладывает условие на строки. С ним мы отбросим часть из них и
получим таблицу, уменьшенную по вертикали. В терминах реляционной алгебры мы
фильтруем отношение. Совместим проекцию и фильтр в одном запросе:
select id, name from users
where age between 18 and 49
Операторы SELECT и WHERE срезали данные с разных сторон, но итоговый результат — таблица:
┌────┬──────┐
│ id │ name │
├────┼──────┤
│ 2 │ John │
└────┴──────┘
Оператор JOIN (соединение), напротив, расширяет таблицу по горизонтали. Ниже мы соединяем пользователей с их профилями по ссылочному полю.
create table profiles (
user_id integer,
job text,
is_open boolean
);
insert into profiles values
(1, 'teacher', true),
(2, 'programmer', false),
(3, 'tester', true);
select * from users u
join profiles p on u.id = p.user_id;
Итоговая таблица содержит поля обеих таблиц:
┌────┬──────┬─────┬─────────┬────────────┬─────────┐
│ id │ name │ age │ user_id │ job │ is_open │
├────┼──────┼─────┼─────────┼────────────┼─────────┤
│ 1 │ Ivan │ 14 │ 1 │ teacher │ t │
│ 2 │ John │ 34 │ 2 │ programmer │ f │
│ 3 │ Juan │ 51 │ 3 │ tester │ t │
└────┴──────┴─────┴─────────┴────────────┴─────────┘
Даже такой примитивный запрос как select 1 as x возвращает таблицу. В ней одна
колонка с именем x и одна запись с кортежем (1, ):
select 1 as x;
┌───┐
│ x │
├───┤
│ 1 │
└───┘
Возможно, кому-то этот подход напомнил Matlab, где любое значение считается матрицей, пусть даже размера 1x1.
Таблицы и операции над ними образует то, что автор называет табличным мышлением. Любые данные можно представить таблицей или их набором, а затем применить проекцию, фильтр или объединение. Операции декларативны: нет промежуточных переменных, циклов и ветвлений. База данных выводит технические шаги из описания.
В прикладных языках вроде Python или Java мы работаем не с таблицами, а коллекциями. Чаще всего это списки и словари, в случае строгой типизации – структуры, записи. Следующий тезис прозвучит странно, но все-таки: в обработке данных коллекции уступают таблицам. Если выразить проекцию и фильтр на прикладном языке, код окажется многословным, а иной раз – запутанным.
Причина в том, что Python, Java и аналоги – языки общего назначения. Их задача – быть удобными во всем, не вдаваясь слишком глубоко в какую-то область. При работе с коллекциями они удобны, но уступают специальному решению – SQL.
Предположим, имеется список пользователей. Каждый элемент – словарь с полями id, name и age. Нужно выбрать пользователей старше 18 лет, оставив при этом код и имя. Выразим это на разных языках, например императивном Python. Нам понадобится функция subset, которая вернет подмножество словаря:
dеf subset(d, *keys):
return {k: d[k] for k in keys}
users = [
{"id": 1, "name": "Ivan", "age": 14},
{"id": 2, "name": "John", "age": 34},
{"id": 3, "name": "Juan", "age": 51},
]
new_users = [
subset(user, "id", "name") for user in users
if user["age"] > 18
]
[{'id': 2, 'name': 'John'},
{'id': 3, 'name': 'Juan'}]
То же самое на Clоjure, функциональном диалекте Лиспа:
(dеf users
[{:id 1 :name "Ivan" :age 14}
{:id 2 :name "John" :age 34}
{:id 3 :name "Juan" :age 51}])
(->> users
(filter (fn [user]
(-> user :age (> 18))))
(map (fn [user]
(select-keys user [:id :name]))))
({:id 2 :name "John"}
{:id 3 :name "Juan"})
В каждом языке это можно проделать разными способами, но мы выбрали самые очевидные: list comprehension в Python и map/filter Clоjure. Теперь запишем то же самое на SQL:
select id, name from users where age > 18;
┌────┬──────┐
│ id │ name │
├────┼──────┤
│ 2 │ John │
│ 3 │ Juan │
└────┴──────┘
Решение на SQL короче, однако мы усложним пример. Предположим, к пользователям
прилагаются профили. Вдобавок к отбору по возрасту нужно оставить тех
пользователей, в профиле которых указано “открыт к предложениям” (логическое
поле is_open).
Вот какие трудности это влечет. Во-первых, чтобы найти профиль по номеру
пользователя, нужно составить словарь user_id => profile. Во-вторых, на каждом
шаге проверять, есть ли профиль, и если нет, отбрасывать пользователя, даже если
ему больше 18 лет. В-третьих, при наличии профиля проверять флаг is_open. Код на
Python:
profiles = [
{"user_id": 1, "job": "teacher", "is_open": True},
{"user_id": 2, "job": "programmer", "is_open": False},
{"user_id": 3, "job": "tester", "is_open": True},
]
dеf index_by(field, rows):
return {row[field]: row for row in rows}
profile_index = index_by("user_id", profiles);
result = []
for user in users:
user_id = user["id"]
profile = profile_index[user_id]
if user["age"] > 18 and profile["is_open"]:
row = {**user, **profile}
result.append(row)
[{'id': 3,
'name': 'Juan',
'age': 51,
'user_id': 3,
'job': 'tester',
'is_open': True}]
Версия на Clоjure:
(dеf profiles
[{:user-id 1 :job "teacher" :is-open true}
{:user-id 2 :job "programmer" :is-open false}
{:user-id 3 :job "tester" :is-open true}])
(dеfn index-by [kw rows]
(->> rows
(map (juxt kw identity))
(into {})))
(dеf profile-index
(index-by :user-id profiles))
(->> users
(filter (fn [user]
(-> user :age (> 18))))
(map (fn [user]
(let [profile (get profile-index (:id user))]
(merge user profile))))
(filter (fn [row]
(-> row :is-open))))
({:id 3,
:name "Juan",
:age 51,
:user-id 3,
:job "tester",
:is-open true})
Решение на SQL:
select * from users u
join profiles p on u.id = p.user_id
where u.age > 18 and p.is_open;
┌────┬──────┬─────┬─────────┬────────┬─────────┐
│ id │ name │ age │ user_id │ job │ is_open │
├────┼──────┼─────┼─────────┼────────┼─────────┤
│ 3 │ Juan │ 51 │ 3 │ tester │ t │
└────┴──────┴─────┴─────────┴────────┴─────────┘
Третий вариант — снова самый короткий, но преимущество не только в этом. SQL декларативен: он не указывает порядок обхода таблиц, не требует циклов и проверок if/else. Для его исполнения не нужен интерпретатор, установленный локально. Код на SQL выполнит сервер, а мы получим результат.
Автор видел примеры того, как целые экраны кода можно было заменить небольшим запросом. Единственное ограничение в том, что данные не всегда находятся в базе: в эпоху микросервисов их все чаще извлекают по сети. Сущности получают из разных источников и соединяют в приложении: строят индексы id => entity, обходят их и отбрасывают часть данных. Эти действия легко заменить шагами:
- загрузить данные в реляционные таблицы;
- выполнить запросы с нужными WHERE, JOIN, GROUP BY и так далее.
Недостаток в том, что данные нужно загрузить в базу – а ведь мы уже потратили время на обращение к сервисам. Это увеличивает трафик и замедляет обработку. Однако сегодня для каждого языка найдется in-memory SQL engine – движок SQL, который работает в одном процессе с приложением. Прежде всего это SQLite и DuckDB: они написаны на C/C++ и вызываются через внешний интерфейс (foreign function call). В Java доступны библиотеки H2 и HyperSQL, написанные на ней самой. Поэтому работа с данными сводится к шагам:
- собрать ингредиенты из сетевых сервисов;
- перенести их в SQL-движок, запущенный в памяти;
- выполнить запросы и прочитать результат.
Часто разработчики выступают против SQL и заменяют абстракцией: ORM (Object Relational Mapping) или потоками (LinQ в C#, Streams в Java). Однако практика показывает: на долгой дистанции SQL проще и удобней. Реляционная модель позволяет строить любые проекции данных и при этом:
-
исходные данные не изменяются: выборка двух столбцов не удалит исходные столбцы. Это касается императивных языков, где коллекции изменяемы, и удаление элемента скажется на всем коде.
-
Не нужно писать императивный код с обходом коллекций. SQL выражает намерение, а не реализацию задумки.
Не нужна машина, на которой установлены Python, Java и другие языки. Если данные хранятся в базе, для работы с ними хватит минимального клиента вроде psql.
Подход NoSQL
На рубеже нулевых и десятых годов возникли другие подходы к хранению данных. Их ключевая черта – отказ от таблиц и языка SQL. Совокупно эти решения назвали NoSQL, причем No означает не английское “нет”, а not only — не только SQL. Некоторые NoSQL-базы все-таки предлагают похожий на SQL язык и концепцию таблиц (например, Cassandra).
Решения NoSQL крайне разнообразны. Прежде всего это простые хранилища ключей и значений. Свои скромные возможности они компенсируют скоростью доступа и репликацией по многим узлам.
Некоторые базы рассчитаны на хранение документов. От хранилищ “ключ-значение” они отличаются тем, что значение, во-первых, объемно (занимает килобайты и мегабайты), а во-вторых – структурировано. Документ хранится не строкой, а в виде дерева или набора “путь-значение”. Рассмотрим следующий документ:
{
"id": "123456",
"title": "Some Document",
"attrs": {
"code": 662234,
"created_at": "2025-01-22",
"users": [
{
"id": 1,
"email": "user1@test.com"
},
{
"id": 2,
"email": "user2@test.com"
}
]
}
}
При вставке он раскладывается на пары “путь->значение”:
id -> "123456"
title -> "Some Document"
attrs.code -> 662234
attrs.created_at -> 2025-01-22
attrs.users.0.id -> 1
attrs.users.0.email -> user1@test.com
attrs.users.1.id -> 2
attrs.users.1.email -> user2@test.com
Чтобы найти документы по атрибутам, для некоторых путей строят индекс. В запросах используется либо доменный язык (DSL), либо выражение пути, аналог XPath для XML. Все это мы рассмотрим в последующих главах.
База данных Datomic и аналоги (например, XTDB) хранят документы в виде кортежей EAV – entity, attribute, value, что означает “сущность”, “атрибут”, “значение”. В реляционной модели мы бы выразили EAV таблицей:
create table eav (
e integer,
a text,
v text
);
Достоинство модели EAV в том, ей можно описать что угодно: пользователей, профили, товары, заказчиков и так далее. Все это поместиться в одной таблице! Подобно двоичной системе счисления, EAV — своего рода рубеж: далее упростить модель невозможно.
Именно в минимализме EAV проявляется красота этой тройки. Ниже мы заполняем таблицу данными: это пользователи и профили из задачи выше. Каждая группа кортежей относятся к одной сущности:
insert into eav values
(10001, 'user/name', 'Ivan'),
(10001, 'user/age', '14'),
(10002, 'user/name', 'John'),
(10002, 'user/age', '34'),
(10003, 'user/name', 'Juan'),
(10003, 'user/age', '51'),
(10004, 'profile/user-ref', '10001'),
(10004, 'profile/job', 'teacher'),
(10004, 'profile/is-open', 'true'),
(10005, 'profile/user-ref', '10002'),
(10005, 'profile/job', 'programmer'),
(10005, 'profile/is-open', 'false'),
(10006, 'profile/user-ref', '10003'),
(10006, 'profile/job', 'tester'),
(10006, 'profile/is-open', 'true');
Пользователи и профили хранятся в одной таблице и не конфликтуют друг с другом. Однако для SQL подобный минимализм неудобен: он усложняет запросы. Чтобы выбрать пользователей старше 18 лет, открытых к предложениям, понадобится каскад подзапросов и соединений. Маловероятно, что запрос ниже понятен без погружения в контекст:
select *
from eav
where e in (
select v::integer
from eav
where a = 'profile/user-ref'
and e in (
select e from eav where a = 'profile/is-open' and v = 'true'
)
intersect
select e
from eav
where a = 'user/age' and v::integer > 18
);
Напротив, в реляционной модели он удивительно прост:
select * from users u
join profiles p on u.id = p.user_id
where u.age > 18 and p.is_open;
Для работы с EAV служит особый язык запросов, основанный на Datalog. В свою очередь Datalog – подмножество языка Prolog, созданного для логических задач. Язык опирается на сопоставления и правила. Приведем код на Clоjure, где мы работаем с Datomic. Для начала запишем пользователей с профилями:
(dеf data
[{:db/id "user1" :user/name "Ivan" :user/age 14}
{:db/id "user2" :user/name "John" :user/age 34}
{:db/id "user3" :user/name "Juan" :user/age 51}
{:profile/user-ref "user1" :profile/job "teacher" :profile/is-open true}
{:profile/user-ref "user2" :profile/job "programmer" :profile/is-open false}
{:profile/user-ref "user3" :profile/job "tester" :profile/is-open true}])
(d/transact conn {:tx-data data})
Выберем пользователей старше 18 лет, открытых к предложениям:
(dеf query
'[:find (pull ?u [*])
:in $
:where
[?u :user/age ?age]
[(> ?age 18)]
[?p :profile/user-ref ?u]
[?p :profile/is-open true]])
(d/q query (d/db conn))
[[{:db/id 96757023244368, :user/name "Juan", :user/age 51}]]
Как и в случае с SQL, запрос на Datalog занял меньше строк, чем код на Clоjure. Специализированное решение – всегда более емкое.
Переход с SQL на EAV требует серьезной перестройки мышления. И хотя Datomic не стал популярен как реляционные базы, он вызывал своего рода ренессанс Datalog. Появились его различные клоны: XTDB, Datascript, Datalevin и другие, каждый со своими особенностями.
Некоторые решения NoSQL абстрагируются от хранения данных. Скажем, Datomic использует Postgres или MySQL для чтения и записи EAV (наряду с этой тройкой он хранит два других атрибута, которые нас не интересуют). Упрощая, можно сказать, что Datomic — это огромное Btree-дерево, части которого хранятся в реляционной таблице. Также Datomic использует кластер Memcached, а его облачная версия — хранилище S3. Похоже устроена база XTDB: данные могут хранится в реляционных базах, файлах S3 и даже кластере Kafka.
Из всего разнообразия NoSQL нас интересуют базы, ориентированные на документы. Перечислим те, которые пользователь может установить на свой сервер (т.н. self-hosted решения):
-
MongoDB: производительная база данных, написанная на C++. Известна своей масштабируемостью и репликацией. Очень популярна в мире Node.js.
-
CouchDB: хранилище документов, написанное на Erlang. CouchDB интересен концепцией представлений (view). Последние устроены как комбинация шагов Map и Reduce — первичная выборка и ее свертка. Разработка CouchDB опирается на публикации Google о технологиях BigTable и MapReduce.
-
Cassandra: распределенная колоночная база на языке Java. Изначально нацелена на работу в кластере. Предлагает таблицы и SQL-подобный язык, хотя и более ограниченный, чем в реляционных аналогах.
К облачным решениям относятся:
-
DynamoDB: один из главных сервисов AWS, хранилище документов по ключу. Отличается высокой скоростью и масштабированием. Работает по протоколу REST JSON, что позволяет общаться с ней через обычный HTTP-клиент.
-
OpenSearch: еще одно решение AWS для индексации документов. Создан на базе ElasticSearch после того, как последний сменил лицензию. Формально OpenSearch не является облачным сервисом, но сложность его развертки столь высока, что в основном им пользуются как услугой облачных провайдеров.
-
YandexDB (YDB): база данных, созданная в Яндексе. Изначально использовалась для внутренних нужд, в настоящее время доступна как платный сервис. Работает по протоколу gRPC, а также поддерживает API DynamoDB. Последний фактор облегчает переезд из AWS в Яндекс.
Это неполный список, однако мы не ставим цель перечислить все решения для хранения документов. Важнее другое: что представляют собой документы? Где они встречаются и почему их выбирают вместо реляционных таблиц?
Знакомство с документами
Документ — это набор фактов о сущности. Договор с поставщиком, заявка на кредит, история болезни – все это документы. Приведем пример в формате JSON:
{
"id": "123456",
"title": "Some Document",
"attrs": {
"code": 662234,
"created_at": "2025-01-22",
"users": [
{
"id": 1,
"email": "user1@test.com"
},
{
"id": 2,
"email": "user2@test.com"
}
]
}
}
У документа есть уникальный ключ, чаще всего — UUID (уникальный идентификатор). Для генерации UUID служат разные алгоритмы; позже мы обсудим, какой из них предпочесть. Важно, что документы плохо совместимы с нарастающим счетчиком, принятым в реляционных базах. Это связано с тем, что номер документа должен быть глобальным в рамках всей базы, а не только в разрезе сущности.
Предположим, мы знаем номер документа — 123. Этого недостаточно, чтобы его извлечь. Нужно знать сущность: users, orders и так далее. Ключ документа становится парой (users, 123), и эту пару нужно где-то хранить. По своей природе пара сложнее, чем один элемент.
Другой довод в пользу UUID в том, что его производят разные участники. Когда вы создаете документ, UUID может произвести Postgres, приложение (Java, Python и так далее), очередь задач или другой узел инфраструктуры. Нужно лишь, чтобы везде использовался одинаковый алгоритм, например UUID4 или UUID7. Особенности последнего мы обсудим чуть позже.
Числовые ключи, напротив, лишены описанного преимущества. За их производство отвечает строго один узел – база данных. Другие участники не могут назначить документу номер, потому что нет гарантии, что он не появился в промежутке между проверкой и генерацией. Как мы упоминали, документные базы работают на нескольких узлах в кластере. В таких условиях сложно производить уникальные возрастающие числа.
Мы уделили ключам столько внимания, чтобы читатель запомнил: при работе с документами используйте уникальные идентификаторы. У сущности может быть числовой номер, но чаще всего он служит для второстепенных целей. Например:
-
историческая: документы перенесли из реляционной базы данных, где у них были числовые ключи – “айдишники”. В системе остался API, который до сих пор обращается к данным по этому полю, например
GET /users/123. В этом случае мы поддерживаем совместимость с API. -
прикладная: в финансах и бухгалтерии приняты свои системы нумерации документов. Менеджеру удобно, когда у заявок на кредит числовые номера, уникальные в рамках года. Мы согласны поддерживать такой атрибут обеспечить его уникальность; однако это именно атрибут, а не первичный ключ.
Кроме ключа, у документа есть метаданные: даты создания и последнего изменения, версия, автор, принадлежность к какому-либо домену, набор тегов.
Тело документа содержит атрибуты – пары “путь -> значение”. Значения могут быть вложенными, например словарем внутри словаря или списком словарей, в каждом из которых другой список.
Приведем несколько документов. В первом метаданные и атрибуты собраны вместе, и не совсем ясно, что есть что:
{
"id": "c2a44fe0-6840-4916-b6dd-b401f34f9c2a",
"type": "contract",
"number": 523552,
"created_at": "2025-01-01",
"created_by": {
"id": "099f2886-9772-4128-a5cd-b648369ce3f0",
"email": "some.user@test.com"
},
"tags": [
"risk",
"contract",
"acme"
],
"expires_at": "2027-12-31",
"amount": 100000000
}
Вариант, где метаданные собраны в отдельный словарь, выглядит удобнее:
{
"id": "c2a44fe0-6840-4916-b6dd-b401f34f9c2a",
"meta": {
"type": "contract",
"created_at": "2025-01-01",
"created_by": {
"id": "099f2886-9772-4128-a5cd-b648369ce3f0",
"email": "some.user@test.com"
},
"tags": [
"risk",
"contract",
"acme"
]
},
"number": 523552,
"expires_at": "2027-12-31",
"amount": 100000000
}
Иногда атрибуты выносят в отдельный словарь. На вершине документа остаются поля id, meta и attrs:
{
"id": "c2a44fe0-6840-4916-b6dd-b401f34f9c2a",
"meta": {
"type": "contract",
"created_at": "2025-01-01",
"created_by": {
"id": "099f2886-9772-4128-a5cd-b648369ce3f0",
"email": "some.user@test.com"
},
"tags": [
"risk",
"contract",
"acme"
]
},
"attrs": {
"number": 523552,
"expires_at": "2027-12-31",
"amount": 100000000
}
}
Перейдем к другому вопросу: чем документ отличается от реляционных таблиц? Для этого рассмотрим документ: договор со ссылкой на контрагента:
{
"id": "c2a44fe0-6840-4916-b6dd-b401f34f9c2a",
"number": 523552,
"created_at": "2025-01-01",
"expires_at": "2027-12-31",
"contractor": {
"id": "bd6a0da9-fe35-4a37-846c-dff3f32cf0ac",
"title": "Acme Inc"
},
"amount": 100000000,
"currency": "EUR"
}
Разложим его на две таблицы: контрагенты и договоры:
create table contractors (
id uuid primary key,
title text
);
create table contract (
id uuid primary key,
number integer,
created_at date,
expires_at date,
contractor uuid references contractors(id),
amount integer,
currency text
);
insert into contractors values (
'bd6a0da9-fe35-4a37-846c-dff3f32cf0ac',
'Acme Inc'
);
insert into contract values (
'c2a44fe0-6840-4916-b6dd-b401f34f9c2a',
523552,
'2025-01-01',
'2027-12-31',
'bd6a0da9-fe35-4a37-846c-dff3f32cf0ac',
100000000,
'EUR'
);
Одним запросом мы получим обе сущности:
select * from contract c, contractors cs
where c.contractor = cs.id;
┌──────────────────────────────────────┬────────┬────────────┬────────────┬──────────────────────────────────────┬───────────┬──────────┬──────────────────────────────────────┬──────────┐
│ id │ number │ created_at │ expires_at │ contractor │ amount │ currency │ id │ title │
├──────────────────────────────────────┼────────┼────────────┼────────────┼──────────────────────────────────────┼───────────┼──────────┼──────────────────────────────────────┼──────────┤
│ c2a44fe0-6840-4916-b6dd-b401f34f9c2a │ 523552 │ 2025-01-01 │ 2027-12-31 │ bd6a0da9-fe35-4a37-846c-dff3f32cf0ac │ 100000000 │ EUR │ bd6a0da9-fe35-4a37-846c-dff3f32cf0ac │ Acme Inc │
└──────────────────────────────────────┴────────┴────────────┴────────────┴──────────────────────────────────────┴───────────┴──────────┴──────────────────────────────────────┴──────────┘
Так зачем усложнять жизнь документами, если есть таблицы? Причин может быть несколько, и мы опишем их в отдельном параграфе.
Доводы в пользу документов
Преимущества документов очевидны, если выдвинуть следующие требования.
Неоднородность. В реляционных базах каждая таблица имеет структуру, которой подчиняются все записи. Добавление новой колонки, особенно к большим таблицам, считается трудоемкой задачей, и этого стараются избегать. Документы, напротив, обладают нечеткой структурой. Считается нормой, когда у двух документов почти одинаковый состав полей, но разница все-таки есть.
Покажем это на примере. Предположим, система ведет договоры между участниками. Бухгалтерские реалии таковы, что в зависимости от типа участников структура договора меняется. Договоры между двумя обществами, обществом и ИП, ИП и самозанятым различаются кардинально. Часть полей будет общей, но будет и немало других, особых для каждого случая.
В приложении строят каскад схем. Сперва определяют базовую схему, которой соответствует любой документ. В ней содержатся поля id, meta и attrs. Запишем ее на псевдокоде:
Type = Enum(user, profile, contract, ...)
Meta = {
type: Type,
created_at: Date,
created_by: UserRef,
tags: List<String>
}
Attrs = Dict<String, Any>
Document = {
id: UUID,
meta: Meta,
attrs: Attrs
}
На ее основе определяют договор. Считаем, что условная функция merge рекурсивно объединяет две схемы:
ContractBase = merge(Document, {
attrs: {
amount: Integer,
currency: EnumCurrency,
contractor: ContractorRef
}
})
От общего договора наследуют конкретный случай: договор между организацией и индивидуальным предпринимателем:
ContractOrgIp = merge(ContractBase, {
attrs: {
ip: IPRef,
tenor: TenorSpec,
departments: List<DepartmentRef>
}
})
Другой случай – договор между двумя организациями. Он включает структуру рисков и третью сторону, которая берет их на себя:
ContractOrgOrg = merge(ContractBase, {
attrs: {
org: OrgRef,
risks: List<RiskTable>,
risk_taker: RiskTakerRef
}
})
В зависимости от типа участников договор проверяют нужной схемой: ContractOrgIp
или ContractOrgOrg. Каждую схему можно расширить: добавить сроки рисков,
комментарии, структуру расчетов.
Следующая причина, когда документы удобны – вложенность. Пока атрибуты плоские (не превышают одного уровня вложенности), их можно хранить в таблице. Но иногда появляются словари и списки словарей. Ниже заявка на кредит хранит пользователей, которые над ней работали:
{
"id": "6e394e79-2d71-47ae-a314-534fd9a10719",
"amount": 10000,
"currency": "USD",
"users": [
{
"id": "418d4488-507f-4ad4-86a3-f1c1e43f4b69",
"email": "ivan@test.com"
},
{
"id": "9f3d6c5a-02b3-45cf-b256-699fe91aab59",
"email": "anna@test.com"
},
{
"id": "66250983-1a11-421d-86b8-753384bf4845",
"email": "elena@test.com"
}
]
}
Хранить вложенный массив в плоской таблице неудобно. Обычно поступают так: таблица application хранит скалярные (отличные от коллекций) атрибуты. Коллекции выносят в смежные таблицы и связывают их таблицами-мостами:
┌────────────────────────────────────────────────────────────────────────────────────────────┐
│┌──────────────────────────┐ ┌──────────────────────────────────────┐│
││ applications │ │ users ││
│├────────┬────────┬────────┤ ├────────┬───────────────┬─────────────┤│
││ id │ amount │currency│ │ id │ email │ full_name ││
│├────────┼────────┼────────┤ ├────────┼───────────────┼─────────────┤│
││...10719│ 10000│ USD│ ┌─▶│...3f4b6│ ivan@test.com│ Ivan Testov││
│└────────┴────────┴────────┘ │ ├────────┼───────────────┼─────────────┤│
│ ▲ │ ▲│...aab59│ anna@test.com│ Anna Testova││
│ │ ┌─────────────────────┐ │ │├────────┼───────────────┼─────────────┤│
│ │ │ application_users │ │ ││...f4845│ elena@test.com│Elena Testova││
│ │ ├────────────┬────────┤ │ │└────────┴───────────────┴─────────────┘│
│ │ │document_id │user_id │──┘ │ ▲ │
│ │ ├────────────┼────────┤ │ │ │
│ ├──────────────────│ ...10719│...3f4b6│────┘ │ │
│ │ ├────────────┼────────┤ │ │
│ ├──────────────────│ ...10719│...aab59│ │ │
│ │ ├────────────┼────────┤ │ │
│ └──────────────────│ ...10719│...f4845│──────────┘ │
│ └────────────┴────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────┘
Проблема в том, что у пользователя тоже могут быть вложенные поля, например список ролей или отделов, которым он принадлежит. Понадобится таблица application_users_roles или application_users_departments. Структура базы усложняется: чем больше вложенность документа, тем сложнее каскад таблиц. Если на каком-то уровне возникнет ошибка, ее тяжело расследовать.
Следующей причиной служит версионирование. Схема документа меняется со временем: сперва поле было числом, но выяснилось, что в нем могут быть символы алфавита. В другом поле была ссылка на документ, но стало ясно, что вместе с ссылкой нужно хранить тип сущности. Без типа трудно понять, в какой таблице искать ссылку. Вот как выглядит документ до изменений:
{
"id": "...",
"amount": 10000,
"contractor": "e093c3c8-54c8-4375-a964-4a5de09ef3d0",
"currency": "RUB"
}
и после:
{
"id": "...",
"amount": 10000,
"contractor": {
"ref": "e093c3c8-54c8-4375-a964-4a5de09ef3d0",
"entity": "contractor"
},
"currency": "RUB"
}
Чтобы работа с документами не превратилась в хаос, вводят версии схем. Версию хранят в метаданных и используют в операторах if или case при работе с полем. В примере ниже функция принимает документ и возвращает ссылку на контрагента:
(dеfn contractor-ref [doc]
(let [version (-> doc :meta :version)]
(cond
(>= version 2)
(-> doc :contractor :ref)
:else
(-> doc :contractor))))
Логика сводится к оператору cond: он находит первое истинное условие и
возвращает следующую за ним форму. Если версия документа больше или равна двум,
путь к ссылке учитывает :ref. В противном случае сработает форма :else.
Предположим, с версии 5 структура ссылки опять поменялась:
{
"meta":{
"version":5
},
"contractor":{
"ref":{
"id":"...",
"type":":contractor"
}
}
}
Чтобы это учесть, добавим в cond новую пару:
(dеfn contractor-ref [doc]
(let [version (-> doc :meta :version)]
(cond
(>= version 5)
(-> doc :contractor :ref :id)
(>= version 2)
(-> doc :contractor :ref)
:else
(-> doc :contractor))))
Реляционные базы не позволяют делать то же самое на уровне колонок. Если ссылка хранится в поле с типом uuid, в нее нельзя записать лишние данные: понадобится новая колонка.
Еще одно удобство документных баз в том, что, как правило, они предлагает удобные CRUD-операции. Реляционные базы, напротив, перекладывают их на ваши плечи.
Когда вы подключаетесь к документной базе из приложения, вам доступен объект
Connection или Client. Он предлагает методы .create, .update,
get_by_id, delete_by_id и другие. Вот как выглядит жизненный цикл документа
на псевдокоде:
client = Client.connect(host, port, ...)
doc = {
meta: {
created_at: now(),
created_by: user.id
},
attrs: {
amount: 10000,
currency: "USD"
}
}
client.save(doc)
doc.update({"attrs": {"amount": 10550}})
doc.delete()
Выше мы создали документ, обновили вложенное поле и удалили его. Разработчик ничего не знает про внутреннее устройство базы. Работа с ней сводится к вызову методов, что очень удобно.
Реляционные базы не предлагают таких удобств. Разработчику доступен метод .execute, который ожидает запрос и параметры. Что именно в этом запросе — остается на ваше усмотрение. Чтобы извлечь платеж по ID, мы пишем SELECT:
select * from payments where id = ?
Чтобы удалить — DELETE:
delete from payments where id = ?
Разработчик должен помнить об инъекциях, избегать конкатенации строк для построения SQL, помнить операторы и многое другое.
Выше таблица не может быть параметром. Выражения select * from users и
select * from orders – это разные запросы. Если разработчик хочет
универсальную функцию, которая принимает имя таблицы и ID, ее пишут отдельно с
учетом небезопасных символов (экранирования). Некоторые таблицы квалифицированы,
то есть включают схему: не просто payments, а prod_analytics.payments. В
этом случае обе части экранируются отдельно:
select * from "prod_analytics"."payments" ...
Документные базы используют словари для фильтрации. Чтобы выбрать документы по нескольким признакам, передают словарь атрибут => значение (по умолчанию они объединяются оператором AND). Если нужен еще один критерий отбора, в словарь добавляют поле, и задача решена:
client.find("payments", {
amount: 10000,
currency: "USD",
created_by: 153523
})
В реляционных базах так не получится. В них фильтрация по двум и трем параметрам — это разные запросы. Они отличаются даже не числом параметров, а синтаксисом:
select * from payments where amount = 10000
select * from payments where amount = 10000 and currency = 'USD'
В итоге, даже скачав библиотеку для Postgres, разработчик не может приступить к работе. Требуется некий “обвес”, вспомогательный код, который берет на себя вставку данных, выборку, удаление и остальное. Поэтому так популярны ORM: они служат посредником между базой и приложением. ORM действительно помогает на старте: легче начать проект, новички быстрее пишут код. У этой медали другая сторона: иногда ORM посылает неоптимальные или излишние запросы, но разработчик не в курсе этого.
Задачи, которые решают документы
Разберем теперь, как фирмы приходят к документо-ориентированным базам данных: какие факторы подталкивают их к тому, чтобы платить за кластер MongoDB или облачный сервис DynamoDB. Речь пойдет о реальных, а не выдуманных случаях.
Проблема типов. Некоторые данные с трудом ложаться на реляционную модель из-за разнообразия типов. Следующий пример это иллюстрирует.
Предположим, мы храним товары в Postgresql или MySQL. У товара есть код, название, цена, ссылка на продавца и другие поля, свойственные всем товарам. Но как быть с полями, которые относятся к особым видам товаров? Для ноутбука это диагональ экрана, наличие портов и частота памяти. Для одежды — материалы, длина рукава, обхват талии. Для спортивного питания — калорийность, доля жиров, белков, углеводов, масса одной порции, число порций в упаковке и так далее.
Приведем характеристики случайных товаров с маркетплейсов. Игровой ноутбук:
Процессор..................Intel Core 7 240H
Частота процессора.........2500
Ядер процессора............10
Тип памяти.................DDR5
Частота памяти.............5600
Наличие микрофона..........да
Беспроводные интерфейсы....Bluetooth, Wi-Fi
Интерфейсы.................Ethernet, USB 3.2 Gen2 Type A x 2
Футболка:
Цвет товара.........черный
Стиль...............повседневный
Декор...............отсутствует
Размер..............L/XL
Спортивное питание:
Количество в упаковке.....12 штук
Объем.....................1400
Общий вес.................800
Диетические особенности...без глютена, без сахара
Проблема характеристик в том, что их много. Если вынести каждую их них в колонку, мы упремся в ограничение базы на их число. (В скобках отметим, что максимальное число колонок в Postgres — 1600, но на самом деле эта цифра зависит от состава типов. Если точнее, длина записи не может превышать 8 килобайт – максимальный размер страницы. Ограничение можно обойти, собрав Postgresql из исходного кода с заменой константы blocksize, однако маловероятно, что читатель пойдет на эти меры.)
Массовые колонки плохи тем, что для большей части товаров они пустуют. Представим таблицу, где для каждой характеристики создана колонка. Вот как выглядят данные:
| id | title | laptop.cpu_count | laptop.mem_freq | cloth.color | cloth.size | food.box_mass | food.diet_note |
|------+------------------+------------------+-----------------+-------------+------------+---------------+----------------|
| 1001 | Razor Flare 18 | 10 | 5600 | | | | |
| 1002 | MSI Katana 13 XH | 12 | 3200 | | | | |
| 2001 | T-Shirt White | | | white | L,X,XL | | |
| 2002 | Shirt Pop Blue | | | blue | XXL,S | | |
| 3001 | Mega Snack Plus | | | | | 1400 | no gluten |
| 3002 | Protein Bomb | | | | | 1200 | no sugar |
Характеристики ноутбуков для всех товаров, кроме ноутбуков, будут пустыми; то же самое относится к другим колонкам. Таблица превратилась в разреженную матрицу, большую часть которой занимает пустота. Хранение пустоты обходится дорого, и мы должны ее избегать.
Поскольку рост по горизонтали нам закрыт, пойдем по вертикали. Мы уже знакомы с
моделью EAV, и здесь она весьма кстати. Добавим таблицу item_props с колонками
item_id, property и value. В них записаны ссылки на товары, имена характеристик
и значения:
create table items(
id integer primary key,
title text
);
create table item_props(
item_id integer references items(id),
property text,
value text
);
Подготовим товары: два ноутбука, две футболки и два батончика:
insert into items values
(1001, 'Razor Flare 18'),
(1002, 'MSI Katana 13 XH'),
(2001, 'T-Shirt White'),
(2002, 'Shirt Pop Blue'),
(3001, 'Mega Snack Plus'),
(3002, 'Protein Bomb');
Назначим характеристики:
insert into item_props values
(1001, 'laptop.cpu_count', '10'),
(1002, 'laptop.mem_freq', '5600'),
(1002, 'laptop.cpu_count', '12'),
(1002, 'laptop.mem_freq', '3200'),
(2001, 'cloth.color', 'white'),
(2001, 'cloth.size', 'L,X,XL'),
(2002, 'cloth.color', 'blue'),
(2002, 'cloth.size', 'XXL,S'),
(3001, 'food.box_mass', '1400'),
(3001, 'food.diet_note', 'no gluten'),
(3002, 'food.box_mass', '1200'),
(3002, 'food.diet_note', 'no sugar');
Чтобы выбрать товары с характеристиками, соединим таблицы оператором JOIN:
select * from items, item_props
where items.id = item_props.item_id;
┌──────┬──────────────────┬─────────┬──────────────────┬───────────┐
│ id │ title │ item_id │ property │ value │
├──────┼──────────────────┼─────────┼──────────────────┼───────────┤
│ 1001 │ Razor Flare 18 │ 1001 │ laptop.cpu_count │ 10 │
│ 1002 │ MSI Katana 13 XH │ 1002 │ laptop.mem_freq │ 5600 │
│ 1002 │ MSI Katana 13 XH │ 1002 │ laptop.cpu_count │ 12 │
│ 1002 │ MSI Katana 13 XH │ 1002 │ laptop.mem_freq │ 3200 │
│ 2001 │ T-Shirt White │ 2001 │ cloth.color │ white │
│ 2001 │ T-Shirt White │ 2001 │ cloth.size │ L,X,XL │
│ 2002 │ Shirt Pop Blue │ 2002 │ cloth.color │ blue │
│ 2002 │ Shirt Pop Blue │ 2002 │ cloth.size │ XXL,S │
│ 3001 │ Mega Snack Plus │ 3001 │ food.box_mass │ 1400 │
│ 3001 │ Mega Snack Plus │ 3001 │ food.diet_note │ no gluten │
│ 3002 │ Protein Bomb │ 3002 │ food.box_mass │ 1200 │
│ 3002 │ Protein Bomb │ 3002 │ food.diet_note │ no sugar │
└──────┴──────────────────┴─────────┴──────────────────┴───────────┘
Таблицу item_props можно заполнять бесконечно, не беспокоясь о числе
характеристик. Если ноутбуку понадобится новое свойство (объем видеопамяти), это
будет очередная строка в таблице:
insert into item_props values
(1001, 'laptop.video_mem', '8 Gb');
Наш дизайн не учитывает, что у характеристик разные типы. Частота памяти — это число (2666 герц), размер одежды — перечисление (L, XL), наличие bluetooth — логический флаг (есть или нет). Иные значения могут быть коллекциями, например версии кодеков или протоколы — это списки строк или чисел.
В таблице item_props колонка value носит строковый тип. Это компромисс, на
который мы пошли, чтобы хранить в ней любое значение. В целом это неудобно,
особенно если требуется поиск по характеристикам: придется приводить текст к
числу, булеву и другим типам.
Нам поможет тип jsonb, который вмещает другие типы: строки, числа, а также коллекции. Назначим полю value тип jsonb, и теперь оно может хранить любые значения. Проделаем это на новой таблице:
create table item_props_json(
item_id integer references items(id),
property text,
value jsonb
);
insert into item_props_json values
(1001, 'laptop.cpu_count', '10'),
(1002, 'laptop.mem_freq', '5600'),
(1002, 'laptop.cpu_count', '12'),
(1002, 'laptop.mem_freq', '3200'),
(2001, 'cloth.color', '"white"'),
(2001, 'cloth.size', '["L","X","XL"]'),
(2002, 'cloth.color', '"blue"'),
(2002, 'cloth.size', '["XXL","S"]'),
(3001, 'food.box_mass', '1400'),
(3001, 'food.diet_note', '"no gluten"'),
(3002, 'food.box_mass', '1200'),
(3002, 'food.diet_note', '"no sugar"');
Обратите внимание, что строки заключаются в двойные кавычки, потому что этого требует стандарт JSON. Выберем данные еще раз:
select * from items, item_props_json
where items.id = item_props_json.item_id;
┌──────┬──────────────────┬─────────┬──────────────────┬──────────────────┐
│ id │ title │ item_id │ property │ value │
├──────┼──────────────────┼─────────┼──────────────────┼──────────────────┤
│ 1001 │ Razor Flare 18 │ 1001 │ laptop.cpu_count │ 10 │
│ 1002 │ MSI Katana 13 XH │ 1002 │ laptop.mem_freq │ 5600 │
│ 1002 │ MSI Katana 13 XH │ 1002 │ laptop.cpu_count │ 12 │
│ 1002 │ MSI Katana 13 XH │ 1002 │ laptop.mem_freq │ 3200 │
│ 2001 │ T-Shirt White │ 2001 │ cloth.color │ "white" │
│ 2001 │ T-Shirt White │ 2001 │ cloth.size │ ["L", "X", "XL"] │
│ 2002 │ Shirt Pop Blue │ 2002 │ cloth.color │ "blue" │
│ 2002 │ Shirt Pop Blue │ 2002 │ cloth.size │ ["XXL", "S"] │
│ 3001 │ Mega Snack Plus │ 3001 │ food.box_mass │ 1400 │
│ 3001 │ Mega Snack Plus │ 3001 │ food.diet_note │ "no gluten" │
│ 3002 │ Protein Bomb │ 3002 │ food.box_mass │ 1200 │
│ 3002 │ Protein Bomb │ 3002 │ food.diet_note │ "no sugar" │
└──────┴──────────────────┴─────────┴──────────────────┴──────────────────┘
Так выглядят данные в терминале, однако в приложении результат может отличаться. Библиотеки для работы с базой автоматически парсят значения json(b), и они становятся нативными типами приложения: числами, строками, словарями и так далее.
В некоторых языках, например в Java, нет встроенного JSON-парсера. Драйвер JDBC
не преобразует колонку jsonb в значение JVM. Вместо этого он вернет объект
PGObject с JSON-строкой, и как с ней работать – остается на ваше
усмотрение. Различные ORM предлагают JSONField – класс, который кодирует и
декодирует данные, используя стороннюю библиотеку. В других языках, например
Clоjure, можно расширить типы протоколом – соглашением о том, как упаковать их в
JSON и прочитать обратно.
Решение с характеристиками можно улучшить. Вместо того, чтобы хранить их по отдельности, построим словарь вида характеристика -> значение. В этом случае таблица item_props не понадобится: словарь хранится в самом товаре. Пример:
drop table item_props;
drop table item_props_json;
alter table items add column props jsonb;
update items set props = $$
{"laptop.cpu_count": 10, "laptop.mem_freq": 5600}
$$ where id = 1001;
update items set props = $$
{"cloth.color": "white", "cloth.size": ["L","X","XL"]}
$$ where id = 2001;
Мы удалили таблицы характеристик, добавили товару колонку props и записали в нее свойства двух товаров. На практике такие изменения автоматизируют: пишут запрос, который “схлопывает” характеристики и значения в словари и переносит в товары. В следующих главах мы рассмотрим эту технику.
Попутно решается еще одна деталь: объект JSON исключает повторы ключей, поэтому не получится хранить две характеристики с одинаковым именем. В таблице item_props мы бы создали уникальный индекс для комбинации (item_id, property).
Словарь характеристик, хоть и ослабляет структуру базы, оказывается удачным решением. Да, поле JSON – черный ящик, который хранит что угодно. Да, перед записью вы обязаны проверить, что props – словарь, а не число или список. Да, тип jsonb нарушает нормальные формы, которым учат в университетах. Однако упрощение, которое дает jsonb, в конечном счете перевешивает недостатки. Мы получили меньше таблиц и меньше кода. Задача решена простым и понятным способом.
Сложная структура. Иногда сервис получает уведомления платежных систем: Paypal, Stripe, App Store. Чтобы проверить покупку, из уведомления берут коды пользователя и товара, сумму, номер транзакции. Эти поля записывают в базу и показывают в личном кабинете. Перед обработкой уведомления проверяют, что транзакция с таким номером еще не встречалась.
На практике уведомления содержат больше четырех полей. Так, служба Paypal IPN (Instant Payment Notification, мгновенные уведомления о платежах) передает почти сорок полей, среди которых:
- код уведомления;
- код транзакции;
- код покупателя;
- восемь полей адреса (страна, город, улица и так далее);
- подробные данные о платеже: валюта цены, валюта оплаты, курс конвертации, комиссия Paypal, сумма с ней и без нее, отдельная цена доставки;
- иные служебные поля: тип операции, статус клиента, статус платежа, кодировка, версия протокола.
То же самое относится к App Store: сервера Apple присылают огромные документы, структура которых описана в документации.
Чаще всего разработчики читают только те поля, что необходимы для проверки платежа. Остальное отбрасывают за ненадобностью: у разработчика нет времени, чтобы адаптировать базу данных. В самом деле: чтобы хранить уведомления Paypal в таблице, понадобится 40 колонок. Много времени уйдет на то, чтобы разложить документ на поля и правильно записать их. Позже формат уведомлений может измениться, и мы получим ошибку из-за несовместимых типов. Досадно, когда уведомление пришло, но не обработалось по нашей вине.
Решение в том, чтобы выбрать из уведомления только главные поля, а остальные сохранить в поле jsonb. Этот вариант устойчив к ситуации, когда структура уведомления меняется.
Зачем хранить уведомления? Одна из причин в том, что они полезны для отчетов и аналитики. Например, руководитель хочет знать, какую услугу чаще всего покупали в такой-то стране в определенный период. Менеджер запросит отчет продаж в разрезе категорий товаров. Имея данные в базе, пусть даже в виде jsonb, эти потребности легко удовлетворить.
Формально платежные сервисы предлагают отчеты в личном кабинете, но их качество желает лучшего. Сервисы не предлагают группировку, а выгружают только плоские записи. Ожидается, что их передадут программисту, который сгруппирует должным образом. Некоторые сервисы поддерживают группировку, но диапазон дат не может быть больше трех месяцев. Чтобы собрать отчет за прошлый год, понадобятся четыре запроса: с декабря по февраль, с марта по май и так далее, после чего результаты объединяют.
Локальные данные решают эту проблему. Разумеется, время от времени проводят сверку с платежным сервисом, чтобы выявить расхождения. Однако в плане отчетности вы не зависите от него: не нужно вызывать API, чтобы показать историю платежей или продажи в определенной стране за месяц. Технически это решается хранением документов в поле jsonb.
Стандарты. Иногда бизнес опирается на стандарты — соглашения о том, как обмениваться данными. Одним из примеров служит FHIR (Fast Healthcare Interoperability Resources) — стандарт обмена медицинской информацией в США и других странах. FHIR определяет сущности, принятые в медицинской сфере: пациент, клиника, анализы, история болезни. Также он описывает спецификацию REST-сервера, который их хранит и обрабатывает.
Сущности выглядят как огромные JSON-документы. Приведем пример пациента с официального сайта:
{
"resourceType" : "Patient",
"id" : "example",
"identifier" : [{
"use" : "usual",
"type" : {
"coding" : [{
"system" : "http://terminology.hl7.org/CodeSystem/v2-0203",
"code" : "MR"
}]
},
"system" : "urn:oid:1.2.36.146.595.217.0.1",
"value" : "12345",
"period" : {
"start" : "2001-05-06"
},
"assigner" : {
"display" : "Acme Healthcare"
}
}],
"active" : true,
"name" : [{
"use" : "official",
"family" : "Chalmers",
"given" : ["Peter",
"James"]
},
{
"use" : "usual",
"given" : ["Jim"]
},
{
"use" : "maiden",
"family" : "Windsor",
"given" : ["Peter",
"James"],
"period" : {
"end" : "2002"
}
}],
"telecom" : [{
"use" : "home"
},
{
"system" : "phone",
"value" : "(03) 5555 6473",
"use" : "work",
"rank" : 1
},
{
"system" : "phone",
"value" : "(03) 3410 5613",
"use" : "mobile",
"rank" : 2
},
{
"system" : "phone",
"value" : "(03) 5555 8834",
"use" : "old",
"period" : {
"end" : "2014"
}
}],
"gender" : "male",
"birthDate" : "1974-12-25",
"_birthDate" : {
"extension" : [{
"url" : "http://hl7.org/fhir/StructureDеfinition/patient-birthTime",
"valueDateTime" : "1974-12-25T14:35:45-05:00"
}]
},
"deceasedBoolean" : false,
"address" : [{
"use" : "home",
"type" : "both",
"text" : "534 Erewhon St\nPeasantVille, Rainbow, Vic 3999",
"line" : ["534 Erewhon St"],
"city" : "PleasantVille",
"district" : "Rainbow",
"state" : "Vic",
"postalCode" : "3999",
"period" : {
"start" : "1974-12-25"
}
}],
"contact" : [{
"relationship" : [{
"coding" : [{
"system" : "http://terminology.hl7.org/CodeSystem/v2-0131",
"code" : "N"
}]
}],
"name" : {
"family" : "du Marché",
"_family" : {
"extension" : [{
"url" : "http://hl7.org/fhir/StructureDеfinition/humanname-own-prefix",
"valueString" : "VV"
}]
},
"given" : ["Bénédicte"]
},
"additionalName" : [{
"use" : "nickname",
"given" : ["Béné"]
}],
"telecom" : [{
"system" : "phone",
"value" : "+33 (237) 998327"
}],
"address" : {
"use" : "home",
"type" : "both",
"line" : ["534 Erewhon St"],
"city" : "PleasantVille",
"district" : "Rainbow",
"state" : "Vic",
"postalCode" : "3999",
"period" : {
"start" : "1974-12-25"
}
},
"additionalAddress" : [{
"use" : "work",
"line" : ["123 Smart St"],
"city" : "PleasantVille",
"state" : "Vic",
"postalCode" : "3999"
}],
"gender" : "female",
"period" : {
"start" : "2012"
}
}],
"managingOrganization" : {
"reference" : "Organization/1"
}
}
Опытный разработчик скажет, что документ можно разбить на таблицы. Технически это возможно; скорее всего, найдется ORM, которая поделит документ на таблицы patient, address, phone и другие, а при чтении – соберет обратно. Проблема в том, что подобных сущностей — полторы сотни (на момент написания книги — 157), и если допустить, что на каждую понадобятся три таблицы…
Нет смысла считать: ясно, что такая база будет слишком сложной. Почти вся она будет сгенерирована, и уместить ее в голове одному человеку невозможно. Факт, что однажды ее станет сложно поддерживать, является лишь вопросом времени. Поэтому документы хранят как есть — без дробления на таблицы в колонке jsonb. Скажем, таблица пациентов выглядит так:
create table patients (
id uuid primary key dеfault uuid_generate_v4(),
entity jsonb not null,
created_at timestamptz not null dеfault current_timestamp,
updated_at timestamptz
);
Особо важные свойства пациента можно вынести в колонки, чтобы ускорить к ним доступ. Однако чаще всего это решается индексами или вычисляемыми столбцами, которые мы рассмотрим в будущих главах.
Другая область, где используют документы — это игры. В пошаговых стратегиях, где ходы делают по очереди, состояние игры описано документом. Он содержит сведения об игроках, юнитах, их здоровье, статусах, предметах, очередности хода и многое другое. Состояние игры хранится в одном месте и читается одним запросом. Когда игрок делает ход, состояние извлекают из поля jsonb. К нему применяют накопленные ходы, записывают в базу новое состояние и рассылают игрокам события.
По такому принципу работала Глобальная Карта компании Wargaming, где когда-то работал автор. Состояние Карты хранилось в Postgresql в поле jsonb; от игроков принимались ходы. Каждый час запускалась задача, которая применяла ходы к состоянию, и таким образом шла игра.
Наконец, разработчик может столкнуться с документной базой в результате расширения фирмы. Банки и корпорации чаще покупают компании, чем создают их с нуля. Может случится так, что банк купил стартап для путешествий, который хранит данные в MongoDB. Сразу после покупки дают задачу на интеграцию с ним в других сервисах. Перенос стартапа на другой стек – дело сложное и полное сюрпризов, поэтому вы долго будете иметь дело с тем, что есть.
Причины, по которым выбирают документные базы в пользу реляционных, бывают самыми разными. Самое важное — разработчик не всегда на это влияет, и к этому следует быть готовым.
Слабые стороны документов
Мы привели некоторые достоинства документных баз. Пусть читатель не возводит их в абсолют: не бывает базы данных, хорошей абсолютно во всем. У каждой из них сильные и слабые стороны, и задача в том, чтобы выбрать решение, сильные стороны которого совпадают с нуждами проекта. Выбор сводится к торгу: соглашаемся на одно, терпим другое.
Перечислим слабые стороны документов, которые заметны в сравнении с реляционной моделью.
Первая и самая значимая особенность — отсутствие оператора JOIN. В SQL им соединяют таблицы по горизонтали. Скажем, если профиль ссылается на пользователя, можно извлечь обе сущности разом, соединив по ключам (первичному и внешнему). Это полезно в отчетах, выгрузке данных, а также когда мы работаем не с одним пользователем, а с тысячами.
Аргумент насчет тысяч записей особенно важен. Когда нужен один пользователь и
его профиль, обе записи легко выбрать по отдельности функцией get_by_id. Если
это отчет по всем пользователям, мы выполним два запроса: все пользователи и все
профили. Далее предстоит работа: профили индексируют, обходят пользователей,
получают по словарю профиль, фильтруют данные. Это долго, хрупко, требует кода и
тестирования.
Другими словами: разработчик делает то, что умеет реляционная база данных. Оператор JOIN выполнит то же самое, только на порядок быстрее, безопасней и без экранов кода.
Иные документные базы не имеют оператора JOIN в принципе. К ним относится Cassandra – распределенная база, написанная на Java. Проблему соединения в ней решают денормализацией. Если нужно соединение таблиц users и profiles, заводят таблицу users_profiles и пишут в нее те же данные. Поскольку данные обновляются в двух местах, это чревато расхождением и как следствие – странными результатами.
В MongoDB аналог JOIN называется $lookup. Этот оператор выбирает
сущности из смежной коллекции. Предположим, в MongoDB хранятся статьи и
комментарии. Ниже мы выбираем статьи, при этом к каждой из них подтягиваются
комментарии по условию post._id = comment.post_id (поле id предваряется
подчеркиванием, потому что оно системное).
db.posts.aggregate([
{
$lookup: {
from: "comments",
localField: "_id",
foreignField: "post_id",
as: "comments"
}
},
{
$limit: 100
}
])
В результате у каждой статьи поле comments с массивом комментариев.
Определенно, оператор $lookup — лучше, чем ничего. С ним задача упрощается: не
нужно соединять данные в приложении. Однако $lookup покрывает только левое
соединение (LEFT JOIN). Чтобы сделать его внутренним (исключить статьи без
комментариев), добавляют условие, что собранный массив не пуст:
{$match: {comments: {$ne: []}}}
Для правого, полного, анти- и других соединений операторов нет. Это расстроит любителей реляционных баз, поскольку они знают: семейство операторов JOIN — мощный инструмент, которым решают целый пласт задач.
Похожая проблема встречается в Datomic. Эта база, наоборот, сильна во внутренних соединениях (INNER JOIN), при котором записи без совпадений отбрасываются. Левые соединения частично возможны при помощи функции get-else. Полные и правые, увы, приходится делать в приложении.
Общий принцип таков: документные базы лишь частично реализуют JOIN. Если вы мыслите таблицами, понадобится время, чтобы сменить парадигму.
В плане транзакций документные базы тоже уступают реляционным. Например, в Datomic транзакции выполняются строго одна за другой. Так устроен транзактор – процесс, который отвечает за изменения в базе. Если транзакция займет длительное время, она заблокирует другие операции на запись (на чтении это не скажется). Подобных ситуаций избегают, разбивая крупные изменения на группу мелких.
Изначально MongoDB предлагала транзакции в рамках одного
документа. Гарантировалось, что либо все изменения увенчаются успехом, либо не
сработает ни одно. В 2018 году появились ACID-транзакции в разрезе нескольких
документов. MongoDB по-прежнему разделяет эти понятия: легкие транзакции для
одного документа и тяжелые для нескольких. Последние доступны лишь в случае,
когда сервер запущен с поддержкой реплики (флаг --replset). Это усложняет
локальную разработку, когда MongoDB запущен в Docker и нужно отладить транзакцию
в нескольких документах.
В Postgres транзакции занимают центральное место. Существуют их разные уровни, точки сохранения и отката. Многие сущности и переменные живут строго в рамках транзакции. За счет механизма MVCC (multi-version concurrency control, мульти-версионный контроль параллельного доступа) многие транзакции выполняются параллельно и не мешают друг другу. В критических случаях применяют двухфазные транзакции, известные как XA или 2 phase commits. С ними атомарные изменения возможны даже на разных физических серверах.
Работая с документной базой, обратите внимание на протокол обмена. Если данные передаются в двоичном виде, это хороший признак: сервер может вернуть большой объем данных, что необходимо в ряде задач.
Иные базы общаются с клиентами по REST и JSON (например, OpenSearch). На первый взгляд это удобно: нужен только HTTP-клиент и JSON-парсер. Как правило, то и другое есть в поставке языка. Работа с базой становится делом тривиальным: нужно отправить JSON, проверить статус и прочитать результат. Многие задачи легко автоматизировать утилитами curl и jq.
Протокол HTTP позволяет обращаться к базе из браузера при помощи XMLHttpRequest и Fetch API. Это снижает нагрузку на приложение, потому что оно не служит посредником между браузером и базой. С другой стороны, это небезопасно: обращение к базе из браузера означает, что последний хранит ключ доступа. Подобный доступ используют в закрытых системах, например внутренних приложениях фирмы, где база доступна только из локальной сети.
Недостаток JSON том, что он плохо подходит для ленивого чтения. Парсеры читают
его целиком, и на больших данных это проблематично. Растет потребление памяти;
нельзя обработать первую сущность, пока не прочитаны все. Текстовый формат
избыточен: числа записаны неплотно (один байт – один десятичный разряд), строки
экранированы (содержат символы \n, \t, \uXXXX и другие). Неоднозначная
ситуация с числами с плавающей запятой. JSON не сообщает, как их читать (Float,
Double, BigDecimal), и выбор остается за вами. Также в JSON нет дат и времени:
эти поля приводят к нужным типам вручную.
Двоичный формат решает эти проблемы. Числа в нем записаны плотно: скажем, 2 147 483 647 умещается в четыре байта, а не десять. Строки не экранируются, и парсер не бежит по ним в поисках обратной косой черты. Длина значений известна заранее. Каждое значение предваряется кодом типа, так что мы отличим Double от BigDecimal и строку от времени. Двоичный формат удобен для ленивого чтения.
Читатель возразит: проблемы форматов решаются библиотеками. Хорошо написанный клиент скрывает эти шероховатости, и разработчику не важно, как передаются даты, сколько выделено памяти и так далее. Но практика показывает: чем дольше вы работаете с базой, тем вероятней эти детали коснутся вас.
Обратите внимание, насколько легко забрать из таблицы все данные. В Postgres для этого служит команда COPY. Она выгружает таблицу или запрос в файл, процесс или поток. В последнем случае клиент принимает поток и направляет в локальный файл, сеть, функцию обработки. COPY поддерживает три формата: CSV, текстовый и двоичный, каждый со своими преимуществами и недостатками. Команда COPY полезна при переезде, в отчетности, тестах, резервном копировании и других случаях. Копирование работает в обе стороны: от сервера клиенту и обратно. Большие таблицы копируют параллельно в несколько потоков: каждый отвечает за свою часть. При этом прирост скорости почти линейный.
Не все документные базы столь легко делятся данными. Например, OpenSearch возвращает не более 10 тысяч документов за один запрос. Если в базе миллион документов, потребуется сто обращений. При этом ваша задача – следить, чтобы результат каждого запроса не превышал 2 147 483 647 байтов, иначе переполнится объект ByteArrayOutputStream.
OpenSearch поддерживает API под названием scroll (аналог курсора в Postgres). Он гарантирует, что в рамках скролла мы не увидим изменений, сделанных другими клиентами. Однако scroll подразумевает запросы в цикле и проброс nextToken. Это влечет написание кода и его отладку. В плане удобства scroll бесконечно далек от COPY.
Наконец, документные базы часто дороги в развертке и эксплуатации. Причина в том, что многие из них устроены как абстракции над другими хранилищами данных. Выше мы упоминали: Datomic хранит индексы в Postgresql, MySQL или DynamoDB. Желательно иметь кластер Memcache для снижения нагрузки. XTDB работает поверх RocksDB, кластера Kafka или сервиса S3. OpenSearch состоит из компонентов с разной функциональностью: ведущий узел (master node), хранилище (data node), прием данных (ingest node) и другие.
Когда в системе много узлов, их трудно развернуть локально. В том числе поэтому документные базы почти всегда работают в облаке. Для локальной разработки доступны их упрощенные dev-версии. В случае Datomic это библиотека com.datomic/local, в которой нет серверного узла: база работает в приложении и использует файлы или память для хранения.
Считается очевидным, что такие системы дешевле развернуть в облаке. На первых порах это действительно так: запуск OpenSearch или Datomic в AWS занимает минуту с момента нажатия на кнопку. Если купить виртуальный сервер и поручить то же самое devops-инженеру, он потратит на это дни.
Однако за все нужно платить, и часто это понимают, когда увязли в облачных решениях. Автор наблюдал эту ситуацию в стартапах и крупных фирмах. На ранних этапах команда ни в чем себе не отказывает: внедряет одно облачное решение, второе, третье. Бдительность усыпляют trial-периоды, когда счета выставляются со скидкой. Но вот они кончились, и счет за облако съедает почти всю прибыль. Инвесторы требуют урезать расходы, и разработчикам ставят задачи на оптимизацию. Теперь они заняты техническими вещами, а не развитием продукта.
Не начинайте работу с облачной документной базой, пока не рассчитаете стоимость. Итоговую сумму умножьте на два: во-первых, чтобы не ошибиться в меньшую сторону и оставить запас бюджета. Во-вторых, учитывайте тестовые окружения: они понадобятся для обкатки приложения и закрытых показов заказчику. Тестовые окружения, хоть и потребляют меньше ресурсов, тоже вносят вклад в ежемесячные счета.
Замечание о переоценке
Индустрия страдает от еще одного недуга: многие ожидания переоценены. Открывая стартап, руководство планирует кратный рост пользователей в день. Программисты пишут сложный кэш, уверенные, что система не выдержит наплыва посетителей. В качестве базы данных используют что-то облачное, распределенное, ориентированное на документы. Причина та же: ожидается, что традиционные Postgres и MySQL не выдержат нагрузки.
По наблюдению автора, чаще случается иное: мощные решения оказываются избыточными, а их поддержка – тратой времени и денег. Не переоценивайте мощь NoSQL. Меняйте парадигму только если исчерпали возможности реляционных баз – однако и здесь случаются удивительные примеры.
На конференции PGConf сотрудник OpenAI рассказал о роли Postgres в компании. С его слов, OpenAI использует один узел Postgres в облаке Azure. Из особых техник применяется только репликация, чтобы разгрузить чтение. Запись обслуживает один узел, при этом не используется даже шардирование. А тем временем аудитория OpenAI приближается к миллиарду пользователей! Согласно опросам, каждый десятый человек в мире пользуется их услугами – и все это обслуживает один узел Postgres с несколькими репликами. Подробности доклада и слайды вы найдете на странице PGConf Dev 2025.
При всем скепсисе автора к AI нельзя не признать: продукты OpenAI совершили поворот в индустрии. Появился пример того, как компания с одним узлом Postgres вышла на уровень гигантов. Выбирая технический стек, помните об этом факте.
Почему Postgres
В этой книге мы решаем следующую задачу: адаптируем Postgres для хранения документов. На первый взгляд это бессмысленно: зачем Postgres, если существуют специальные решения? Многие из них мы упоминали: это MongoDB, OpenSearch, DynamoDB и другие.
Причина в том, что пока развивались NoSQL-решения, Postgres тоже не стоял на месте. На смену json пришел jsonb, и с каждой версией его возможности улучшаются. Мы узнаем, как читать подмножество jsonb, обновлять его отдельные поля, переводить jsonb в таблицу и обратно, использовать язык JSON Path и другие техники. Вместе они не уступают возможностям документных баз.
Кроме типов json(b), Postgres силен в других направлениях. Прежде всего это операторы JOIN. С их помощью строят отчеты и сложные выборки, сравнивают массивы данных на предмет совпадений. JOIN развивает табличное мышление, о котором мы говорили в начале главы. Со временем вы увидите, что почти любая задача решается соединением: внутренним, левым, полным и другими.
Транзакции в Postgres раскрываются в полной мере. Они дают гранулярный контроль над изменениями, управляют параллельным доступом к данным.
Расширение pg_trgm (триграммы) предлагает поиск на вхождение строки, сопоставление с регулярным выражением, проверку схожести строк.
Расширение pg_cron выполняет запросы по расписанию, что снимает вопрос о внешнем планировщике.
Обычные и материализованные представления помогают в отчетности. Они снижают нагрузку на базу, смягчают неудачный дизайн. Это и многое другое мы изучим в дальнейших главах.
Postgres открыт и бесплатен в использовании. Он умерен в требованиях и работает на самом скромном оборудовании. Вы найдете Postgres во всех менеджерах пакетов: apt, yaml, brew и других. Легко запустить Postgres на виртуальном сервере ценой в несколько долларов. Это будет настоящий Postgres, а не муляж для локальной разработки.
Если вы арендуете базу в облаке, но исчерпали бюджет, возможны десятки вариантов переезда. Каждый провайдер предлагает услугу забора данных у конкурента. Предоставьте доступ к базе, и другой провайдер клонирует ее себе. Так выживают стартапы: первый год они арендуют Postgres в AWS со льготным периодом. Как только он заканчивается, переезжают со скидкой в Google Cloud, потом – в Azure и так далее.
Для Postgres написано множество утилит и расширений. Ваш лучший друг – утилита
psql, интерактивная оболочка для базы данных. Ее возможности крайне обширны:
запросы, импорт и экспорт таблиц, просмотр метаданных, переменные и
псевдонимы. Программы PGAdmin, DBeaver, DataGrip и другие предлагают графический
интерфейс. PGAdmin запускается в браузере и не требует локальной установки.
Postgresql — база данных с большим объемом учебных материалов. На этом поле с ней конкурирует разве что MySQL. Документация на 3000 страниц охватывает все тонкости Postgresql, в том числе низкоуровневые. Компания Postgres Pro выпускает книги на русском языке для читателей разного уровня. Все материалы доступны бесплатно и без регистрации. К книгам прилагаются файлы с запросами, чтобы не набирать их вручную. В вашем распоряжении учебная база в нескольких вариантах (малого, среднего, большого, гигантского).
Тайное преимущество SQL
Выбирая хранилище данных, имейте в виду: SQL знают не только программисты.
Сегодня почти любая должность, связанная с цифрами и финансами, подразумевает знание SQL. Сотрудники банков и телекомов работают в OLAP-системах, которые агрегируют данные из многих источников. Каждая из этих программ предлагает свой диалект SQL, и сотрудники изучают их на внутренних курсах. PostgreSQL становится еще одним диалектом, который учится в короткое время.
Автор проводил внутренние вебинары на тему как пользоваться Postgres и PGAdmin. Вопреки ожиданиям, у сотрудников было мало вопросов, и всем было все понятно. Позже автор видел, как сотрудники писали сложные запросы и передавали знания друг другу.
В крупных фирмах SQL становится связующим звеном между бизнесом и программистами. Он помогает перевести бизнес-жаргон в технические термины. Например, если аналитик понимает SQL, он объяснит, что значит “дейли лимиты с апрувом без кепэсити” – какие таблицы и фильтры имеются в виду. Иногда аналитики пишут сложные запросы, не привлекая программистов, и тем самым берегут их время.
Обмен знаниями усложняется, если данные хранятся в системе, отличной от SQL. Придется думать о том, как предоставить доступ аналитикам. Скорее всего, понадобятся скрипты, которые переносят данные в реляционную базу. Попытки переучить сотрудников на что-то иное могут встретить саботаж.
В некоторых NoSQL-базах наблюдается интересная вещь: их заказчики продавливают SQL. Например, в Datomic, основанный на языке Datalog, со временем добавили диалект SQL – пусть не такой мощный, как в Postgres, но удобный для аналитиков. Базу XTDB сделали совместимой с протоколом PG Wire, чтобы к ней можно было обратиться из любого клиента для Postgres.
Решения, которые противопоставляют себя SQL, все-таки вынуждены с ним считаться. Это подтверждает тезис: данные нужны всем, а не только программистам. От того, насколько легко обратиться к данным, зависят процессы и коммуникации. Опытный программист должен думать не только о себе, но и коллегах, в том числе тех, кто не умеет программировать. Порой именно реляционная база оказывается тем решением, которое удобно всем.
Теперь когда со вступлениями окончено, перейдем к практической части: познакомимся с возможностями JSON в Postgres.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter