Tarantool - это СУБД с двумя встроенными движками хранения, которые можно смешивать в одной инстанции и даже в одной схеме: движок задаётся для каждого спейса отдельно.
- memtx - in-memory движок, используется по умолчанию. Все данные и индексы целиком лежат в оперативной памяти, диск нужен только для надёжности (WAL и снапшоты).
- vinyl - дисковый движок на основе LSM-дерева (log-structured merge tree). В памяти держится только горячая часть и кеш, основной объём данных живёт на диске.
Как это устроено внутриДвижок - это не про "быстро/медленно вообще", а про компромисс. memtx платит памятью за латентность. vinyl платит латентностью и write amplification за объём. Выбор делается под профиль нагрузки, а не "по умолчанию".
memtx: всё в Arena, один TX-поток
В Tarantool фиксированное число независимых потоков, которые не делят состояние, а обмениваются сообщениями через очереди. С базой данных работает ровно один поток - transaction processor (TX-поток). Все транзакции в нём выполняются строго последовательно, поэтому в типичных ситуациях memtx работает без блокировок (lock-free): конкуренции за данные просто нет.
Внутри TX-потока есть область памяти под данные - Arena. В ней аллокаторы (проект small) размещают т uples, спейсы и индексы. Основной аллокатор таплов - slab allocator, его статистику отдаёт модуль box.slab. Параллелизм внутри TX-потока обеспечивают файберы - кооперативные сопрограммы, которые сами уступают управление в точках yield.
Надёжность memtx обеспечивают другие потоки:
- iproto - сетевой поток: принимает запрос, парсит, формирует сообщение и отдаёт в TX-поток.
- WAL - пишет каждый изменяющий запрос в write-ahead log (файлы .xlog) на диск.
- снапшот-файбер периодически снимает целый снимок Arena в файл .snap.
vinyl: LSM-дерево, уровни и компакция
vinyl хранит не значения, а операции: REPLACE, DELETE, UPSERT. Каждой операции присвоен LSN (монотонный номер). Дерево упорядочено по ключу по возрастанию, а внутри одного ключа - по LSN по убыванию (свежее сверху).
Свежие записи попадают в L0 - уровень в памяти (в Tarantool это B+*-дерево), размер которого ограничен параметром vinyl.memory. Когда L0 переполняется, он целиком сбрасывается на диск в файл-run (операция dump) и очищается. Раны образуют пирамиду уровней L1, L2 ...: чем ниже, тем старее и крупнее данные.
Компакция (compaction) - это сборка мусора: несколько соседних ранов сливаются в один новый. При встрече двух версий одного ключа остаётся только свежая; пара "вставка + удаление" выкидывается совсем. Удаление физически делается через tombstone (маркер-надгробие) в L0, который проходит вниз по уровням и исчезает при major-компакции на нижнем уровне.
Чтобы не сканировать все уровни при поиске несуществующего ключа (это нужно, например, при вставке в уникальный индекс - "паразитный" скрытый read), vinyl использует Bloom-фильтр на каждый ран: если бит ноль - ключа точно нет. Плюс page index (первый ключ каждой страницы) в кеше RAM, сжатие страниц через zstd, и range tuple cache - кеш диапазонов значений, а не страниц.
Большой индекс делится на ranges (размер - vinyl.range_size): split режет диапазон пополам при переполнении, coalesce склеивает почти пустые. Какой run какому range принадлежит, отслеживает метажурнал .vylog через лёгкие ссылки-slice.

memtx в RAM против дискового LSM vinyl
Ключевые команды и код
Классический box.cfg (1.x/2.x и совместимый режим)
Код: Выделить всё
box.cfg{
memtx_memory = 2 * 1024 * 1024 * 1024, -- 2 GiB на Arena memtx
vinyl_memory = 512 * 1024 * 1024, -- 512 MiB на L0 всех LSM
}
-- спейс в памяти (по умолчанию)
box.schema.space.create('hot', { engine = 'memtx' })
box.space.hot:create_index('pk', { type = 'TREE', parts = {1, 'unsigned'} })
-- дисковый спейс на vinyl
box.schema.space.create('archive', { engine = 'vinyl' })
box.space.archive:create_index('pk', { parts = {1, 'unsigned'} })
box.space.archive:insert{1, 'cold data'}
В 3.x те же настройки задаются под группами memtx и vinyl:
Код: Выделить всё
groups:
default:
replicasets:
r1:
instances:
i1: {}
memtx:
memory: 2147483648 # = memtx.memory
allocator: small
vinyl:
memory: 536870912 # = vinyl.memory (размер L0)
bloom_fpr: 0.05 # вероятность ложного срабатывания фильтра
run_count_per_level: 2
run_size_ratio: 3.5
Поведенческие отличия (моноширинно)
Код: Выделить всё
+-------------------+---------------------------+----------------------------+
| Свойство | memtx | vinyl |
+-------------------+---------------------------+----------------------------+
| Где данные | всё в RAM (Arena) | в основном на диске (LSM) |
| Типы индексов | TREE, HASH, RTREE, BITSET | только TREE |
| Временные спейсы | да | нет |
| len() | точное число таплов | приблизительный максимум |
| count() | константа по времени | переменное время |
| delete() | вернёт удалённый тапл | всегда nil |
| select | без yield (до коммита) | делает yield (get/pairs) |
| index:random() | есть | нет |
+-------------------+---------------------------+----------------------------+
- "vinyl - это просто memtx, который не влезает в RAM". Нет. vinyl - LSM-движок, заточенный под запись: чтения дороже, особенно по холодным данным. У него своя семантика (см. таблицу): len() приблизителен, delete() возвращает nil, count() не константный.
- "vinyl не ест память". Ест: L0 целиком в RAM (vinyl.memory), плюс page index, Bloom-фильтры и range cache. Если памяти под L0 мало, дампы идут чаще и write amplification растёт.
- Write amplification. vinyl физически пишет больше, чем вы изменили - реально 5, 10, иногда 20x. Это плата за LSM. Уменьшается тюнингом run_size_ratio (выше отношение - меньше уровней, но дороже компакция) и автоматическим zstd-сжатием.
- Компакция не успевает за дампами. Если потоки компакции (vinyl_write_threads) отстают, растёт read и space amplification: запросы тормозят, диск пухнет. Это главная эксплуатационная боль vinyl - следите за box.stat.vinyl().
- UPSERT не бесплатен. upsert выполняется отложенно при компакции и не делает скрытого read - но если по одному ключу накопились десятки тысяч непросквошенных upsert, чтение вынуждено "проигрывать" всю их историю. Классические грабли горячего ключа.
- Вторичные индексы и уникальность в vinyl провоцируют скрытые reads. Обновление поля под вторичным индексом требует прочитать старое значение из первичного индекса "под капотом". upsert хорош только без вторичных ключей и без рисков ошибок при отложенном применении.
- Индексы memtx не в снапшоте. При рестарте они строятся заново - на больших спейсах это удлиняет старт.
Задание. Создайте два спейса с одинаковой схемой, но разными движками, и сравните поведение len() и delete().
Код: Выделить всё
box.cfg{}
-- memtx
box.schema.space.create('s_mem', { engine = 'memtx' })
box.space.s_mem:create_index('pk', { parts = {1, 'unsigned'} })
box.space.s_mem:insert{1, 'a'}
print('memtx len =', box.space.s_mem:len()) -- точное: 1
print('memtx delete =', box.space.s_mem:delete{1}) -- вернёт {1, 'a'}
-- vinyl
box.schema.space.create('s_vin', { engine = 'vinyl' })
box.space.s_vin:create_index('pk', { parts = {1, 'unsigned'} })
box.space.s_vin:insert{1, 'a'}
print('vinyl len =', box.space.s_vin:len()) -- приблизительный максимум
print('vinyl delete =', box.space.s_vin:delete{1}) -- nil
Контрольные вопросы
- Почему memtx в типичных ситуациях работает без блокировок, и какой поток у него отвечает за все транзакции?
- Что физически происходит при удалении строки в vinyl и почему vinyl хранит операции, а не значения? Что такое tombstone?
- Что такое write amplification в vinyl, какой порядок он имеет на практике и какими параметрами на него влияют?
- Почему len() в vinyl приблизителен, delete() возвращает nil, а select делает yield - в отличие от memtx?