Not Only SQL: ищем альтернативы реляционным базам

Обсуждение побудило меня написать статью о возможных альтернативах реляционным базам данных и SQL Server.

Так уж случилось, что, когда я учился в университете (как и многие мои коллеги), в то время еще не существовало толком никаких альтернатив реляционным базам. Только-только появился алгоритм Map-Reduce (2004), но в продуктах хранения данных он начал использоваться примерно в 2006 и позже. О самом алгоритме я узнал в году, наверное, 2010 (как минимум не раньше — точно не помню), до этого момента я использовал SQL Server и файлы. Облачные хранилища данных появились еще позже — Amazon AWS появился в 2006 и стал более-менее на слуху в 2008 году. Microsoft Azure появился вообще в 2010 и набрал популярность еще через пару лет.

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

Но время идет, и технологии стремительно развиваются. За последние 10 лет появилось много хранилищ данных на базе MapReduce, а также на базе других алгоритмов и структур данных. Сейчас никого не удивишь длинным списком из существующих хранилищ. Вот только некоторые из них: Google BigTable, Amazon Dynamo, Azure TableStorage, ElasticSearch, MongoDb, Apache HBase, Neo4j, Amazon S3, Azure DocumentDb, Druid и так далее. Причем некоторые из них можно использовать как сервис PaaS (Platform As A Service). PaaS позволяет сэкономить кучу денег, времени и нервов на хостинге и обслуживании хранилища. Многие из них по-настоящему распределенные и масштабируемые горизонтально, в отличие от SQL.

Как работает реляционная база данных

Image source

В основе реляционных баз данных лежит структура под названием b-tree, что накладывает определенные ограничения. Реляционная база данных реализована по ACID модели (Atomic, Consistent, Isolated, Durable). Другими словами, из стандартных Consistency-Availability-Partition Tolerance (статья на тему CAP) реляционная база поддерживает Consistency и Availability. Каждая таблица — это один или более индексов (clustered and unclustered indexes). Каждый индекс представляет собой отдельное b-tree. Это значит, что, если у вас в таблице десять индексов, при каждой записи (удалении, вставке или изменении) в таблицу, база данных будет делать десять записей (минимум) на диск в b-trees. Так как база поддерживает ACID — все операции с данными синхронные, а значит, придется ждать пока все операции записи будут завершены перед «коммитом» транзакции. Между скоростью чтения и записи в SQL базе данных всегда есть компромисс. Невозможно одновременно иметь быстрые запись и чтение в реляционной базе данных. Хотите быструю запись — ваши таблицы должны содержать минимум индексов. Хотите быстрое чтение — наоборот нужно создать много индексов, которые покроют все ваши условия в запросах.

Быстрая реляционная база данных

А что если нельзя иметь одновременно быстрые чтение и запись в реляционной базе? Компромисс между чтением и записью можно обойти при помощи создания второй базы, которую называют Data Warehouse — ее оптимизируют под чтение данных и создание сложных отчетов. Таким образом, Operational (OLTP) база поддерживает быструю запись и изменение данных, а Data Warehouse (OLAP) поддерживает быстрое чтение и сложные многоэтажные запросы. Но тут вступает в дело CAP теорема — вы не можете иметь все три свойства CAP одновременно в такой конфигурации. Другими словами, вы не можете транзакционно писать одновременно в Operational базу и в Data Warehouse — транзакция будет распределенной и очень медленной. Базы получаются разделены физически, и данные в Data Warehouse попадают с некоторой задержкой. Следовательно, мы приходим к Eventual Consistency на реляционной базе данных — то, что мы пишем в Operational базу, невозможно сразу же прочитать из Data Warehouse. Реляционные базы данных не масштабируются горизонтально. И никакие Shards и Partitions все равно не помогут победить CAP теорему.

Альтернатива реляционным базам данных

Альтернативные хранилища в основном используют другие структуры данных для хранения, например, LSM, Distributed Hash Table, Inverted Index, Graph или что-то еще более экзотическое. Подробнее про эти структуры данных можно почитать на википедии. По большому счету большинство альтернатив реляционным базам используют разделение записи и чтения в пределах одной базы. Они часто реализуют BASE модель (Basically Available, Soft-state, Eventually-consistent) и жертвуют Consistency в пользу Availability и Partition Tolerance. Если рассмотреть Eventually-consistent хранилище на примере MongoDb — MongoDb выигрывает в производительности записи у SQL, потому что вторичные индексы обновляются не сразу при записи, а асинхронно — через некоторое время. Если у вас несколько партишенов и есть копии данных (реплики), они также обновляются асинхронно через некоторое время. Другими словами, если вы делаете запись и сразу же пытаетесь найти записанный документ — есть шанс, что он не будет найден. Либо вы найдете предыдущую (устаревшую) версию документа. Это и есть отсутствие строгой целостности (strict Consistency). Объяснить все нюансы с Partition Tolerance и Consistency потянет на отдельную статью — кому интересна тема рекомендую почитать NoSQL Distilled — там очень хорошо раскрыта тема CAP теоремы и ее следствий. Стоит упомянуть, что многие из альтернативных хранилищ поддерживают транзакционность в пределах партишена либо глобально по требованию за счет распределенной транзакции, что позволяет писать, используя привычный подход ACID-транзакций.

Event Sourcing

Image source

Очень мощная альтернатива реляционной модели — Event Sourcing. В привычной нам реляционной модели мы храним объекты и связи между ними. При изменении объекта мы просто перезаписываем старый объект новым. Другими словами, мы всегда храним только одно состояние объекта — последнее сохраненное. В реляционной модели, думаю, всем знакомы проблемы с синхронизацией, производительностью при изменении одних и тех же данных, deadlocks.

Event Sourcing — это когда каждое изменение в доменной модели записывается как событие (event). Все события immutable, таким образом, хранилище представляет собой append-only log, где ничего не удаляется и не меняется. За счет этого сразу решается куча проблем с синхронизацией и масштабированием записи. Также, как побочный эффект, появляется полный лог всех операций в системе и возможность реконструировать любой доменный объект на любой момент времени в системе. Это может очень помочь при отладке и воспроизведении ошибок. Конечно же, есть и минусы Event Sourcing — в первую очередь это бОльший объем данных и невозможность выполнять привычные запросы select ... where ... в таком хранилище. Проблема с запросами легко решается использованием отдельного хранилища для чтения. Проблема с объемом данных надуманная, так как хранение данных сегодня стоит копейки по сравнению со стоимостью трафика или процессорного времени.

Любая операции в модели Event Sourcing выглядит так:
Given начальная последовательность событий;
When выполняем операцию (команду);
Then в лог записывается одно-или-более событий или Exception (ошибка).

Как можно заметить — все вычисления с доменной моделью можно реализовать в функциональном стиле без побочных эффектов (side effects).
Тема очень интересная и очень обширная, поэтому я упомяну, что Event Sourcing часто используется вместе с Domain Driven Design (DDD) и Command Query Responsibility Segregation (CQRS) и перейду к конкретному примеру на Azure Table Storage.

Event Sourcing на Azure Table Storage

Я хочу показать пример, как можно использовать Azure Table Storage для построения высоконагруженной системы с Event Sourcing моделью. Давайте попробуем смоделировать что-то простое — например, счет игрока в онлайн-казино. У нас есть пользователь, который может пополнять свой счет, делать ставки, получать выигрыш и снимать деньги. Когда на счету нет денег — пользователь не может делать ставки. Мы можем смоделировать следующие события для пользователя: MoneyDeposited, BetPlaced, WinReceived, MoneyWithdrawn.

Azure Table Storage — высокопроизводительное облачное хранилище от Microsoft. Оно очень простое — вы можете создавать таблицы, в которых по умолчанию есть столбцы PartitionKey (string), RowKey (string). Также вы еще можете иметь 255 столбцов для хранения произвольных данных. Запросы поддерживаются только для PartitionKey и RowKey, для всех остальных случаев производительность запросов очень низкая, так как никакие поля, кроме PartitionKey и RowKey, не индексируются. Поддерживается транзакционность на уровне Partition в пределах 100 записей. То есть можно атомарно писать до 100 записей с одинаковым PartitionKey. В общем случае в таком хранилище при использовании Event Sourcing нам нужно три поля: PartitionKey — aggregate Id, RowKey — event Id, Data — event body (произвольный JSON).

В случае с казино наш игрок является aggregate (из терминологии DDD) или границей транзакции. Игрок взаимодействует только с казино, и мы его можем полностью изолировать от других игроков. Таким образом, PartitionKey в нашем хранилище будет aggregate Id — уникальное Id игрока. RowKey будет использоваться как последовательный номер события в потоке.

В таком решении у нас будет количество партишенов равно количеству игроков. Если у нас миллион игроков — значит будет миллион партишенов в таблицы. В Azure Table Storage нет ограничений на количество партишенов, и это очень удобно. В пределах одного партишена производительность до 2000 IOPS.

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

С многопоточным обновлением данных (точнее, c записью нового события) есть как минимум два решения. Первое и самое очевидное — не используйте многопоточность. Например, можно реализовать actor-based решение и всегда обрабатывать запись в один поток. Второе решение — используйте optimistic concurrency. Можно добавить специальную запись StreamHeader, в которой будет храниться номер последнего записанного события и другие метаданные. StreamHeader мы будем обновлять в момент записи событий. В Table Storage у каждой записи есть ETag, на базе которого реализовано optimistic concurrency. Если кто-то уже перезаписал StreamHeader — мы получим Exception при попытке записи.

Вторая проблема — вычисление текущего состояния (баланса) игрока решается похожим способом. Достаточно просто добавить в запись StreamHeader текущее состояние счета. Как альтернатива — нам нужно перечитывать весь поток событий? чтобы выполнить какое-то действие (команду).

Так как мы можем писать до 100 записей транзакционно, и при записи проверяется был ли изменен перезаписываемый объект — со специальной записью StreamHeader мы можем гарантировать целостность данных в пределах партишена.

Внимательный читатель заметил, что в этом решении нет ни одного вторичного индекса. Например, мы не можем искать по имени игры или по сумме ставки. Для сложных запросов нам нужно другое хранилище — например, DocumentDB, где мы сможем делать сложную аналитику и отвечать на вопросы «сколько игроков поднимают ставку более чем в два раза перед тем, как все проиграть». Я бы назвал Azure TableStorage хранилищем, оптимизированным для записи и хорошим выбором для EventSourcing.

Eventual Consistency решает проблемы с масштабированием

Я понял за годы работы программистом, что многие реальные бизнес-процессы не требуют строгой консистентности и транзакционности всех операций. Как пример — продажа товара в интернет-магазине. Представьте себе у вас есть 10 000 000 покемонов и весь мир потенциальных покупателей. Можно взять sql и реализовать примерно такую логику — при попытке покупки мы делаем запрос в базу, если покемоны еще есть — покупку разрешаем, иначе говорим, что покемоны закончились. Для этого нам понадобится одно физическое место (табличка в базе), где мы будем вести учет проданных покемонов. Либо счетчик проданных покемонов, который транзакционно обновляется при каждой продаже и опрашивается при каждой попытке купить. Это и есть наше узкое место в системе.

Можно сделать все по-другому. Если спросить любого продажника — ему все равно, сколько у вас товара на складе. Он будет рад продать любое количество — чем больше, тем лучше. Так устроена работа продажника, да и многих бизнесов: утром — деньги, вечером — стулья. И даже если стульев нет, все будут рады получить деньги утром. =)

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

Context is a king

В контексте хранилищ данных очень важна модель данных и приложения. Действительно реляционных данных в мире мало. Реляционная модель (как и любая другая модель) — всего лишь наша попытка представления реальности в цифрах. Есть множество других моделей, которые игнорируются в силу доминирования реляционных баз данных. Например, вот этот текст, который вы сейчас читаете, можно рассматривать по-разному. С точки зрения реляционной модели здесь есть слова, предложения и абзацы, которые составляют текст. А можно рассматривать как последовательность изменений, которые я вносил в текст в течение нескольких вечеров, пока я его писал. Также его можно рассматривать как один целостный документ — «blob», который имеет ссылку в интернете и который имеет смысл только как единое целое. Или можно рассматривать текст как вектор слов на русском языке. Или как массив символов кириллицы. Ну и так далее — текст один, а моделей множество. Все зависит от поставленной задачи. Все задачи решать при помощи реляционной модели данных и SQL — плохая идея.

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

P.S. На моей последней работе вообще нет SQL, и я считаю это приятным бонусом ко всему остальному.

Похожие статьи:
Cпецвыпуск нашего дайджеста расскажет об Android-анонсах главной конференции Google. Наиболее трендовыми темами стали AI и виртуальная...
Компания Logitech представила в России свой обновлённый модельный ряд беспроводных колонок линейки Ultimate Ears – UE ROLL, UE BOOM 2 и UE MEGABOOM....
Привет! Я Data Engineer в NIX, фанат обработки данных больших и маленьких, поклонник Python. В этой статье расскажу, как Data Engineer помогает...
17-річний киянин Ігор Клименко став найкращим студентом світу 2022 року за версією Global Student Prize. Його проєкт —...
Кабінет міністрів планує розглянути постанову про «єБронювання». Цей документ визначатиме, як компаніям...
Яндекс.Метрика