Внутренности vinyl: LSM, компакция, тюнинг

Рейтинг: 75.8% · 12 голосов
Исчерпывающий курс по Tarantool 3.x: модель данных, движки memtx и vinyl, Lua и файберы, транзакции и MVCC, SQL, конфигурация (box.cfg и декларативная 3.x), репликация и Raft, шардирование vshard, эксплуатация, безопасность. 47 уроков со схемами.
Ответить
Аватара пользователя
denis_tnt
Сообщения: 47
Зарегистрирован: 11 май 2026, 05:31

Внутренности vinyl: LSM, компакция, тюнинг

Сообщение denis_tnt »

Оглавление курса (47)
  1. Что такое Tarantool: in-memory СУБД и сервер приложений
  2. Архитектура изнутри: процесс, потоки, event-loop
  3. Установка и первый запуск: tt CLI, пакеты, Docker
  4. Интерактив: консоль, admin-консоль, первые команды
  5. Спейсы и кортежи: форматы, типы данных
  6. Типы индексов и их применимость
  7. Движки хранения: memtx vs vinyl
  8. DDL: схема, создание спейсов и индексов, миграции
  9. DML и выборки: insert/update/upsert, итераторы
  10. Персистентность: WAL, снапшоты, recovery
  11. Внутренности memtx: аллокаторы slab/arena, память
  12. Внутренности vinyl: LSM, компакция, тюнинг (вы здесь)
  13. Lua и LuaJIT в Tarantool: box, модули, rocks
  14. Файберы: кооперативная многозадачность, каналы
  15. Транзакции: ACID, изоляция, MVCC
  16. Хранимые процедуры, модули, организация приложения
  17. net.box: удалённые вызовы, async
  18. Пулы соединений, балансировка, реконнект
  19. Ошибки и диагностика: box.error, pcall
  20. Типы и сериализация: MsgPack, decimal, datetime, uuid
  21. SQL в Tarantool: возможности и связь с box
  22. SQL: таблицы, JOIN, подзапросы, представления
  23. SQL: подготовленные выражения, транзакции, Lua-интероп
  24. Классическая конфигурация box.cfg (legacy 1.x/2.x)
  25. Декларативная конфигурация 3.x: config.yaml, иерархия
  26. Роли и приложения в 3.x
  27. Централизованная конфигурация: etcd / config storage
  28. tt CLI глубоко: разработка, сборка, запуск
  29. Cartridge (официальный legacy) и миграция на 3.x
  30. Репликация: replicaset, топологии
  31. Механика репликации: WAL-стриминг, vclock
  32. Синхронная репликация и выборы лидера (Raft)
  33. Жизненный цикл узла: bootstrap, join, rejoin
  34. vshard: router/storage, виртуальные бакеты
  35. Решардинг и rebalancing бакетов
  36. Запросы поверх шардов: map-reduce, crud
  37. Мониторинг: метрики, Prometheus, Grafana
  38. Логирование и аудит
  39. Бэкапы и восстановление
  40. Безопасность: аутентификация, RBAC, TLS
  41. Производительность: профилирование, тюнинг
  42. Обновления: схема, rolling upgrade
  43. Деплой в продакшен: Docker, топология (официальные паттерны)
  44. Администрирование через официальный TCM (Tarantool Cluster Manager)
  45. Коннекторы: Python, Go, Java
  46. Ключевые модули (rocks): crud, metrics, queue, expirationd
  47. Capstone: шардированный отказоустойчивый кластер
Обзор: зачем vinyl и почему LSM

memtx держит 100% данных в RAM. vinyl - дисковый движок для случаев, когда данных больше, чем памяти, и докупить память нереально. Внутри vinyl лежит не B-дерево, а LSM-дерево (log-structured merge tree). Ключевая идея: на запись B-дерево читает и переписывает целый блок (4 Кб) ради изменения 100 байт - это write amplification в десятки раз. LSM же не переписывает данные на месте, а только дописывает в конец файлов (append-only), что резко снижает запись на SSD.

Главное отличие LSM от B-дерева: оно хранит не значения, а операции - REPLACE, DELETE, UPSERT. У каждой операции есть LSN (монотонно растущий номер). Внутри одного дерева всё упорядочено сначала по ключу (по возрастанию), затем в рамках ключа по LSN (по убыванию) - то есть самая свежая версия ключа лежит первой.

Механика: уровни, дамп, компакция

L0 и дамп. Свежие операции пишутся в L0 - часть дерева в RAM (в Tarantool это B+*-дерево). Размер L0 ограничен параметром vinyl_memory. Когда L0 переполняется, он целиком сбрасывается в файл на диске - это дамп (dump), а сам файл называется run (сортированный пробег). После дампа L0 очищается под новые операции. Дамп идёт из теневой ("shadow") копии L0, поэтому новые вставки и чтения не блокируются.

Пирамида уровней. Раны на диске образуют последовательность по LSN: диапазоны LSN в разных ранах не пересекаются, более свежие раны - ближе к вершине пирамиды. Раны примерно одного размера образуют уровень (L1, L2, ...). Соотношение размеров ранов соседних уровней задаёт vinyl_run_size_ratio (по умолчанию ~3.5), а максимум ранов на уровне - vinyl_run_count_per_level (по умолчанию 2).

Компакция. Когда на уровне накапливается больше ранов, чем разрешено, несколько соседних ранов сливаются в один новый - это компакция (она же merge/purge/сборка мусора). При слиянии для одного ключа остаётся только версия с наибольшим LSN; если за вставкой шло удаление - обе операции выбрасываются. Компакция всегда идёт в отдельном потоке, независимо от дампов - это возможно благодаря append-only природе (старые раны после дампа не меняются, компакция лишь создаёт новые).

Tombstone (надгробие). Из append-only файла нельзя "удалить" запись, поэтому DELETE вставляет в L0 маркер-надгробие. Оно проходит сквозь промежуточные уровни и физически удаляется только при major-компакции, затрагивающей самый нижний уровень.

Ranges и slices. Один индекс - это не одно LSM-дерево, а набор поддеревьев - ranges (диапазонов), размер каждого регулирует vinyl_range_size. При переполнении range делается split (разрезается по среднему ключу), при усыхании соседей - coalesce (слияние). Split и coalesce не создают новых ранов: вводится сущность slice - ссылка на часть рана с диапазоном ключей. Всё это пишется в метаданный лог .vylog (формат совместим с .xlog), что делает truncate и drop мгновенными - реально файлы чистит фоновый GC.

Изображение
Путь данных vinyl: L0, дамп, компакция, кэш

Чтение, page index, bloom-фильтры, кэш

При точечном поиске нужна самая свежая операция по ключу: идём с вершины пирамиды вниз и останавливаемся на первом совпадении (если это DELETE - значит ключа нет). В худшем случае (ключа нет) приходится просмотреть все уровни - это и есть высокий read amplification у LSM.

Page index. При дампе/компакции данные рана режутся на страницы размером vinyl_page_size, каждая сжимается через zstd. Первый ключ и смещение каждой страницы пишутся в .index-файл. Все .index целиком кэшируются в RAM, поэтому нужная страница в .run находится за одно обращение к диску.

Bloom-фильтр. Чтобы не сканировать уровни ради отсутствующих ключей (типичный случай - проверка уникальности при вставке, "паразитные" чтения), к каждому рану строится bloom-фильтр. Если хоть один бит на позициях ключа равен 0 - ключа в ране точно нет. Вероятность ложного срабатывания задаёт vinyl_bloom_fpr (по умолчанию 0.05 = 5%). Фильтры лежат в .index и кэшируются.

Range tuple cache. У vinyl уникальный кэш: он хранит не страницы (как RocksDB/MySQL), а диапазоны значений индекса, уже слитые по всем уровням. Поэтому кэш работает и для точечных, и для range-запросов, и в RAM попадают только горячие кортежи. Размер - vinyl_cache.

Команды и тюнинг (двойной трек)

Классический box.cfg (1.x/2.x/3.x).

Код: Выделить всё

box.cfg{
  vinyl_memory          = 1024*1024*1024,  -- размер L0 (квота на запись)
  vinyl_cache           = 256*1024*1024,   -- range tuple cache
  vinyl_run_count_per_level = 2,           -- ранов на уровне до компакции
  vinyl_run_size_ratio  = 3.5,             -- форма пирамиды
  vinyl_range_size      = nil,             -- авто; иначе размер range
  vinyl_page_size       = 8*1024,
  vinyl_bloom_fpr       = 0.05,
  vinyl_read_threads    = 1,
  vinyl_write_threads   = 4,               -- дампы + компакции
  vinyl_timeout         = 60,
}
Декларативный 3.x (config.yaml). Те же параметры лежат под groups/replicasets/instances в секции storage:

Код: Выделить всё

groups:
  group-001:
    replicasets:
      replicaset-001:
        instances:
          instance-001:
            vinyl:
              memory: 1073741824
              cache: 268435456
              run_count_per_level: 2
              run_size_ratio: 3.5
              bloom_fpr: 0.05
              read_threads: 1
              write_threads: 4
              timeout: 60
Создание space и форс-компакция.

Код: Выделить всё

s = box.schema.space.create('t', {engine='vinyl'})
s:create_index('pk', {parts={1,'unsigned'},
                      page_size=8192, run_count_per_level=2, bloom_fpr=0.01})
-- посмотреть статистику движка:
box.stat.vinyl()           -- regulator, scheduler, memory, disk
-- per-index: дампы, компакции, размер
s.index.pk:stat()
-- принудительная major-компакция индекса (с 2.8+):
s.index.pk:compact()
В box.stat.vinyl() смотрите: scheduler.compaction_queue (сколько байт ждёт компакции - если растёт, компакция не успевает), regulator.rate_limit (троттлинг записи), regulator.blocked_writers (фиберы, ждущие квоту L0).

Частые заблуждения и грабли
  • "Изменил bloom_fpr/page_size через index:alter() - сразу применилось". Нет: новые опции действуют только в НОВЫХ файлах. Старые раны перестроятся лишь при компакции - возможно, придётся вызвать index:compact().
  • "Больше кэша - быстрее любой запрос". В vinyl кэш снижает общую нагрузку, а не магически ускоряет всё. Промахи всё равно идут на диск; цель - стабильное время ответа.
  • "UPSERT всегда дешёвый". Он откладывает выполнение до компакции. Если по ключу накопились тысячи upsert без дампа (L0 не переполнился) - чтение проигрывает всю историю. Спасает фоновое squash-сжатие upsert, но не злоупотребляйте upsert на горячих ключах.
  • "len() вернёт точное число кортежей". У vinyl len() и count() приблизительны/переменны по времени, delete() всегда возвращает nil, а select может yield-ить. Это следствие LSM-структуры.
  • "Компакция не успевает = просто медленно". Если compaction_queue растёт, Tarantool троттлит запись (rate_limit), и при всплесках возможны таймауты (vinyl_timeout). Лечится числом vinyl_write_threads и формой пирамиды.
  • Маленький run_size_ratio = высокая пирамида = больше уровней = выше read amplification, но меньше работы на компакцию. Большой ratio - наоборот. Tune под профиль нагрузки (read- vs write-heavy).
Правило большой пирамиды: write amplification растёт примерно как log по основанию x от (N/L0), умноженный на x, где x = run_size_ratio. Read amplification пропорционален числу уровней. Один параметр одновременно оба не улучшит - выбирайте сторону.
Мини-лаба

Создайте vinyl-space, залейте данные так, чтобы прошёл хотя бы один дамп, и понаблюдайте за компакцией.

Код: Выделить всё

box.cfg{vinyl_memory=64*1024*1024}
s = box.schema.space.create('lab', {engine='vinyl'})
s:create_index('pk', {parts={1,'unsigned'}})
for i=1,200000 do s:replace{i, string.rep('x',512)} end
-- посмотрите дампы и раны:
require('json').encode(s.index.pk:stat().disk)
box.stat.vinyl().scheduler        -- dump_count, tasks_*
s.index.pk:compact()              -- форсируем major-компакцию
box.stat.vinyl().scheduler.compaction_queue   -- проследите, как опустеет
Задание: зафиксируйте disk.dump.count до и после заливки и compaction_queue до/после compact(). Объясните, почему число ранов уменьшилось.

Контрольные вопросы
  • Чем дамп отличается от компакции и почему компакция может идти параллельно с дампом?
  • Что физически происходит при DELETE в vinyl и когда tombstone действительно исчезает?
  • Как vinyl_run_size_ratio влияет на форму пирамиды, write- и read-amplification?
  • Зачем нужны bloom-фильтр и page index, и почему range tuple cache эффективнее кэша страниц для range-запросов?
👍2 ❤️3 🔥2 😄 🤔2
Ответить
← Предыдущая глава
Внутренности memtx: аллокаторы slab/arena, память
Следующая глава →
Lua и LuaJIT в Tarantool: box, модули, rocks

Все главы курса «Tarantool: in-memory СУБД и сервер приложений с нуля до продакшена»

Поделиться темой: ✈ Telegram VK
  • Похожие темы

Вернуться в «Tarantool: СУБД и сервер приложений»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 0 гостей