golang

Хотелось пополнить резюме, а написала LSM-движок с MVCC, снапшотами и Value Log на чистом Go

  • четверг, 7 мая 2026 г. в 00:00:12
https://habr.com/ru/articles/1032208/

Введение: Неделю назад я не думала писать такую базу данных

Начиналось всё с банального желания пополнить свое резюме парой строчек. Листала сайты с разными проектами, чтобы в резюме было что‑то посерьёзнее утилит для диагностики. Наткнулась на идею написать KV‑хранилище. Естественно, перед тем как что‑то планировать, нужно было разобраться, что из себя это чудо представляет и какие они вообще бывают. И вот тут для меня началось самое интересное.

Случайно получилось, что моя встраиваемая БД — это LSM‑Tree, есть атомарная группа операций, интерактивные транзакции с оптимистичной блокировкой, MVCCинвертированными метками , обнаружением конфликтов, snapshot isolation и управлением активными снапшотами), Value Log , WAL, Compaction, Манифест и даже GC. И если её поднять как сервер, то можно использовать CLI (скоро и веб), и доступна база из всех языков, поддерживающих gRPC (12+). А это пока только первый релиз, в планах ещё ого‑го сколько!

Я слышала, что есть B‑tree и LSM, но, честно говоря, не более названия. Каждая новая статья становилась причиной двух следующих. Самым впечатляющим стало изучение устройства других NoSQL‑решений: конкурентный доступ, оптимизация хранения больших значений, как спастись от отключения питания и многое другое. Я загорелась, понимая, что сильно усложню свой пет‑проект, понаделаю ошибок, потому что не делала такое никогда, принялась за написание плана.

Первый коммит я сделала 22 апреля. Через пару дней у меня уже было работающее ядро: MemTable, WAL, SSTable, Compaction, MVCC, транзакции, Column Families. Ещё через несколько дней — gRPC, REST, WebSocket, CLI, Docker и CI/CD.

Оглавление


Архитектура ScoriaDB: как я собирала конструктор

Я не изобретала велосипед это по сути конструктор, содержащий в себе множество готовых решений. Посмотрев, как устроены LevelDB, RocksDB, BadgerDB, TiKV, я взяла у них некоторые идеи. Получился гибрид.

Архитектура базы данных
Архитектура базы данных

MemTable: почему всё‑таки B‑tree, а не skip list (и когда это станет проблемой)

Мне нужна была отсортированная in‑memory структура. Для чего? Чтобы быстро делать скан по префиксу и потом сбрасывать эти отсортированные данные в SSTable.

Вот как я сравнивала варианты:

  • Хеш‑таблица? нет, она не сохраняет порядок ключей, скан невозможен.

  • sync.Map? Тоже нет, не упорядочена, под write‑heavy нагрузкой медленная.

  • Skip list — Хм, это золотой стандарт LSM (LevelDB, RocksDB, BadgerDB), lock‑free или хотя бы с минимальными блокировками. Но корректная реализация конкурентного skiplist в Go — задача, которая может затянуться и отвлечь от реализации mvp.

  • B‑tree — взяла github.com/google/btree. Стабильный, есть copy‑on‑write, быстро и просто использовать.

Поясню еще один момент, глобальный мьютекс на всю MemTable (реализация B‑tree) означает, что при интенсивной записи все горутины выстраиваются в очередь. Для первого релиза я осознанно пошла на это, выиграв время для реализации других частей. В планах на v0.3.0 — замена на lock‑free skip list с собственным управлением памятью. Это будет необходимо, чтобы база держала конкурентную нагрузку.

SSTable и Bloom‑фильтр

Когда MemTable переполняется, она сбрасывает все в файл — SSTable. Вот его структура:

┌─────────────┐
│   Header    │  ← magic, версия, контрольная сумма заголовка
├─────────────┤
│ Block index │  ← массив «последний ключ блока → смещение»
├─────────────┤
│Bloom filter │
├─────────────┤
│  Block 1    │  ← данные + CRC32 блока
│  Block 2    │
│  ...        │
└─────────────┘
  • Блочный индекс — массив, который для каждого блока SSTable хранит его последний ключ и смещение в файле. Позволяет быстро найти нужный блок бинарным поиском, не читая весь файл.

  • Префиксное сжатие — способ экономить место в SSTable: если ключи имеют общий префикс (например, user:100, user:101), то общая часть хранится один раз, а различающиеся суффиксы — отдельно.

  • Bloom‑фильтр — вероятностная структура, которая быстро говорит «ключа точно нет», отсекая лишние чтения с диска. Ложноположительные срабатывания возможны, но они лишь приводят к холостому поиску в блоке, не нарушая корректности.

  • Контрольные суммы — каждый блок снабжён CRC32. При чтении блока я проверяю сумму, чтобы обнаружить битые данные на диске. Без этого можно было бы молча получить мусор, если файл повреждён.

WriteBatch: атомарные группы операций

Идея из RocksDB. Если нужно записать несколько ключей атомарно — либо все, либо ничего. В ScoriaDB WriteBatch сериализуется в один буфер и пишется в WAL одной записью. При восстановлении применяется всё до маркера конца. Если обнаружен обрыв — операции батча не применяются.

Схема движения данных при записи
Схема движения данных при записи

WiscKey и Value Log: большие значения без амплификации записи

Классический LSM страдает амплификацией записи : значение в 1 МБ копируется при каждом компакшене. Десять раз и уже 10 МБ записи, а двадцать — 20 МБ. У Badger я подсмотрела реализацию Value Log (WiscKey‑стиль). Значения >64 байт выносятся в отдельный файл, а в LSM‑дереве хранятся только указатели.

CRC32 значения хранится отдельно — в заголовке записи VLog. Так что по указателю я могу быстро найти значение, проверить его целостность и прочитать.

mmap и почему zero‑copy в Go — не то, чем кажется

Я использую mmap для отображения файла VLog в память. Какие проблемы возникают:

  • После syscall.Munmap (например, при закрытии БД) слайсы, которые ссылаются на этот регион, становятся висячими указателями, то есть любое обращение к ним вызывает SIGSEGV.

  • Без контроля времени жизни таких слайсов легко получить аварию: горутина читает по старому слайсу, пока основной поток закрыл файл и сделал Munmap.

Текущая реализация копирует данные из mmap‑области в новую кучу, думаю это безопасно и одно лишнее копирование не сыграет. Настоящий zero‑copy требовал бы системы подсчёта ссылок на регионы mmap и гарантии, что никто не держит слайс после закрытия VLog. Пока это остаётся в планах на v0.3.0.

MVCC и инвертированная метка

MVCC (Multi‑Version Concurrency Control) даёт каждой записи версию с временной меткой commitTS. Транзакция получает startTS и видит только версии с commitTS <= startTS. Главное: писатели никогда не блокируют читателей. Если транзакция обнаруживает конфликт (кто‑то записал ключ после её начала), она возвращает ошибку, и вызов должен повторить транзакцию — обычно в цикле с несколькими попытками.

Инвертированная метка — трюк, который я подсмотрела в TiKV. Обычно timestamp растёт, и новые записи лежат в конце дерева. Причем хранится не сам timestamp, а его побитовое отрицание (^uint64(now) в Go — это инвертирование всех битов). Благодаря этому свежие записи (с большим commitTS) оказываются в начале итератора. Итератор по умолчанию идёт от новых к старым.

Column Families: зачем они нужны на самом деле

Идея из Bigtable и Cassandra. Независимые LSM‑деревья внутри одной базы. У каждого CF свои настройки: размер MemTable, политика компакшена, а в будущем — TTL.

Реализовано виде map[string]*Engine и общего WAL для атомарности между CF. Без общего WAL при сбое часть CF могла остаться в обновлённом состоянии, а другая — нет. Это была одна из первых грубых ошибок, которую я нашла вообще аж при разработке анализатора логов на этой бд.

Leveled Compaction: классика

Классическая схема из LevelDB: уровни L0 → L1 → L2 → L3. На L0 файлы могут пересекаться по ключам, на L1 и ниже — нет.

На схеме показан процесс компакшена уровня 0 в ScoriaDB (v0.1.0). Три файла уровня 0, которые могут пересекаться по ключам, сливаются в один новый файл уровня 1 без пересечений. Для слияния используется multi‑way merge с min‑heap. При этом учитываются активные снапшоты (через minActiveSnapshotTS), чтобы не удалять версии, нужные долгим транзакциям. После компакшена уровень 0 очищается.
На схеме показан процесс компакшена уровня 0 в ScoriaDB (v0.1.0). Три файла уровня 0, которые могут пересекаться по ключам, сливаются в один новый файл уровня 1 без пересечений. Для слияния используется multi‑way merge с min‑heap. При этом учитываются активные снапшоты (через minActiveSnapshotTS), чтобы не удалять версии, нужные долгим транзакциям. После компакшена уровень 0 очищается.

Сначала была заглушка, потом я сделала multi‑way merge с min‑heap. В планах — добавлять ограничение размеров уровней, чтобы избежать write stall, когда входящая запись опережает компакшн.

Manifest: журнал метаданных

Когда при компакшене создаются и удаляются SSTable, нужно атомарно обновлять список файлов. Просто сканировать папку ненадёжно, потому что можно получить неконсистентное состояние.

Я взяла идею из RocksDB: все изменения состава файлов пишутся в журнал MANIFEST. Сейчас каждая запись — это VersionEdit в JSON (с последующим fsync). Это удобно для отладки, но не для продакшена . В v0.2.0 планирую перейти на компактный бинарный формат и пакетную запись изменений.

Пример записи:

{
  "level": 1,
  "add": ["sstable_000123.sst"],
  "delete": ["sstable_000045.sst"]
}

Fail‑safe VLog: восстановление после повреждения

Если во время записи в Value Log случился сбой, файл может повредиться (magic mismatch — специальная сигнатура в начале). Я сделала как в BadgerDB: при открытии проверяю magic. Если не совпадает — переименовываю в .corrupt, создаю новый, восстанавливаю данные из WAL. Записи с указателями на старый VLog пропускаются (они потеряны), но база остаётся работоспособной.

VFS‑абстракция для тестирования

Чтобы тестировать сбои диска, я абстрагировала все файловые операции через интерфейс VFS:

type VFS interface {
    Open(name string) (File, error)
    Create(name string) (File, error)
    Rename(old, new string) error
    Remove(name string) error
}

В production — DefaultVFS (обёртка над os), в тестах — мок, который эмулирует ошибки записи, задержки или отказы. Правда, здесь есть тонкость: для mmap нужен реальный файловый дескриптор (*os.File), а не абстрактный vfs.File. Поэтому в тестах, где я подменяю файловую систему моком, VLog не сможет использовать mmap — и мой механизм fail‑safe VLog проверить на моках пока не выйдет. Это ограничение я планирую исправить в v0.3.0, например, сделав mmap опциональным или заменив его на pread в тестовом режиме.

Чистый Go без cgo и gRPC как у etcd

RocksDB требует C++ и cgo — сложная сборка, проблемы с кросс‑компиляцией. BadgerDB — чистый Go, но без встроенного сервера.

Я сделала ставку на чистый Go: go build одной командой, никаких внешних зависимостей, работает на всех платформах. А gRPC‑сервер (как в etcd и TiKV) даёт строгую типизацию, HTTP/2 и клиенты на 12+ языках. REST и WebSocket я добавила скорее ради эксперимента; в идеальном мире серверный слой должен быть отдельным процессом или включаться флагом сборки.

Что пошло не так: реальные проблемы и их решения

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

1. WAL без синхронизации → данные улетали в буфер, а не на диск

Как было: самый первый WAL писался через f.Write(). Данные уходили в буфер операционной системы и при перезагрузке просто исчезали.

Как обнаружилось: провела стресс‑тест: записала 10 000 ключей, убила процесс через kill -9, перезапустила базу… пусто.

Исправление: добавила f.Sync() после каждой записи батча. Теперь данные действительно сохраняются.

Цена вопроса: замеры на Intel Core i3‑1215U:

Бенчмарк

Итераций

Время на операцию

BenchmarkPut_WithoutSync-8

5000

300 456 ns/op

BenchmarkPut_WithSync-8

1000

1 523 400 ns/op

Запись замедлилась в 5 раз. Но теперь после kill -9 данные не теряются.

Обратите внимание: это тесты одиночных записей с честным fsync на диск. В синтетическом тесте без реальной синхронизации задержка ~1 мкс — но такие цифры не отражают надёжность.

Что дальше: в v0.2.0 добавлю Group Commit — буферизирую несколько записей и делаю один fsync на группу. Должно стать и быстро, и надёжно.

2. Гонка в генераторе меток (и как её нашёл ‑race)

Было: генератор меток был просто lastTS++. Без блокировок.

И когда запустила 100 горутин, которые одновременно вызывали NextTimestamp(). Начали появляться дубликаты меток, MVCC ломался, тесты падали нестабильно.

Как нашла: включила go test -race. Без него я бы искала этот баг неделю.

Исправление (первое):

// Было
func (g *TimestampGenerator) Next() uint64 {
    g.lastTS++
    return g.lastTS
}

// Стало
func (g *TimestampGenerator) Next() uint64 {
    return atomic.AddUint64(&g.lastTS, 1)
}

Но этого оказалось мало. При перезапуске базы счётчик lastTS сбрасывался в 1, и новые транзакции могли получить метки, которые уже были меньше уже записанных в SSTable. Это ломало изоляцию снапшотов между запусками.

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

Бонус: я добавила кэш последних версий(lastCommitCache), чтобы проверять конфликты за O(1) без полного сканирования LSM.

3. Value Log без CRC32 → мусор вместо значений

Я писала в VLog и магическое число, и CRC32 каждого значения. Но при чтении проверяла только магическое число, а CRC32 нет.

Как обнаружилось: после очередного краша VLog повредился (магическое число не совпало). Движок создал новый пустой файл, а старые указатели из WAL вели в пустоту. База молча возвращала мусор вместо значений.

Исправление:

  • При открытии проверяю магическое число. Если не совпадает — переименовываю файл в .corrupt, создаю новый, восстанавливаю данные из WAL.

  • В методе Get перед возвратом значения сверяю CRC32 блока с записанным.

4. Компакшн, который убивал снапшоты

Изначально он оставлял только самую новую версию каждого ключа, а все старые удалялись без разбора.

В процессе разработки транзакций появились долгие транзакции. Они смотрели на свой startTS, пытались найти нужную версию, а их нет! Потому что компакшн решил, что ее надо убрать.

Решение: добавила глобальный минимум активных снапшотов (minActiveSnapshotTS), атомарную переменную. В компакшене перед удалением версии теперь проверяется: если commitTS этой версии больше, чем minActiveSnapshotTS, то оставляем версию.

5. WriteBatch терял атомарность

Раньше: каждая операция из батча писалась в WAL отдельной записью, тем самым пытаясь экономить на сериализации (и как оказалось неизвестсно зачем)

Как проявилось: тесты на восстановление после сбоя показали, что если процесс умирал после записи 3 операций из 10, при повторном запуске применялись только эти 3.

Теперь: сериализую весь WriteBatch в один буфер, записываю одной записью в WAL с маркерами начала и конца. При восстановлении: если есть маркер конца — применяю всё; если нет — отбрасываю целиком.

6. Column Families теряли атомарность между CF

Как было: у каждого CF был свой WAL. Мне казалось, так проще и изолированнее

К чему привело: когда батч обновлял два CF, произошёл сбой, после того, как WAL первого CF записался (и даже, возможно, выполнил fsync), но до того, как второй CF начал свою запись. В итоге первый CF сохранил изменения, а второй — нет. Атомарность между CF была потеряна.

Исправление: сделала общий WAL для всех CF. WriteBatch сериализуется в одну запись с идентификаторами CF. При восстановлении теперь читается только один поток и точно известно, куда применять каждую операцию.

7. ScanCF возвращал пустой итератор вместо ошибки

Что было до: в публичном API ScanCF я возвращала пустой итератор, если запрошенный Column Family не существовал.

Как выяснилось на практике: в демо‑проекте Scorix (анализатор логов) импортёр забыл создать CF raw. ScanCF молча возвращал пустой результат. Пользователь видел «No logs found» и не понимал, почему.

Исправление: создала errorIterator, который при первом вызове Next() или Err() возвращает ошибку CF not found. Сигнатура метода не изменилась, но пользователь наконец видит причину.

8. Manifest без fsync → база забывала свежие SSTable

Что было: VersionEdit писался в JSON‑файл, но не вызывал fsync. Всё работало…

К чему привело: после сбоя база не видела SSTable, созданные во время последнего компакшена. Файлы на диске лежали, а метаданные о них как след простыл.

Исправление: добавила f.Sync() после каждой записи в Manifest.

И ещё один fsync, замедляющий компакшн.

9. SSTable не проверял CRC блоков

Исходная наивность: я проверял только заголовок SSTable, считая, что если заголовок цел — то и всё остальное в порядке.

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

Исправление: добавила CRC32 на каждый блок SSTable. При чтении блока проверяю контрольную сумму. Если не сошлась — блок считается повреждённым.


Что уже работает, а что ещё нет

Стабильно работает (v0.1.0)

Компонент

Статус

Примечание

LSM‑движок

MemTable, SSTable (с CRC), Bloom, компакшн

Value Log + mmap

CRC32, fail‑safe, копирование в кучу (zero‑copy будет позже)

WAL + fsync + recovery

После kill -9 восстанавливается

MVCC + Snapshot Isolation

Атомарный генератор, персистентность lastTS

Интерактивные транзакции

С повторными попытками

Column Families

Атомарность между CF через общий WAL

gRPC / REST / CLI

JWT, роли (admin, readwrite, readonly)

Docker + CI/CD

Одна команда — и база работает

Стресс‑тесты

20+ прогонов с -race, включая конфликтный тест

Что пока на костылях или в планах

  • MemTable: B‑tree с глобальным мьютексом → замена на lock‑free skip list (v0.3.0).

  • WAL: Group Commit (v0.2.0).

  • Manifest: бинарный формат (v0.2.0).

  • Value Log GC: ручной режим есть (scoria gc), автоматический (битовая карта, инкрементальный) → v0.3.0.

  • Ограничение размеров уровней в компакшенеv0.3.0.

  • TTL для записейv0.2.0.

  • Raft, шардирование, распределённые транзакции, Sorted Sets, JSON‑документы → после v1.0 (без точных дат).

Что я вынесла из этого проекта

  1. Не нужно боятся переписывать. Три переписывания компакшена, два — WAL, один серьёзный рефакторинг MVCC. Мне становилось страшно, что останется какая‑то глубокая ошибка после этих изменений, ведь до этого я максимально старалась избежать того чтобы пришлось прям кардинально что‑то менять.

  2. Тесты с -race — с первого дня. Гонку в генераторе меток я нашла бы за день, а не за неделю, если бы включила -race раньше. В моём CI это стало обязательно, до недавних пор я об этом не думала.

  3. fsync — правда полезная штука. Запись замедлилась в 5 раз (всего лишь), но теперь после kill -9 данные сохраняются. Позже Group Commit амортизирует все затраты, сохраняя надежность.

  4. LSM — это намного сложнее, чем просто скинуть на диск. Bloom‑фильтр отсекает лишние чтения. Блочный индекс и CRC блоков — ещё пара мелочей, но без них данные либо теряются, либо ищутся вечность. Префиксное сжатие экономит память. Value log в помощь LSM. В сумме эти мелочи и делают базу данных быстрой и надёжной.

  5. Самое сложное — заставить всё работать вместе. MemTable работает, SSTable работает, WAL работает. Каждый по отдельности — отлично. Но когда они начинают взаимодействовать при компакшне и восстановлении, вылезают такие грабли, о которых ты и не догадывалась. Интеграционные и стресс‑тесты — вот где настоящая боль и настоящие открытия.


Попробовать у себя

ScoriaDB открыта под лицензией Apache 2.0.

# Клонировать
git clone https://github.com/f4ga/ScoriaDB.git
cd ScoriaDB

# Быстрый старт через Docker
docker compose -f deployments/docker-compose.yml up --build
# Сервер на :50051 (gRPC) и :8080 (HTTP)
# Логин/пароль при первом запуске: admin/admin

# Или собрать вручную
go build -o scoria-server ./cmd/server
go build -o scoria-cli ./cmd/cli
./scoria-server &
TOKEN=$(./scoria-cli admin auth admin admin)
./scoria-cli --token "$TOKEN" set hello world
./scoria-cli --token "$TOKEN" get hello

# Запустить все тесты (обязательно с -race!)
go test -race ./...

Демонстрация CLI <3

Небольшая история любви
Небольшая история любви

Документация

Полная документация ScoriaDB доступна в папке [docs/] репозитория на GitHub c подробным описанием всех команд CLI, Column Families, транзакций, админ прав и примеров интеграции

Язык

Документация

Пример

Go (встраиваемый)

Go-Embedded

pkg/scoria

Python

python-doc.md

example.py

Java

java-doc.md

example.java

C++

cpp-doc.md

example.cpp

Заключение

ScoriaDB начиналась как строчка в резюме, а стала для меня причиной написать об этом свою статью. Сейчас это рабочий LSM-движок с MVCC, транзакциями, Column Families и собственным Value Log — всё на чистом Go, без внешних зависимостей, с gRPC‑сервером и CLI из коробки. Он не заменит Redis в специфичных для него вещах, но для embedded‑сценариев, локального хранения, анализа логов или учебных проектов, уже сейчас, более чем готов.

Пока я собирала материал для статьи, в голову пришло ещё несколько идей, и между делом появились новые коммиты, исправления и даже фичи. Проект живёт своей жизнью: дорожная карта расписана на несколько версий вперёд, issues открыты, тесты гоняются с -race в CI. И я сильно устала, потому что эти дни были для меня слегка перегруженными...


Исходники на GitHub

Если вы тоже пишете свой движок или просто знаете, как помочь советом, давайте обсуждать в issues — комментарии я тоже читаю.

А вы когда‑нибудь начинали проект чисто для резюме, а получалось что‑то большее?