- PVSM.RU - https://www.pvsm.ru -
Начиналось всё с банального желания пополнить свое резюме парой строчек. Листала сайты с разными проектами, чтобы в резюме было что‑то посерьёзнее утилит для диагностики. Наткнулась на идею написать 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.
Вступление: хотела пополнить резюме, а написала базу данных [1]
Демонстрация CLI [7]
Документация [8]
Заключение [9]
Я не изобретала велосипед это по сути конструктор, содержащий в себе множество готовых решений. Посмотрев, как устроены LevelDB, RocksDB, BadgerDB, TiKV, я взяла у них некоторые идеи. Получился гибрид.
Мне нужна была отсортированная 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 с собственным управлением памятью. Это будет необходимо, чтобы база держала конкурентную нагрузку.
Когда MemTable переполняется, она сбрасывает все в файл — SSTable. Вот его структура:
┌─────────────┐
│ Header │ ← magic, версия, контрольная сумма заголовка
├─────────────┤
│ Block index │ ← массив «последний ключ блока → смещение»
├─────────────┤
│Bloom filter │
├─────────────┤
│ Block 1 │ ← данные + CRC32 блока
│ Block 2 │
│ ... │
└─────────────┘
Блочный индекс — массив, который для каждого блока SSTable хранит его последний ключ и смещение в файле. Позволяет быстро найти нужный блок бинарным поиском, не читая весь файл.
Префиксное сжатие — способ экономить место в SSTable: если ключи имеют общий префикс (например, user:100, user:101), то общая часть хранится один раз, а различающиеся суффиксы — отдельно.
Bloom‑фильтр — вероятностная структура, которая быстро говорит «ключа точно нет», отсекая лишние чтения с диска. Ложноположительные срабатывания возможны, но они лишь приводят к холостому поиску в блоке, не нарушая корректности.
Контрольные суммы — каждый блок снабжён CRC32. При чтении блока я проверяю сумму, чтобы обнаружить битые данные на диске. Без этого можно было бы молча получить мусор, если файл повреждён.
Идея из RocksDB. Если нужно записать несколько ключей атомарно — либо все, либо ничего. В ScoriaDB WriteBatch сериализуется в один буфер и пишется в WAL одной записью. При восстановлении применяется всё до маркера конца. Если обнаружен обрыв — операции батча не применяются.
Классический LSM страдает амплификацией записи : значение в 1 МБ копируется при каждом компакшене. Десять раз и уже 10 МБ записи, а двадцать — 20 МБ. У Badger я подсмотрела реализацию Value Log (WiscKey‑стиль). Значения >64 байт выносятся в отдельный файл, а в LSM‑дереве хранятся только указатели.
CRC32 значения хранится отдельно — в заголовке записи VLog. Так что по указателю я могу быстро найти значение, проверить его целостность и прочитать.
Я использую mmap для отображения файла VLog в память. Какие проблемы возникают:
После syscall.Munmap (например, при закрытии БД) слайсы, которые ссылаются на этот регион, становятся висячими указателями, то есть любое обращение к ним вызывает SIGSEGV.
Без контроля времени жизни таких слайсов легко получить аварию: горутина читает по старому слайсу, пока основной поток закрыл файл и сделал Munmap.
Текущая реализация копирует данные из mmap‑области в новую кучу, думаю это безопасно и одно лишнее копирование не сыграет. Настоящий zero‑copy требовал бы системы подсчёта ссылок на регионы mmap и гарантии, что никто не держит слайс после закрытия VLog. Пока это остаётся в планах на v0.3.0.
MVCC (Multi‑Version Concurrency Control) даёт каждой записи версию с временной меткой commitTS. Транзакция получает startTS и видит только версии с commitTS <= startTS. Главное: писатели никогда не блокируют читателей. Если транзакция обнаруживает конфликт (кто‑то записал ключ после её начала), она возвращает ошибку, и вызов должен повторить транзакцию — обычно в цикле с несколькими попытками.
Инвертированная метка — трюк, который я подсмотрела в TiKV. Обычно timestamp растёт, и новые записи лежат в конце дерева. Причем хранится не сам timestamp, а его побитовое отрицание (^uint64(now) в Go — это инвертирование всех битов). Благодаря этому свежие записи (с большим commitTS) оказываются в начале итератора. Итератор по умолчанию идёт от новых к старым.
Идея из Bigtable и Cassandra. Независимые LSM‑деревья внутри одной базы. У каждого CF свои настройки: размер MemTable, политика компакшена, а в будущем — TTL.
Реализовано виде map[string]*Engine и общего WAL для атомарности между CF. Без общего WAL при сбое часть CF могла остаться в обновлённом состоянии, а другая — нет. Это была одна из первых грубых ошибок, которую я нашла вообще аж при разработке анализатора логов на этой бд.
Классическая схема из LevelDB: уровни L0 → L1 → L2 → L3. На L0 файлы могут пересекаться по ключам, на L1 и ниже — нет.
minActiveSnapshotTS), чтобы не удалять версии, нужные долгим транзакциям. После компакшена уровень 0 очищается.Сначала была заглушка, потом я сделала multi‑way merge с min‑heap. В планах — добавлять ограничение размеров уровней, чтобы избежать write stall, когда входящая запись опережает компакшн.
Когда при компакшене создаются и удаляются SSTable, нужно атомарно обновлять список файлов. Просто сканировать папку ненадёжно, потому что можно получить неконсистентное состояние.
Я взяла идею из RocksDB: все изменения состава файлов пишутся в журнал MANIFEST. Сейчас каждая запись — это VersionEdit в JSON (с последующим fsync). Это удобно для отладки, но не для продакшена . В v0.2.0 планирую перейти на компактный бинарный формат и пакетную запись изменений.
Пример записи:
{
"level": 1,
"add": ["sstable_000123.sst"],
"delete": ["sstable_000045.sst"]
}
Если во время записи в Value Log случился сбой, файл может повредиться (magic mismatch — специальная сигнатура в начале). Я сделала как в BadgerDB: при открытии проверяю magic. Если не совпадает — переименовываю в .corrupt, создаю новый, восстанавливаю данные из WAL. Записи с указателями на старый VLog пропускаются (они потеряны), но база остаётся работоспособной.
Чтобы тестировать сбои диска, я абстрагировала все файловые операции через интерфейс 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 в тестовом режиме.
RocksDB требует C++ и cgo — сложная сборка, проблемы с кросс‑компиляцией. BadgerDB — чистый Go, но без встроенного сервера.
Я сделала ставку на чистый Go: go build одной командой, никаких внешних зависимостей, работает на всех платформах. А gRPC‑сервер (как в etcd и TiKV) даёт строгую типизацию, HTTP/2 и клиенты на 12+ языках. REST и WebSocket я добавила скорее ради эксперимента; в идеальном мире серверный слой должен быть отдельным процессом или включаться флагом сборки.
Ох сколько было часов уже с уставшими, болючими глазами, но со странным чувством, что это нельзя так оставлять, надо доделать, сделать коммит и спать, возможно некоторый перфекционизм здесь тоже сыграл свою роль.
Как было: самый первый WAL писался через f.Write(). Данные уходили в буфер операционной системы и при перезагрузке просто исчезали.
Как обнаружилось: провела стресс‑тест: записала 10 000 ключей, убила процесс через kill -9, перезапустила базу… пусто.
Исправление: добавила f.Sync() после каждой записи батча. Теперь данные действительно сохраняются.
Цена вопроса: замеры на Intel Core i3‑1215U:
|
Бенчмарк |
Итераций |
Время на операцию |
|---|---|---|
|
|
5000 |
300 456 ns/op |
|
|
1000 |
1 523 400 ns/op |
Запись замедлилась в 5 раз. Но теперь после kill -9 данные не теряются.
Обратите внимание: это тесты одиночных записей с честным fsync на диск. В синтетическом тесте без реальной синхронизации задержка ~1 мкс — но такие цифры не отражают надёжность.
Что дальше: в v0.2.0 добавлю Group Commit — буферизирую несколько записей и делаю один fsync на группу. Должно стать и быстро, и надёжно.
Было: генератор меток был просто 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.
Я писала в VLog и магическое число, и CRC32 каждого значения. Но при чтении проверяла только магическое число, а CRC32 нет.
Как обнаружилось: после очередного краша VLog повредился (магическое число не совпало). Движок создал новый пустой файл, а старые указатели из WAL вели в пустоту. База молча возвращала мусор вместо значений.
Исправление:
При открытии проверяю магическое число. Если не совпадает — переименовываю файл в .corrupt, создаю новый, восстанавливаю данные из WAL.
В методе Get перед возвратом значения сверяю CRC32 блока с записанным.
Изначально он оставлял только самую новую версию каждого ключа, а все старые удалялись без разбора.
В процессе разработки транзакций появились долгие транзакции. Они смотрели на свой startTS, пытались найти нужную версию, а их нет! Потому что компакшн решил, что ее надо убрать.
Решение: добавила глобальный минимум активных снапшотов (minActiveSnapshotTS), атомарную переменную. В компакшене перед удалением версии теперь проверяется: если commitTS этой версии больше, чем minActiveSnapshotTS, то оставляем версию.
Раньше: каждая операция из батча писалась в WAL отдельной записью, тем самым пытаясь экономить на сериализации (и как оказалось неизвестсно зачем)
Как проявилось: тесты на восстановление после сбоя показали, что если процесс умирал после записи 3 операций из 10, при повторном запуске применялись только эти 3.
Теперь: сериализую весь WriteBatch в один буфер, записываю одной записью в WAL с маркерами начала и конца. При восстановлении: если есть маркер конца — применяю всё; если нет — отбрасываю целиком.
Как было: у каждого CF был свой WAL. Мне казалось, так проще и изолированнее
К чему привело: когда батч обновлял два CF, произошёл сбой, после того, как WAL первого CF записался (и даже, возможно, выполнил fsync), но до того, как второй CF начал свою запись. В итоге первый CF сохранил изменения, а второй — нет. Атомарность между CF была потеряна.
Исправление: сделала общий WAL для всех CF. WriteBatch сериализуется в одну запись с идентификаторами CF. При восстановлении теперь читается только один поток и точно известно, куда применять каждую операцию.
Что было до: в публичном API ScanCF я возвращала пустой итератор, если запрошенный Column Family не существовал.
Как выяснилось на практике: в демо‑проекте Scorix (анализатор логов) импортёр забыл создать CF raw. ScanCF молча возвращал пустой результат. Пользователь видел «No logs found» и не понимал, почему.
Исправление: создала errorIterator, который при первом вызове Next() или Err() возвращает ошибку CF not found. Сигнатура метода не изменилась, но пользователь наконец видит причину.
Что было: VersionEdit писался в JSON‑файл, но не вызывал fsync. Всё работало…
К чему привело: после сбоя база не видела SSTable, созданные во время последнего компакшена. Файлы на диске лежали, а метаданные о них как след простыл.
Исправление: добавила f.Sync() после каждой записи в Manifest.
И ещё один fsync, замедляющий компакшн.
Исходная наивность: я проверял только заголовок SSTable, считая, что если заголовок цел — то и всё остальное в порядке.
Как проявилось: в тестах с повреждённым файлом (симуляция битого диска) база продолжала читать блоки и возвращала мусор.
Исправление: добавила CRC32 на каждый блок SSTable. При чтении блока проверяю контрольную сумму. Если не сошлась — блок считается повреждённым.
|
Компонент |
Статус |
Примечание |
|---|---|---|
|
LSM‑движок |
✅ |
MemTable, SSTable (с CRC), Bloom, компакшн |
|
Value Log + mmap |
✅ |
CRC32, fail‑safe, копирование в кучу (zero‑copy будет позже) |
|
WAL + fsync + recovery |
✅ |
После |
|
MVCC + Snapshot Isolation |
✅ |
Атомарный генератор, персистентность lastTS |
|
Интерактивные транзакции |
✅ |
С повторными попытками |
|
Column Families |
✅ |
Атомарность между CF через общий WAL |
|
gRPC / REST / CLI |
✅ |
JWT, роли (admin, readwrite, readonly) |
|
Docker + CI/CD |
✅ |
Одна команда — и база работает |
|
Стресс‑тесты |
✅ |
20+ прогонов с |
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 ./...
Полная документация ScoriaDB доступна в папке [docs/] [10] репозитория на GitHub c подробным описанием всех команд CLI, Column Families, транзакций, админ прав и примеров интеграции
|
Язык |
Документация |
Пример |
|---|---|---|
|
Go (встраиваемый) |
Go-Embedded [11] |
pkg/scoria [12] |
|
Python |
python-doc.md [13] |
example.py [14] |
|
Java |
java-doc.md [15] |
example.java [16] |
|
C++ |
cpp-doc.md [17] |
example.cpp [18] |
ScoriaDB начиналась как строчка в резюме, а стала для меня причиной написать об этом свою статью. Сейчас это рабочий LSM-движок с MVCC, транзакциями, Column Families и собственным Value Log — всё на чистом Go, без внешних зависимостей, с gRPC‑сервером и CLI из коробки. Он не заменит Redis в специфичных для него вещах, но для embedded‑сценариев, локального хранения, анализа логов или учебных проектов, уже сейчас, более чем готов.
Пока я собирала материал для статьи, в голову пришло ещё несколько идей, и между делом появились новые коммиты, исправления и даже фичи. Проект живёт своей жизнью: дорожная карта расписана на несколько версий вперёд, issues открыты, тесты гоняются с -race в CI. И я сильно устала, потому что эти дни были для меня слегка перегруженными...
Исходники на GitHub [19]
Если вы тоже пишете свой движок или просто знаете, как помочь советом, давайте обсуждать в issues — комментарии я тоже читаю.
А вы когда‑нибудь начинали проект чисто для резюме, а получалось что‑то большее?
Автор: norzy
Источник [20]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/tranzaktsii/451109
Ссылки в тексте:
[1] Вступление: хотела пополнить резюме, а написала базу данных: #intro
[2] Архитектура ScoriaDB: как я собирала конструктор: #architecture
[3] Что пошло не так: реальные проблемы и их решения: #problems
[4] Что уже работает, а что ещё нет: #works
[5] Что я вынесла из этого проекта: #lessons
[6] Попробовать у себя: #try
[7] Демонстрация CLI: #cli-demo
[8] Документация: #docs
[9] Заключение: #conclusion
[10] [docs/]: https://github.com/f4ga/ScoriaDB/tree/main/docs
[11] Go-Embedded: https://github.com/f4ga/ScoriaDB/blob/main/docs/README.md#go-embedded-api
[12] pkg/scoria: https://github.com/f4ga/ScoriaDB/tree/main/pkg/scoria
[13] python-doc.md: http://python-doc.md
[14] example.py: http://example.py
[15] java-doc.md: http://java-doc.md
[16] example.java: http://example.java
[17] cpp-doc.md: http://cpp-doc.md
[18] example.cpp: https://github.com/f4ga/ScoriaDB/blob/main/docs/c%2B%2B/example.cpp
[19] GitHub: https://github.com/f4ga/ScoriaDB
[20] Источник: https://habr.com/ru/articles/1032208/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1032208
Нажмите здесь для печати.