|
|
|
Professor Seleznov
|
Введение: Неделю назад я не думала писать такую базу данных Начиналось всё с банального желания пополнить свое резюме парой строчек. Листала сайты с разными проектами, чтобы в резюме было что‑то посерьёзнее утилит для диагностики. Наткнулась на идею написать 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: как я собирала конструктор
- Что пошло не так: реальные проблемы и их решения
- Что уже работает, а что ещё нет
- Что я вынесла из этого проекта
- Попробовать у себя
- Демонстрация CLI
- Документация
- Заключение
- Архитектура 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 очищается. Сначала была заглушка, потом я сделала 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 (без точных дат).
Что я вынесла из этого проекта
- Не нужно боятся переписывать. Три переписывания компакшена, два — WAL, один серьёзный рефакторинг MVCC. Мне становилось страшно, что останется какая‑то глубокая ошибка после этих изменений, ведь до этого я максимально старалась избежать того чтобы пришлось прям кардинально что‑то менять.
- Тесты с-race — с первого дня. Гонку в генераторе меток я нашла бы за день, а не за неделю, если бы включила -race раньше. В моём CI это стало обязательно, до недавних пор я об этом не думала.
- fsync — правда полезная штука. Запись замедлилась в 5 раз (всего лишь), но теперь после kill -9 данные сохраняются. Позже Group Commit амортизирует все затраты, сохраняя надежность.
- LSM — это намного сложнее, чем просто скинуть на диск. Bloom‑фильтр отсекает лишние чтения. Блочный индекс и CRC блоков — ещё пара мелочей, но без них данные либо теряются, либо ищутся вечность. Префиксное сжатие экономит память. Value log в помощь LSM. В сумме эти мелочи и делают базу данных быстрой и надёжной.
- Самое сложное — заставить всё работать вместе. 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, транзакций, админ прав и примеров интеграции
Заключение ScoriaDB начиналась как строчка в резюме, а стала для меня причиной написать об этом свою статью. Сейчас это рабочий LSM-движок с MVCC, транзакциями, Column Families и собственным Value Log — всё на чистом Go, без внешних зависимостей, с gRPC‑сервером и CLI из коробки. Он не заменит Redis в специфичных для него вещах, но для embedded‑сценариев, локального хранения, анализа логов или учебных проектов, уже сейчас, более чем готов. Пока я собирала материал для статьи, в голову пришло ещё несколько идей, и между делом появились новые коммиты, исправления и даже фичи. Проект живёт своей жизнью: дорожная карта расписана на несколько версий вперёд, issues открыты, тесты гоняются с -race в CI. И я сильно устала, потому что эти дни были для меня слегка перегруженными...-
Если вы тоже пишете свой движок или просто знаете, как помочь советом, давайте обсуждать в issues — комментарии я тоже читаю. А вы когда‑нибудь начинали проект чисто для резюме, а получалось что‑то большее?-Источник
|
|
|
|