Что такое партиционирование репликация и шардинг
Блог Степана Родионова
Блог о разработке программного обеспечения и технологичных интернет-решений
Мои контакты
понедельник, 29 июня 2015 г.
Проектирование высоконагруженных приложений. Часть 3. База данных.
В предыдущей статье мы рассмотрели frontend и backend, разобрали возможные способы их оптимизации и масштабирования. В этой статье мы будем говорить о базах данных, очень важном звене в нагруженных системах. Попробуем разобраться, какие существуют подходы к масштабированию базы данных. Сразу скажу, что это, пожалуй, самая сложная тема.
База данных.
Модель предметной области и типы БД.
Тюнинг базы данных.
В-четвертых, излюбленная тема JOIN-ов. В запросах не должно быть JOIN-ов. Если нужно перемножить результаты двух таблиц — делайте это в памяти backend-а. Два (или больше) легких запроса к базе данных, а потом в памяти обрабатываете результаты.
Вообще есть очень действенный подход при проектировании слоя хранения данных, который может на начальном этапе обеспечить возможность масштабирования базы данных, либо позволит в дальнейшем поиметь меньше проблем. Он заключается в следующем: изначально представляйте себе такую схему, когда все ваши таблицы уже находятся в разных базах данных на разных серверах и пишите код соответствующим образом. По началу будет непросто, но постепенно все встанет на свои места. Да, это порушит ваши представления о консистентности данных, но highload-проекты требует другого подхода к разработке.
Денормализация.
Для повышения эффективности выборки данных можно попробовать размещать их не самым оптимальным способом — денормализованно, т.е. дублировать, хранить в разных форматах и т.д. Преподаватели в университетах будут негодовать 🙂
Денормализация — это намеренное приведение структуры базы данных в состояние, не соответствующее правилам нормализации, для того, чтобы повысить эффективность чтения данных. В частном случае денормализация позволяет избавиться от JOIN-запросов.
Пара примеров чтобы стало понятнее. Возьмем какую-нибудь запись из социальной сети, у этой записи есть 30 лайков. Нам нужно при наведении на эту запись показать имена и фотографии тех, кто ее лайкнул. В базе данных можно хранить таблицу соответствий [id записи — id лайкнувшего пользователя], а потом просто сделать JOIN имени и фотографии. А можно сразу имя и фотографию включать в эту таблицу соответствий, тогда запрос на выборку будет очень быстрым и легким, мы сразу получим все нужные данные. Понятно, что в таком случае обновление данных будет сложнее, но у любой медали две стороны.
Второй пример. Лента в Instagram. Как вы думаете она строится? «Выбери все последние фотографии тех, на кого я подписан, и отсортируй по времени»? Это не будет работать на таких нагрузках и объемах. Там для каждого пользователя есть своя лента, хранящаяся в Redis. Таким образом, один пост там может храниться в сотнях тысяч экземпляров. Да, дублирование, но это позволяет предоставлять пользователям качественный и быстрый сервис.
Пример с Instagram тесно связан с таким понятием как «масштабирование во времени», о котором мы будем говорить в следующей статье.
Шардинг.
На самом деле это основная техника масштабирования базы данных, она же самая сложная. Принцип простой. Вот есть у вас 15 миллионов пользователей. Всю информацию, которая относится к первым пяти миллионам вы храните на первом сервере, ко вторым пяти — на втором, остальные — на третьем. Таким образом, шардинг — это разбиение ваших данных на отдельных серверах.
Когда данные только добавляются и никогда не удаляются может работать принцип ящиков. Шард — это ящик. Ящик заполнился — добавили новый ящик. Но это такая простая ситуация, которая, как правило, проблем не вызывает. Гораздо интереснее, когда данные на разных шардах могут расти или не расти по разным причинам — так обычно и бывает в реальных проектах. Есть классический пример с Lady Gaga и Facebook: если вы будете хранить все данные, относящиеся к Lady Gaga на сервере №21, то рано или поздно этот сервер переполнится. Тут уже нужно думать, что делать со всеми этими данными. Главная особенность этого сюжета — непредсказуемость, поэтому нужна гибкая техника, которая называется «виртуальные шарды».
[Рекомендую посмотреть выступление Алексея Рыбака (Badoo) и Константина Осипова (Mail.ru) на конференции Highload++ на тему шардинга: http://www.youtube.com/watch?v=MhGO7BBqSBU]
Виртуальные шарды.
Как определить на какой шард нужно делать запрос? Обычно определяется некоторая функция, которая по ключу отдает шард (номер или IP-адрес сервера).
Технику виртуальных шардов можно представить себе как промежуточное отображение, т.е. сначала вы ключ отображаете на некий промежуточный виртуальный шард, а потом этот виртуальный шард на соответствующий ему физический.
В чем суть? Вы разбиваете все пространство данных на заведомо большое и определяете, например, тысячу виртуальных шардов. При этом у вас на самом деле всего 1 физический сервер. Вы запускаете на этом сервере 10 инстансов PostgreSQL, а в каждом инстансе еще по 100 баз данных. Постепенно система начнет наполняться. Только теперь вы сможете без проблем, используя репликацию, разнести данные на отдельные серверы или инстансы. Примерно вот так:
Таким образом, виртуальные шарды — это такая прослойка, которая позволяет backend-у общаться с конкретным шардом, при этом не не задумываясь о том, где этот шард физически находится. К примеру, нужно нам найти пользователя. Сначала он вычисляется «виртуально» — определяется виртуальный шард, затем берется какая-то таблица соответствий, по которой выясняется, где этот виртуальный шард находится физически.
Поначалу все может работать медленнее, чем если бы у нас был просто один сервер и один инстанс, но все это делается для того, чтобы в будущем, когда будет замечаться рост какого-то отдельного шарда, вы могли легко и просто (без изменения бизнес-логики приложения) мигрировать данные с одного сервера на другой.
У вас должен быть реализован какой-то центральный элемент этой системы, который будет знать, как выполняется шардинг. Кто-то должен знать, как «замапить» виртуальный шард на физический. Это уже некая договоренность между backend-ом и звеном хранения. Реализовать этот центральный элемент можно по-разному: это может быть просто конфигурационный файл, в котором вы прописываете соответствия виртуальному шарду физического, либо какой-то сервис, в котором реализуется логика этого же отображения. Все зависит от конкретной ситуации и конкретного проекта.
Репликация.
Что делать, когда один из серверов базы данных выйдет из строя? Срочно его поднимать 🙂 Но в это время обслуживание части пользователей будет невозможно. Чтобы этого не случилось используется репликация. Репликация — это механизм синхронизации данных между несколькими серверами базы данных.
Существует две основных задачи репликации: повышение отказоустойчивости и снижение нагрузки. В первом случае вы поднимаете несколько серверов базы данных и если один из них выходит из строя, то ничего страшного не случится, т.к. клиентов могут обслуживать оставшиеся сервера. Ко второму случаю относятся ситуации, когда запросов на чтение данных намного больше, чем запросов на запись. В этом случае выделяется один сервер (Master), в который пользователи пишут данные, а с остальных серверов (Slave/Replica) они их читают. После записи данных на Master-сервер они автоматически мигрируются (синхронно или асинхронно) на сервера для чтения.
[Про настройку репликации в PostgreSQL можно почитать тут]
Партиционирование.
Шардинг — это когда мы разбиваем данные на части и кладем их по выбранному критерию на разные серверы. Партиционирование — это тоже разбиение данных, но больше похожее на разбиение backend-ов по функциональности, т.е. данные для системы сообщений храним в одной базе, а данные для чего-то другого — в другой.
Партиционирование помогает, когда в вашем проекте часть данных читается очень активно, а часть — реже. Например, новостной сайт. Самые актуальные новости последней недели будут гораздо чаще читаться пользователями, поэтому здесь имеет смысл хранить эти данные отдельно от остальных, «поближе к пользователям». Вы просто выносите новости последней недели в отдельную таблицу или базу данных, в которую идут все запросы за свежими новостями. Помимо этого, есть «общая» база данных (архив), куда попадают вообще все новости. В эту базу данных идут все запросы за старыми новостями. Настройка партиционирования в PostgreSQL или MySQL выполняется достаточно просто, сейчас это практически полностью автоматизированный процесс.
К партиционированию можно отнести и ситуацию, когда мы начинаем разделять хранилище по типам данных. Например, начинаем хранить сообщения в какой-то NoSQL базе данных, а все остальное — в PostgreSQL. Каждая СУБД имеет какие-то свои фишки, позволяющие ей хранить определенные типы данных более эффективно, чем другие СУБД. Поняв это, можно существенно повысить эффективность всей системы.
Например, в вашем проекте есть возможность вести чат между пользователями. Все данные, включая сообщения чатов, хранятся в РСУБД, к примеру, PostgreSQL. Заметив, что формат общения и хранения данных у MongoDB, nodejs и клиентским javascript одинаковый (JSON), можно существенно снизить ненужные издержки, повысить эффективность чатов, обеспечить автоматическое масштабирование системы хранения сообщений и т.д. Nodejs позволяет писать быстрые и легкие сервера, MongoDB обеспечивает очень быструю запись (и чтение) данных, а также автоматическое масштабирование. Плюс ко всему, они общаются на одном «языке». Разумный и прагматичный подход — выбрать инструменты, которые лучше остальных подходят для решения конкретной задачи.
На этом все, мы кратко прошлись по всем основным нюансам масштабирования и оптимизации базы данных. В следующей статье мы познакомимся с другими способами масштабирования больших интернет-проектов, в частности масштабированием во времени.
Масштабирование баз данных — партиционирование, репликация и шардинг
СУБД — это очень часто «узкое место» в производительности веб-приложений, влияющее на быстродействие и устойчивость к высоким нагрузкам. В момент, когда сервер баз данных не может справится с нагрузками, производится масштабирование.
Рассмотрим основные способы увеличения производительности СУБД.
Масштабирование SQL и NoSQL
Описанные ниже схемы масштабирования применимы как для реляционных баз данных, тах и для NoSQL-хранилищ. Разумеется, что у всех баз данных и хранилищ есть своя специфика, поэтому мы рассмотрим только основные направления и в детали реализации вдаваться не будем.
Партиционирование (partitioning)
Партиционирование — это разбиение таблиц, содержащих большое количество записей, на логические части по неким выбранным администратором критериям. Партиционирование таблиц делит весь объем операций по обработке данных на несколько независимых и параллельно выполняющихся потоков, что существенно ускоряет работу СУБД. Для правильного конфигурирования параметров партиционирования необходимо, чтобы в каждом потоке было примерно одинаковое количество записей.
Например, на новостных сайтах имеет смысл партиционировать записи по дате публикации, так как свежие новости на несколько порядков более востребованы и чаще требуется работа именно с ними, а не со всех архивом за годы существования новостного ресурса.
Репликация (replication)
Репликация — это синхронное или асинхронное копирование данных между несколькими серверами. Ведущие сервера называют мастерами (master), а ведомые сервера — слэйвами (slave). Мастера используются для изменения данных, а слэйвы — для считывания. В классической схеме репликации обычно один мастер и несколько слэйвов, так как в большей части веб-проектов операций чтения на несколько порядков больше, чем операций записи. Однако в более сложной схеме репликации может быть и несколько мастеров.
Например, создание нескольких дополнительных slave-серверов позволяет снять с основного сервера нагрузку и повысить общую производительность системы, а также можно организовать слэйвы под конкретные ресурсоёмкие задачи и таким образом, например, упростить составление серьёзных аналитических отчётов — используемый для этих целей slave может быть нагружен на 100%, но на работу других пользователей приложения это не повлияет.
Шардинг (sharding)
Шардинг — это прием, который позволяет распределять данные между разными физическими серверами. Процесс шардинга предполагает разнесения данных между отдельными шардами на основе некого ключа шардинга. Связанные одинаковым значением ключа шардинга сущности группируются в набор данных по заданному ключу, а этот набор хранится в пределах одного физического шарда. Это существенно облегчает обработку данных.
Например, в системах типа социальных сетей ключом для шардинга может быть ID пользователя, таким образом все данные пользователя будут храниться и обрабатываться на одном сервере, а не собираться по частям с нескольких.
Партиционирование, репликация и шардинг — три основных подхода к масштабированию баз данных. Они позволяют обеспечить повышение быстродействия приложения и повысить устойчивость к высоким нагрузкам.
Масштабирование баз данных — партиционирование, репликация и шардинг
Один сервер базы данных в какой-то момент перестает справляться с нагрузкой. В этот момент и следует применять описанные тут техники масштабирования.
Перед тем, как приступать к масштабированию, необходимо провести анализ медленных запросов и убедиться, что сервер MySQL настроен оптимально.
Вертикальный шардинг
Вертикальный шардинг — это выделение таблицы или группы таблиц на отдельный сервер. Например, в приложении есть такие таблицы:
Таблицу users Вы оставляете на одном сервере, а таблицы photos
и
albums
переносите на другой. В таком случае в приложении Вам необходимо будет использовать соответствующее соединение для работы с каждой таблицей
Совместное использование
Шардинг и репликация часто используются совместно. В нашем примере, мы могли бы использовать по два сервера на каждый шард таблицы:
Тогда в приложении работа с этой табличкой может выглядеть так:
Читаем данные со слейвов, а записываем на мастер-сервера
Такая схема часто используется не для масштабирования, а для обеспечения отказоустойчивости. Так, при выходе из строя одного из серверов шарда, всегда будет запасной.
Горизонтальный шардинг
Горизонтальный шардинг — это разделение одной таблицы на разные сервера. Это необходимо использовать для огромных таблиц, которые не умещаются на одном сервере. Разделение таблицы на куски делается по такому принципу:
Самое важное
Шардинг и репликация — это популярные и мощные техники масштабирования систем работы с данными. Несмотря на примеры для MySQL, эти подходы универсальны и могут применяться для любой технологии.
, процесс масштабирования данных — это архитектурное решение, оно не связано с конкретной технологией. Не делайте ошибок наших отцов — не переезжайте с известной Вам технологии на новую из-за поддержки или не поддержки шардинга. Проблемы обычно связаны с архитектурой, а не конкретной базой данных.
Этот текст был написан несколько лет назад. С тех пор упомянутые здесь инструменты и софт могли получить обновления. Пожалуйста, проверяйте их актуальность.
Обработка запросов NoSQL базы данных
Возможности обработки запросов NoSQL базы данных напрямую следуют из ее модели распределения. Поиск по ключу, то есть возможность найти и отдать данные по id, поддерживают любые NoSQL базы данных.
Сложные запросы, а точнее аналитика полученных из базы данных, могут выполняться либо изначально (как например, в MongoDB, Riak, CouchDB), так и через внешние платформы для аналитики, такие как Hadoop, Spark и Flink (например, для Cassandra и HBase).
Популярное решения для распределенных вычислений кластеров NoSQL базы данных – MapReduce. MapReduce работает в два шага:
Масштабируемые решения и трилемма масштабируемости
Существует две категории решений для масштабирования: on-chain и off-chain. Решения для масштабирования в цепочке включают новые или улучшенные механизмы консенсуса, боковые цепи или такие методы, как сегментирование. Хотя ончейн-решения предпочтительнее, они быстро сталкиваются с надоедливой трилеммой масштабируемости, в которой чрезвычайно сложно ориентироваться. Это затруднительное положение связано с печальной реальностью, заключающейся в том, что всякий раз, когда что-то делается для увеличения масштабируемости, это почти всегда сопровождается компромиссом либо в децентрализации, либо в безопасности, либо в том и другом. Решения вне сети, часто называемые решениями уровня 2, такие как платежные и государственные каналы, не освобождаются от этой трилеммы, хотя они могут помочь справиться с ней с другой стороны.
Записки программиста
При разработке нового проекта в качестве основной СУБД нередко выбираются реляционные базы данных, такие, как PostgreSQL или MySQL. В этом действительно есть смысл. Первое время у проекта мало пользователей, и потому все данные помещаются в один сервер. При этом проект активно развивается. Нельзя заранее сказать, какой функционал в нем станет основным, а какой будет выкинут. Есть много историй о том, как мобильный дейтинг в итоге превращался в криптомессанджер, и подобного рода. РСУБД удобны на ранних этапах, потому что они универсальны. Так, PostgreSQL из коробки имеет встроенный полнотекстовый поиск, умеет эффективно работать с геоданными, а также подходит для хранения очередей и рассылки уведомлений. По мере развития проекта и роста нагрузки часть данных может быть перенесена в специализированные NoSQL решения. Также нагрузку можно распределить, поделив базу на несколько совершенно не связанных между собой баз, а также при помощи потоковой репликации. Но что делать в случае, если все это не помогло? В этом посте я постараюсь ответить на данный вопрос.
Примечание: Хочу поблагодарить gridem, sum3rman и gliush за активное участие в обсуждении поднятых в данном посте вопросов. Многие из озвученных ниже идей были позаимствованы у этих ребят.
Декомпозиция проблемы
Задачу построения горизонтально масштабируемого РСУБД-кластера можно разделить на следующие сравнительно независимые задачи:
Попробуем рассмотреть озвученные проблемы по отдельности.
Шардинг
Существует много схем шардирования. С довольно полным списком можно ознакомиться, например, здесь. Насколько мне известно, наиболее практичной и часто используемой схемой (в частности, она используется в Riak и Couchbase) является следующая.
Каждая единица данных относится к определенной «виртуальной корзине», или vbucket. Число vbucket определяется заранее, берется достаточно большим и обычно является степенью двойки. В Couchbase, например, по умолчанию существует 1024 vbucket’а. Для определения, к какому vbucket относится единица данных, выбирается некий ключ, однозначно определяющий единицу данных, и используется формула типа:
vbucket_num = hash(key) % 1024
Couchbase, например, является KV-хранилищем. Поэтому совершенно логично единицей данных в нем является пара ключ-значение, а ключом, определяющим единицу данных, является, собственно, строковый ключ. Но единица данных может быть и более крупной. Например, если мы пишем социальную сеть, то можем создать 1024 баз данных с одинаковой схемой, а в качестве ключа использовать идентификатор пользователя. Самое главное здесь, чтобы данные, попадающие в разные vbucket’ы, были как можно менее связанными друг с другом, а в идеале — вообще никак не связанными.
Описанным выше способом мы получаем отображение (ключ → номер vbucket). Однако это отображение не дает нам ответа на вопрос, где физически следует искать данные, то есть, к какому репликасету они относятся. Для этого используется так называемый словарь, отображающий номер vbucket’а в конкретный репликасет. Поскольку выше было сказано, что задачу автоматического фейловера мы решаем при помощи Stolon, а ему для работы нужен Consul, который, помимо прочего, является KV-хранилищем, вполне логично хранить словарь в Consul. Например, словарем может быть документ вида:
Здесь format_version нужен на случай изменения формата словаря в будущем. Также нам нужна версия (ревизия) словаря, увеличивающаяся при каждом обновлении словаря. Выше она не приведена, так как в Consul это есть из коробки для всех данных и называется ModifyIndex. Наконец, в массиве vbuckets по i-му индексу хранится имя кластера Stolon, соответствующего i-му vbucket. В случае, если в настоящее время происходит перебалансировка (см далее), вместо одного имени кластера хранится пара [«clusterFrom»,»clusterTo»] — откуда и куда сейчас переносятся данные.
Вы спросите, зачем так сложно? Во-первых, эта схема очень гибкая. Например, на ранних этапах развития проекта мы можем использовать один репликасет, хранящий все 1024 vbucket’а. В будущем мы можем использовать до 1024-х репликасетов. Если каждый репликасет будет хранить 1 Тб данных (далеко не предел по сегодняшним меркам), это обеспечит хранение петабайта данных во всем кластере. Во-вторых, при добавлении новых репликасетов не возникает необходимости перемещать вообще все данные туда-сюда, как это происходит, например, при использовании формулы hash(key) % num_replicasets. Наконец, мощности машин в кластере могут различаться. Эта схема позволяет распределить данные по ним неравномерно, в соответствии с мощностями.
Перебалансировка
Что делать в случае, если мы хотим переместить vbucket с одного репликасета на другой?
Начнем со словаря. Каким образом он будет изменяться при перебалансировке, было описано выше. Но перед началом переноса vbucket’ов важно убедиться, что все клиенты увидели новый словарь, в котором отражен процесс переноса. Каким образом это можно сделать? Простейшее решение заключается в том, что для каждой версии словаря раз в определенный интервал времени T (скажем, 15 секунд) клиенты пишут в Consul «словарь такой-то версии последний раз использовался тогда-то». Само собой разумеется, предполагается, что время между клиентами более-менее синхронизировано с помощью ntpd. Если словарь никем не используется уже T*2 времени, можно смело полагать, что все клиенты увидели новую версию словаря. Сами клиенты могут запрашивать последнюю версию словаря просто время от времени, или же воспользоваться механизмом Consul подписки на изменения данных.
Итак, все клиенты знают о начале перебалансировки. Далее возможны варианты.
Дополнение: Вместо pglogical вы, вероятно, захотите использовать логическую репликацию, которая начиная с PostgreSQL 10 теперь есть из коробки.
В случаях (1) и (2) данные можно переносить обычным pg_dump или воспользоваться COPY:
— экспорт таблицы COPY tname TO PROGRAM ‘gzip > /tmp/data.gz’; — экспорт данных по запросу COPY (SELECT * FROM tname WHERE …) TO PROGRAM ‘gzip > /tmp/data.gz’; — импорт данных COPY tname FROM PROGRAM ‘zcat /tmp/data.gz’;
Следует также отметить, что вместо логической репликации можно использовать обычную потоковую. Для этого нужно, чтобы каждый vbucket жил на отдельном инстансе СУБД. В частности, PostgreSQL позволяет легко запускать много инстансов на одной физической машине безо всякой виртуализации. В этом случае вы, вероятно, захотите выбрать несколько меньшее число vbuckets, чем предложенные ранее 1024. Еще, как альтернативный вариант, можно реплицировать вообще все данные, а потом удалять лишние. Но это дорого и будет работать только при введении в строй совершенного нового репликасета.
На мой взгляд, наиболее правдоподобным и универсальным вариантом на сегодняшний день является использование потоковой репликации с удалением лишних данных по окончании репликации по сценарию (3). Это работает только при добавлении совершенно нового, пустого репликасета. В случае, если данные нужно слить с нескольких репликасетов в один, следует использовать pg_dump по сценарию (1).
Решардинг
Напомню, что решардингом, в отличие от перебалансировки, называется изменение числа vbucket’ов или же полное изменение схемы шардирования. Последнее по моим представлениям является очень сложной задачей, делается крайне редко, и исключительно в случае, если весь шардинг реализован непосредственно в самом приложении, а не на стороне СУБД или какого-то middleware перед ним. Здесь очень многое зависит от конкретной ситуации, поэтому далее мы будем говорить о решардинге только в контексте изменения числа vbucket’ов.
Простейший подход к решардингу — это не поддерживать его и просто заранее выбирать достаточно большее количество vbucket’ов
Если же решардинг все-таки требуется поддерживать, многое зависит от того, что было выбрано за единицу данных (см параграф про шардинг). Допустим, ей является строка в таблице. Тогда мы можем очень просто удвоить количество vbucket’ов. Вспомним формулу:
// было vbucket_num = hash(key) % 1024 [ = hash(key) & 0x3FF ] // стало vbucket_num = hash(key) % 2048 [ = hash(key) & 0x7FF ]
После удвоения числа vbucket’ов половина ключей будут соответствовать все тому же номеру бакета, от 0 до 1023. Еще половина ключей будет перенесена в бакеты с 1024 по 2047. Притом ключ, ранее принадлежавший бакету 0, попадет в бакет 1024, ранее принадлежавший бакету 1 — в бакет 1025, и так далее (у номера бакета появится дополнительный старший бит, равный единице). Это означает, что если мы возьмем текущий словарь, и модифицируем его так:
// оператор ++ означает операцию append, присоединение массива с конца dict.vbuckets = dict.vbuckets ++ dict.vbuckets
… то все ключи автоматически окажутся на нужных репликасетах без какого-либо переноса. Теперь, когда число vbucket’ов удвоилось, бакеты можно переносить с репликасета на репликасет, как обычно. Уменьшение числа vbucket’ов происходит аналогично в обратном порядке — сначала серия переносов, затем обновление словаря. Как и в случае с перебалансировкой, следует проверять, что все клиенты увидели новую версию словаря.
Если единицами данных являются базы данных с одинаковой схемой, все несколько сложнее. В этом случае не очень понятно, как можно быстро и правильно для общего случая разделить каждую базу на две. Похоже, лучшее, что можно сделать при такой схемы шардирования, вместо использования формулы hash(key) % 1024 просто сообщать пользователю количество vbucket’ов. В этом случае определение номера бакета по ключу, а также перенос данных в случае решардинга перекладываются на приложение. Зато число бакетов может в любой момент быть увеличено на произвольное число просто путем создания пустых бакетов. Или уменьшено путем удаления лишних бакетов, в предположении, что пользователь заблаговременно перенес из них все данные.
Распределенные транзакции
Поскольку бакеты могут быть логически связанными и храниться на разных репликасетах, иногда приходится делать транзакции между репликасетами. При правильно выбранной схеме шардирования распределенные транзакции должны выполняться редко, поскольку они всегда недешевы. Если в вашем проекте распределенные транзакции не нужно делать никогда, вам сильно повезло.
Как всегда, в зависимости от ситуации задачу можно решить разными способами. Допустим, вы решили воспользоваться описанной выше идеей с неизменяемыми данными, и каждый пользователь в вашем проекте читает данные только из своего бакета. В этом случае «транзакцию» между бакетами А и Б можно выполнить по предельно простому алгоритму:
Шаги (2) и (3) могут выполняться параллельно. Если выполнение кода прервется на шаге (2), (3) или (4), «транзакцию» всегда можно будет докатить (специально предусмотренным для этого процессом). Это возможно по той причине, что операции (2) и (3) идемпотентны — их повторное выполнение приводит к тому же результату. При этом, поскольку пользователь читает данные только из своего бакета, с его точки зрения данные всегда консистентны.
Само собой разумеется, это не настоящие транзакции, но для многих проектов их будет более, чем достаточно. При определенных условиях этот подход можно применить даже в случае, если данные в бакетах изменяемые.
Описание более универсального подхода можно найти в блоге Дениса Рысцова. Также этот прием описан как минимум в документации к MongoDB и блоге CockroachDB. В общих чертах алгоритм примерно такой:
Важно! Приведенное описание предполагает, что каждая операция чтения или записи выполняется в отдельной транзакции при уровне изоляции serializable. Или, в более общем случае, если СУБД не поддерживает транзакций, в одну CAS-операцию. Однако выполнение нескольких операций в одной транзакции не влияет на корректность алгоритма.
Этот алгоритм довольно неприятно применять по той причине, что абсолютно все транзакции, включая локальные, должны понимать, как трактовать локальные изменения. Алгоритм обеспечивает уровень изоляции repeatable read. Это уровень изоляции менее строгий, чем snapshot isolation и serializable, и на нем возможны некоторые аномалии (phantom read, write skew). Тем не менее, он подходит для многих приложений, если знать об его ограничениях.
Хочу еще раз подчеркнуть важность проставления метки транзакции на шаге (2) не только при записи, но и при чтении. Если этого не делать, другая транзакция может изменить объект, который вы читаете, и при повторном его прочтении вы увидите что-то другое. Если вы точно знаете, что не станете ничего писать в него, то можете просто закэшировать объект в памяти.
Заключение
Горизонтальное масштабирование РСУБД — задача решаемая. Несмотря на сложность некоторых описанных выше моментов, это ничто по сравнению со сложностями, которые вас ждут при использовании в проекте исключительно NoSQL решений. В частности, для обеспечения какой-либо консистентности придется делать распределенные транзакции, как было описано выше, практически на все.
Как вы могли убедиться, тут довольно сложно представить универсальное решение, подходящее абсолютно всем и всегда. И это мы еще упустили из виду, например, такие важные вопросы, как репликация между несколькими ДЦ и снятие консистентных бэкапов с множества репликасетов! Именно ввиду существования огромного количества возможных решений мы не рассматривали вопрос автоматизации всего описанного выше.
Надеюсь, что вы нашли представленный выше материал интересным. Как обычно, если у вас есть вопросы или дополнения, не стесняйтесь оставлять их в комментариях!
Дополнение: В этом контексте вас также может заинтересовать статья Поднимаем кластер CockroachDB из трех нод.