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.
Как работает реляционная база данных
В основе реляционных баз данных лежит структура под названием 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
Очень мощная альтернатива реляционной модели — 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, и я считаю это приятным бонусом ко всему остальному.