Originally published on Habr

В 2016-м я выступил на Highload с докладом про Vinyl, движок для хранения данных на диске в Tarantool. С тех пор мы добавили много новых возможностей, но хранение данных на диске — такая объемная тема, что основы, о которых идет речь в этой статье, совсем не изменились.

Vinyl

Почему мы написали новый движок?

Как известно, Tarantool — транзакционная, персистентная СУБД, которая хранит 100% данных в оперативной памяти. Основные преимущества хранения в оперативной памяти — это скорость работы и простота использования: базу данных не нужно тюнить, тем не менее, производительность остаётся стабильно высокой.

Несколько лет назад возникла идея расширить продукт классической технологией хранения, когда в памяти находится только кэш, а основной объём находится на диске. Решили, что движок можно выбирать независимо для каждой таблицы, как в MySQL, но с поддержкой транзакций с самого начала.

Рассматривались готовые библиотеки: RocksDB, WiredTiger, ForestDB, NestDB, LMDB. Однако все они предполагают многопоточный доступ с комплексными примитивами синхронизации.

Tarantool использует архитектуру на основе акторов. Обработка транзакций в выделенном потоке позволяет избежать блокировок и накладных расходов, поглощающих до 80% процессорного времени многопоточных СУБД. Проектирование с прицелом на кооперативную многозадачность обеспечило лучший результат.

Процесс Tarantool

Процесс Tarantool состоит из фиксированного количества «ролевых» потоков.

Алгоритм

Существует два поколения решений: B-деревья (MySQL, PostgreSQL, Oracle) и LSM-деревья (Cassandra, MongoDB, CockroachDB). B-деревья считались эффективнее для чтения, LSM — для записи. С распространением SSD преимущества LSM стали очевидны для большинства сценариев.

Классическое B-дерево

Проблемы B-деревьев

B-дерево — сбалансированное дерево из блоков с отсортированными ключ-значение парами. При поиске алгоритм читает log_B(N) блоков, где N — количество элементов.

Пример: дерево из 100 млн элементов по 100 байт, размер блока 4096 байт. В блоке размещается ~40 элементов. Дерево содержит ~2,57 млн блоков, 5 уровней. Все уровни кроме последнего (~10 ГБ) попадают в кэш.

Проблема записи: обновление одного элемента требует чтения блока, изменения 100 байт из 4096 и записи полного блока. Получается 40-кратное увеличение записываемого объёма!

Это явление называется write amplification (коэффициент — отношение фактически записанных данных к необходимым). Read amplification — аналогичное явление для чтений.

Ключевое отличие LSM

Операции в LSM

LSM хранит не данные, а операции: вставки и удаления. Элемент содержит:

  • Ключ и значение
  • Код операции (REPLACE или DELETE)
  • Log sequence number (LSN) — монотонно возрастающий порядковый номер

Дерево упорядочено сначала по возрастанию ключа, затем по убыванию LSN.

Устройство одного уровня

Устройство одного уровня

Наполнение LSM дерева

L0 (level zero) — часть дерева в оперативной памяти. Размер задаётся опцией vinyl_memory. Элементы добавляются в L0 как в обычную структуру данных (Tarantool использует B+*-tree).

Когда L0 переполняется, выполняется dump — запись L0 в файл на диск и освобождение памяти.

Dump

Все дампы образуют последовательность на диске, упорядоченную по LSN. Более новые файлы находятся вверху пирамиды. При слиянии (compaction/merge) несколько старых файлов объединяются в новый. Если встречаются две версии одного ключа, сохраняется только новая. Если ключ был вставлен, а потом удалён, обе операции исключаются.

Compaction

Уровни LSM-пирамиды

Управление формой LSM-дерева

Соотношение размеров файлов на разных уровнях (level_size_ratio) определяет форму пирамиды:

  • Большое соотношение → меньше уровней → быстрее поиск, но больше затрат на compaction
  • Маленькое соотношение → больше уровней → больше поисков, но реже compaction

Write amplification описывается формулой: x×ln(N/L0)/ln(x), где x — соотношение размеров, N — суммарный размер, L0 — размер памяти.

Write amplification

Для канонического примера (100 млн элементов, 256 МБ памяти, vinyl_level_size_ratio=3.5, run_count_per_level=2):

  • Write amplification ≈ 13
  • Read amplification может достигать 150

Поиск

Поиск в LSM-дереве

Необходимо найти последнюю операцию с ключом. Если это DELETE — элемент отсутствует. Если REPLACE — получено значение. В худшем случае (элемент не существует) поиск проходит все уровни L0-Lk.

Частый сценарий: вставка в уникальный индекс требует проверки отсутствия дубликатов. Это приводит к полному поиску. Для оптимизации используются Bloom-фильтры.

Поиск по диапазону

Поиск по диапазону

Поиск по диапазону [24,30)

Поиск всех значений в диапазоне требует просмотра всех уровней дерева. Из всех источников выбирается ключ с максимальным LSN, остальные операции отбрасываются.

Удаление

Зачем хранить удаления (tombstones)?

  • При поиске они сигнализируют об отсутствии значения
  • При слиянии очищают дерево от “мусорных” записей

Удаление, шаг 1

Удаление, шаг 1: вставка в L0

Удаление, шаг 2

Удаление, шаг 2: tombstone проходит через промежуточные уровни

Удаление, шаг 3

Удаление, шаг 3: при major compaction tombstone удаляется из дерева

Удаления не нужно хранить в L0 (только в памяти). После слияния, затрагивающего последний уровень, удаления можно исключить — отсутствие на нижнем уровне означает отсутствие в дереве.

Оптимизация: если удаление сразу следует за вставкой уникального значения (частый случай при обновлении вторичного индекса), операцию удаления можно фильтровать при слиянии промежуточных уровней.

Преимущества LSM

  • Размер файлов: Dump и compaction создают большие файлы (50-100 МБ), что в тысячи раз больше блока B-дерева
  • Компрессия: Большой размер позволяет эффективно сжимать данные перед записью
  • Фрагментация: Отсутствует, элементы следуют друг за другом без пустот
  • Параллелизм: Все операции создают новые файлы, не изменяя старые. Несколько операций могут идти параллельно без конфликтов
  • Транзакции: Хранение старых версий позволяет реализовать MVCC (multi-version concurrency control)

Недостатки LSM и их устранение

Непредсказуемая скорость записи

В классическом LSM добавление даже одного элемента может переполнить L0, что приводит к каскадному переполнению уровней и непредсказуемым задержкам.

Решение Tarantool:

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

Пример: L0 = 256 МБ, скорость записи = 10 МБ/с. Для записи требуется 26 секунд. При 10k запросов/сек с элементом 100 байт нужно зарезервировать ~26 МБ. Полезный L0 сокращается до 230 МБ.

Параметры:

  • vinyl_timeout = 60 сек (таймаут вставки)
  • vinyl_write_threads = 2 (позволяет выполнять dump параллельно с compaction)

Shadow L0 dump

Dump происходит из «shadow» L0, не блокируя новые вставки и чтения

Dump выполняется из “shadow” L0, не блокируя новые вставки и чтения. Освобождение памяти после dump происходит мгновенно благодаря специализированному аллокатору.

Непредсказуемая скорость чтений

Чтение — самая сложная задача в LSM. Главная проблема: большое количество уровней кратно замедляет поиск и требует больше памяти.

Оптимизация чтений

Компрессия и постраничный индекс

При dump или slicing данные разбиваются на страницы. Размер задаётся опцией vinyl_page_size (настраивается для каждого индекса).

Компрессия использует zstd (Facebook). Первый ключ каждой страницы и её смещение в файле добавляются в page index — отдельный файл для быстрого поиска нужной страницы.

Процесс:

  1. Page index кэшируется в памяти
  2. Нужная страница найдена за одно чтение из .run-файла
  3. После чтения и декомпрессии ключ найден бинарным поиском

Чтение и декомпрессия выполняются отдельными потоками (vinyl_read_threads).

Tarantool использует единый формат для всех файлов (.run и .xlog), упрощая backup и восстановление.

Bloom-фильтры

Необходимость проверки отсутствия данных (скрытые чтения) — частая операция:

  • Вставка в уникальный индекс требует проверки отсутствия дубликата
  • Обновление значения вторичного ключа требует удаления старого значения

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

Параметр Tarantool:

  • bloom_fpr = 0.05 (5% false positive ratio) по умолчанию
  • Автоматически строит оптимальные Bloom-фильтры для полного и частичного ключа

Фильтры хранятся в .index-файле вместе с page index и кэшируются в памяти.

Кэширование

Vinyl использует уникальный для транзакционных систем вид кэша: range tuple cache. Отличие от RocksDB/MySQL:

  • Хранит готовые диапазоны значений после чтения и слияния уровней
  • Применим как для одиночного ключа, так и для диапазона
  • Кэшируются только горячие данные, не страницы
  • Память используется оптимально

Размер кэша: vinyl_cache

Управление сборкой мусора

Проблема масштаба:

Дерево из 100 млн записей по 100 байт требует ~10 ГБ на последнем уровне. При слиянии создаётся временный файл такого же размера. Суммарно может потребоваться до 30 ГБ свободного места для 10 ГБ полезных данных.

Проблема распределения:

Если первичный ключ — временной ряд (монотонная последовательность), основные вставки приходятся на правую часть диапазона. Нет смысла переслиивать огромный файл только для добавления нескольких млн записей в хвост.

Если вставки в одну часть диапазона, а чтения из другой, как оптимизировать форму дерева?

Решение: факторизация через ranges

Tarantool создаёт не одно, а множество LSM-деревьев для каждого индекса. Размер каждого поддерева задаётся vinyl_range_size (по умолчанию 1 ГБ). Поддерево называется range.

Факторизация с помощью ranges

Факторизация больших LSM-деревьев с помощью ранжирования

Процесс:

  • Изначально — один range с диапазоном -inf…+inf
  • При превышении vinyl_range_size выполняется split по срединному ключу
  • Получаются два range: один от -inf до X, другой от X до +inf
  • При удалениях и “похудении” выполняется coalesce (объединение соседних деревьев)

Split и coalesce не приводят к слиянию и созданию новых файлов.

Журнал метаданных (.vylog):

  • Специальный журнал отслеживает, какой файл принадлежит какому range
  • Совместим по формату с .xlog
  • Автоматически ротируется при каждом чекпоинте
  • Позволяет избежать пересоздания файлов при split/coalesce

Промежуточная сущность — slice:

Ссылка на файл с указанием диапазона значений ключа. Хранится исключительно в журнале метаданных.

  • Когда ссылок становится 0 — файл удаляется
  • При split/coalesce создаются новые slice, старые удаляются

Журнал хранит записи: <id дерева, id слайса> или <id слайса, id файла, min, max>.

Тяжёлая работа разбиения откладывается до compaction и выполняется автоматически.

Преимущества:

  • Независимое управление L0, dump и compaction для каждого range
  • Процессы управляемые и предсказуемые
  • Мгновенные операции truncate и drop (работают только с журналом метаданных)
  • Фоновое удаление мусора выполняется независимо

Расширенные возможности Vinyl

Upsert

Операция, объединяющая insert и update без скрытых чтений. Содержит:

  • Значение по умолчанию (если данных нет)
  • Список операций обновления (если значение существует)

На этапе выполнения транзакции Tarantool сохраняет всю операцию в LSM. “Выполняется” она только при слиянии.

Операция upsert

Проблема валидации: Некоторые проверки требуют старых данных (например, прибавление числа к строке).

Ограничения: Не применима при наличии вторичных ключей или триггеров (неизбежны скрытые чтения).

Реальный случай:

В production: огромный набор ключей, текущее время как значение. Вставки либо вставляли ключ, либо обновляли время. Чтения редкие.

После пары дней: процесс потреблял 100% CPU, производительность упала до нуля.

Причина: распределение запросов существенно отличалось. Большая часть ключей обновлялась 1-2 раза в сутки, но были горячие ключи с десятками тысяч обновлений. При чтении по такому ключу требовалось прочитать и проиграть историю из десятков тысяч upsert’ов.

L0 не выталкивалась на диск (памяти было достаточно), поэтому до слияния не доходило.

Решение: Фоновый процесс “упреждающих” чтений для ключей с накоплением >10 upsert’ов. Замена стэка на прочитанное значение.

Вторичные ключи

Даже replace при вторичных ключах требует чтения старого значения: его нужно независимо удалить из вторичных индексов.

Обновление вторичного ключа

Оптимизация: если вторичные индексы не уникальны, удаление “мусора” можно перенести в фазу слияния (реализовано в Vinyl).

Транзакции

Append-only природа LSM позволила реализовать полноценные сериализуемые транзакции.

  • Read-only запросы используют старые версии данных, не блокируя запись
  • Менеджер реализует класс MVTO: при конфликте побеждает первая завершённая
  • Блокировок и дедлоков нет
  • Поддержка gap locks (редко встречается в NoSQL)

Развитие менеджера — в ближайших планах.

Заключение

Tarantool с Vinyl прошёл испытание боем в Mail.Ru Group и вне её. Используется в интернет-компаниях, банках, телекоммуникационных компаниях, промышленном секторе. Доступна для скачивания на сайте проекта.

Проект активно растёт и ищет единомышленников для создания СУБД мирового уровня.