Как избежать скачков во времени отклика и потреблении памяти при снятии снимков состояния в СУБД в оперативной памяти

в 11:38, , рубрики: mail.ru, Анализ и проектирование систем, Блог компании Mail.Ru Group, высокая производительность, ОЗУ, СУБД
Как избежать скачков во времени отклика и потреблении памяти при снятии снимков состояния в СУБД в оперативной памяти - 1

Помните мою недавнюю статью «Что такое СУБД в оперативной памяти и как она эффективно сохраняет данные»? В ней я привел краткий обзор механизмов, используемых в СУБД в оперативной памяти для обеспечения сохранности данных. Речь шла о двух основных механизмах: запись транзакций в журнал и снятие снимков состояния. Я дал общее описание принципов работы с журналом транзакций и лишь затронул тему снимков. Поэтому в этой статье о снимках я расскажу более обстоятельно: начну с простейшего способа делать снимки состояния в СУБД в оперативной памяти, выделю несколько связанных с этим способом проблем и подробно остановлюсь на том, как данный механизм реализован в Tarantool.

Итак, у нас есть СУБД, хранящая все данные в оперативной памяти. Как я уже упоминал в моей предыдущей статье, для снятия снимка состояния необходимо все эти данные записать на диск. Это означает, что нам нужно пройтись по всем таблицам и по всем строкам в каждой таблице и записать все это на диск одним файлом через системный вызов write. Довольно просто на первый взгляд. Однако проблема в том, что данные в базе постоянно изменяются. Даже если замораживать структуры данных при снятии снимка, в итоге на диске можно получить неконсистентное состояние базы данных.

Как же добиться состояния консистентного? Самый простой (и грубый) способ — предварительно заморозить всю базу данных, сделать снимок состояния и снова разморозить ее. И это сработает. База данных может пребывать в заморозке довольно продолжительное время. К примеру, при размере данных 256 Гбайт и максимальной производительности жесткого диска 100 Мбайт/с снятие снимка займет 256 Гбайт/(100 Мбайт/с) — приблизительно 2560 секунд, или (опять же приблизительно) 40 минут. СУБД по-прежнему сможет обрабатывать запросы на чтение, но не сможет выполнять запросы на изменение данных. «Что, серьезно?» — воскликнете вы. Давайте посчитаем: при 40 минутах простоя, скажем, в день СУБД находится в полностью рабочем состоянии 97% времени в самом лучшем случае (на деле, конечно, процент этот будет ниже, потому что на длительность простоя будет влиять множество других факторов).

Какие тут могут быть варианты? Давайте приглядимся к тому, что происходит. Мы заморозили все данные лишь потому, что необходимо было скопировать их на медленное устройство. А что если пожертвовать памятью для увеличения скорости? Суть в следующем: мы копируем все данные в отдельную область оперативной памяти, а затем записываем копию на медленный диск. Кажется, этот способ получше, но он влечет за собой как минимум три проблемы разной степени серьезности:

  1. Нам по-прежнему необходимо замораживать все данные. Допустим, мы копируем данные в область памяти со скоростью 1 Гбайт/с (что опять же слишком оптимистично, потому что на деле скорость может составлять 200-500 Мбайт/с для более-менее продвинутых структур данных). 256 Гбайт/(1 Гбайт/с) — это 256 секунд, или около 4 минут. Получаем 4 минуты простоя в день, или 99.7% времени доступности системы. Это, конечно, лучше, чем 97%, но ненамного.
  2. Как только мы скопировали данные в отдельный буфер в оперативной памяти, нужно записать их на диск. Пока идет запись копии, исходные данные в памяти продолжают меняться. Эти изменения как-то нужно отслеживать, например, сохранять идентификатор транзакции вместе со снимком, чтобы было понятно, какая транзакция попала в снимок последней. В этом нет ничего сложного, но тем не менее делать это необходимо.
  3. Удваиваются требования к объему оперативной памяти. На самом деле нам постоянно нужно памяти вдвое больше, чем размер данных; подчеркну: не только для снятия снимков состояния, а постоянно, потому что нельзя просто увеличить объем памяти в сервере, сделать снимок, а потом снова вытащить планку памяти.

Один из способов решить эту проблему — использовать механизм копирования при записи (далее для краткости я буду использовать английскую аббревиатуру COW, от copy-on-write), предоставляемый системным вызовом fork. В результате выполнения данного вызова создается отдельный процесс со своим виртуальным адресным пространством и используемой только для чтения копией всех данных. Только для чтения копия используется потому, что все изменения происходят в родительском процессе. Итак, мы создаем копию процесса и не спеша записываем данные на диск. Остается вопрос: а в чем тут отличие от предыдущего алгоритма копирования? Ответ лежит в самом механизме COW, который используется в Linux. Как упоминалось немного выше, COW — это аббревиатура, означающая copy-on-write, т.е. копирование при записи. Суть механизма в том, что дочерний процесс изначально использует страничную память совместно с родительским процессом. Как только один из процессов изменяет какие-либо данные в оперативной памяти, создается копия соответствующей страницы.

Естественно, копирование страницы приводит к увеличению времени отклика, потому что, помимо собственно операции копирования, происходит еще несколько вещей. Обычно размер страницы равен 4 Кбайт. Предположим, вы изменили небольшое значение в базе данных. Сперва происходит прерывание по отсутствию страницы, т.к. после выполнения вызова fork все страницы родительского и дочернего процессов доступны только для чтения. После этого система переключается в режим ядра, выделяет новую страницу, копирует 4 Кбайт из старой страницы и снова возвращается в пользовательский режим. Это сильно упрощенное описание, подробней о том, что происходит на самом деле, можно почитать по ссылке.

Если внесенное изменение затрагивает не одну, а несколько страниц (что весьма вероятно при использовании таких структур данных, как деревья), вышеописанная последовательность событий будет повторяться снова и снова, что может значительно ухудшить производительность СУБД. При высоких нагрузках это может привести к резкому увеличению времени отклика и даже непродолжительному простою; к тому же в родительском процессе будет произвольно обновляться большое количество страниц, в результате чего может быть скопирована почти вся база данных, что, в свою очередь, может привести к удвоению необходимого объема оперативной памяти. В общем, если вам повезет, обойдется без скачков во времени отклика и простоя базы данных, если же нет — готовьтесь и к тому, и к другому; ах да, и про удвоенное потребление оперативной памяти тоже не забудьте.

Еще одна проблема с fork в том, что этот системный вызов копирует таблицу дескрипторов страниц. Скажем, при использовании 256 Гбайт памяти размер этой таблицы может достигать сотен мегабайтов, поэтому ваш процесс может зависнуть на секунду-другую, что опять же увеличит время отклика.

Использование fork, конечно, не панацея, но это пока лучшее, что у нас есть. На самом деле некоторые популярные СУБД в оперативной памяти до сих пор применяют fork для снятия снимков состояния — например, Redis.

Можно ли тут что-то улучшить? Давайте присмотримся к механизму COW. Копирование там происходит по 4 Кбайт. Если изменяется всего один байт, копируется все равно вся страница целиком (в случае с деревьями — много страниц, даже если перебалансировка не требуется). А что если нам реализовать собственный механизм COW, который будет копировать лишь фактически измененные участки памяти, точнее — измененные значения? Естественно, такая реализация не послужит полноценной заменой системного механизма, а будет использоваться лишь для снятия снимков состояния.

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

Как избежать скачков во времени отклика и потреблении памяти при снятии снимков состояния в СУБД в оперативной памяти - 2

Как избежать скачков во времени отклика и потреблении памяти при снятии снимков состояния в СУБД в оперативной памяти - 3

Как избежать скачков во времени отклика и потреблении памяти при снятии снимков состояния в СУБД в оперативной памяти - 4

Как вы можете видеть, у элементов могут быть как старые, так и новые версии. К примеру, на последнем изображении в табличном пространстве у значений 3, 4, 5 и 8 по две версии (старая и соответствующая ей новая), у остальных же (1, 2, 6, 7, 9) по одной.

Изменения происходят только в более новых версиях. Версии же постарее используются для операций чтения при снятии снимка состояния. Основное отличие нашей реализации механизма COW от механизма системного в том, что мы не копируем всю страницу размером 4 Кбайт, а лишь небольшую часть действительно измененных данных. Скажем, если вы обновите целое четырехбайтное число, наш механизм создаст копию этого числа, и скопированы будут только эти 4 байта (плюс еще несколько байтов как плата за поддержание двух версий одного элемента). А теперь для сравнения посмотрите на то, как ведет себя системный COW: копируется 4096 байтов, происходит прерывание по отсутствию страницы, переключение контекста (каждое такое переключение эквивалентно копированию приблизительно 1 Кбайт памяти) — и все это повторяется несколько раз. Не слишком ли много хлопот для обновления всего-навсего одно целого четырехбайтного числа при снятии снимка состояния?

Мы применяем собственную реализацию механизма COW для снятия снимков в Tarantool начиная с версии 1.6.6 (до этого мы использовали fork).

На подходе новые статьи по этой теме с интересными подробностями и графиками с рабочих серверов Mail.Ru Group. Следите за новостями.

Все вопросы, связанные с содержанием статьи, можно адресовать автору оригинала danikin, техническому директору почтовых и облачных сервисов Mail.Ru Group.

Автор: Mail.Ru Group

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js