Небольшая заметка о встраиваемой key-value БД под названием Coffer
, написанной на Golang. Если совсем коротко: в остановленном состоянии БД данные лежат на диске, при запуске данные копируются в память. Чтение происходит из памяти. При записи изменяются данные памяти, а изменения записываются в журнал на диск. Максимальный размер хранимых данных ограничен размером оперативной памяти. API позволяет создавать хидеры для записей БД и применять их в транзакциях, сохраняя при этом консистентность данных.
Но сначала небольшое лирическое вступление. Давным давно, когда трава была зеленее, потребовалась мне встраивая key-value БД для go-приложения. Посмотрев по сторонам и потыкавшись в разные пакеты, я как-то не нашёл того, что мне бы понравилось (субъективно), и просто применил решение с внешней реляционной БД. Отличное рабочее решение. Но как говорится, ложечка-то нашлась, а вот осадок остался. Прежде всего хотелось именно нативную, на Go написанную БД, прямо родную-родную. И такие есть, достаточно поглядеть awesome-go. Однако их там не миллион. Это даже удивительно, если учесть, что редок на свете программист, который не писал в своей жизни БД, фреймворк или казуальную игру.
Ну что-же, можно попробовать, и на коленке сваять свой велосипед, с блэкджеком и прочими плюшками. При этом все знают, или по крайней мере догадываются, что написание даже простой key-value БД кажется простым только на первый взгляд. А на самом деле, всё гораздо веселее (и так и получилось). И ещё меня одолевало любопытство насчёт ACID и волновали транзакции. Правда транзакции скорее в финансовом понимании, т.к. я тогда был занят в финтехе.
Безопасность данных
Рассмотрим случай, когда во время работы приложения с активной записью накрылся медным тазом блок питания в компьютере и при этом диск не сломался. Если в этот момент приложение от БД получило ok
, значит данные этой операции не будут потеряны. Если приложение получило отрицательный ответ, то понятное дело, операция не выполнена. Ну и случай, когда приложение отправило запрос, но не получило ответ: эта операция скорей всего не выполнена, но есть маленький шанс, что операция попала в журнал, но ровно в момент отправки ответа произошло отключение энергии.
Как при последнем кейсе узнать, что там было с последними операциями? Это интересный вопрос. Косвенно вы можете об этом догадаться (сделать выводы), посмотрев значение интересующей записи после нового запуска приложения с БД. Однако, если операции достаточно часты, боюсь, это не поможет. Можно посмотреть файл последнего лога (он будет с самым большим номером), но вручную это неудобно. Думаю, в перспективе можно в API добавить возможность просматривать логи (естественно, логи в этом случае не должны удаляться).
Признаюсь честно, сам я шнур из розетки не выдёргивал, т.к. не хочется рисковать железом ради проверки БД. В тестах я просто порчу нормальные файлы логов, и в этом случае, всё происходит так, как я и предполагал. Однако опыта практического использования БД нет, на проде она не работала, и риски есть. Впрочем, для пет-проектов думаю, БД можно юзать достаточно безбоязненно. В общем, обычный disclaimer, гарантий нет.
БД на настоящий момент никак не защищается от использования в двух разных приложениях (или одинаковых, тут это не важно), сконфигурированных работать с одной и той же директорией. Прошу этот момент учитывать! И ещё, поскольку БД встраиваемая, то передавая её в аргументах какой-нибудь ссылочный тип, точно не стоит его менять где-то в параллельной горутине.
Конфигурирование
У базы довольно много параметров, которые можно сконфигурировать, однако практически все они имеют дефолтные значения, поэтому всё можно уместить в одну короткую строку cof, err, wrn := Db(dirPath).Create()
Возвращается ошибка (при ошибке дальнейшая работа с БД запрещена) и варнинг, о котором можно знать, но работе БД это не мешает.
Не буду загромождать текст громоздкими описаниями, при необходимости прошу смотреть их в ридми репозитория — github.com/claygod/coffer/blob/master/README_RU.md#config Обратите внимание на метод Handler, подключающий обработчик для транзакции, о нём я черкну пару строк пониже, здесь же я просто их перечислю:
- Db(dirPath)
- BatchSize(batchSize)
- LimitRecordsPerLogfile(limitRecordsPerLogfile)
- FollowPause(100*time.Second)
- LogsByCheckpoint(1000)
- AllowStartupErrLoadLogs(true)
- MaxKeyLength(maxKeyLength)
- MaxValueLength(maxValueLength)
- MaxRecsPerOperation(1000000)
- RemoveUnlessLogs(true)
- LimitMemory(100 * 1000000)
- LimitDisk(1000 * 1000000)
- Handler(«handler1», &handler1)
- Handler(«handler2», &handler2)
- Handlers(map[string]*handler)
- Create()
API
Насколько возможно, API я сделал простым, да и для key-value базы не стоит слишком мудрить:
- Start — запуск БД
- Stop — остановка БД
- StopHard — остановка невзирая на прямо сейчас исполняемые операции (возможно уберу)
- Save — сохранить снимок текущего состояния БД
- Write — добавить одну запись в БД
- WriteList — добавить несколько записей в БД (режимы strict и optional)
- WriteListUnsafe — добавить несколько записей в БД без оглядки на безопасность данных
- Read — получить одну запись по ключу
- ReadList — получить список записей
- ReadListUnsafe — получить список записей без оглядки на безопасность данных
- Delete — удалить одну запись
- DeleteList — удалить несколько записей в strict/optional режиме
- Transaction — выполнить транзакцию
- Count — сколько записей в БД
- CountUnsafe — сколько записей в БД (чуть быстрей, но unsafe)
- RecordsList — список всех ключей БД
- RecordsListUnsafe — список всех ключей БД (чуть быстрей, но unsafe)
- RecordsListWithPrefix — список ключей с указанным префиксом
- RecordsListWithSuffix — список ключей с указанным окончанием
Небольшие пояснения к API:
- Strict режим — сделай всё или ничего.
- Optional режим — сделай всё, что получится.
- StopHard — возможно, это метод стоит убрать из API, пока не определился.
- Все RecordsList методы не быстрые, т.к. индексов в сторадже сейчас нет, пока это фуллскан.
- Все Unsafe методы более быстрые, но при их использовании консистентность не подразумевается. Их логично использовать на остановленной БД для быстрого её наполнения или ещё чего-то в таком же духе.
- За регулярным обновлением снимка БД следит фолловер, поэтому метод Save тут скорей всего для каких-то особых случаев, когда вы точно хотите создать новый снимок (пока мне на ум такой кейс не приходит, но возможно он есть).
Простой пример использования:
package main
import (
"fmt"
"github.com/claygod/coffer"
)
const curDir = "./"
func main() {
// STEP init
db, err, wrn := coffer.Db(curDir).Create()
switch {
case err != nil:
fmt.Println("Error:", err)
return
case wrn != nil:
fmt.Println("Warning:", err)
return
}
if !db.Start() {
fmt.Println("Error: not start")
return
}
defer db.Stop()
// STEP write
if rep := db.Write("foo", []byte("bar")); rep.IsCodeError() {
fmt.Sprintf("Write error: code `%d` msg `%s`", rep.Code, rep.Error)
return
}
// STEP read
rep := db.Read("foo")
rep.IsCodeError()
if rep.IsCodeError() {
fmt.Sprintf("Read error: code `%v` msg `%v`", rep.Code, rep.Error)
return
}
fmt.Println(string(rep.Data))
}
Транзакции
Как выше уже сказано, моё определение транзакций может не совпадать с общепринятым в БД-строительстве, возможно, их объединяет только идея. В конкретной имплементации транзакция, это некий хидер, заданный на этапе конфигурирования БД (метод Handler
). Когда мы вызываем транзакцию с этим хидером, БД блокирует записи, с которыми будет работать хидер и передаёт их текущие значения на вход хидеру. Хидер манипулирует этими данными так, как ему надо, и возвращает новые значения БД, а та сохраняет их в сторадже. После этого записи разблокируются и становятся доступны для других операций.
В репо есть примеры, которые очень неплохо раскрывают суть использования транзакций. Из любопытства я сделал небольшой финансовый пример, в котором есть дебитная и кредитная операции, трансфер, купля-продажа. Написать этот пример было очень легко, и при этом эта наколеночная реализация вполне консистентна и годится для использования в разных финансовых решениях, или к примеру в логистике.
Важный момент: код хэндлеров не хранится в БД. У меня была идея хранить его в журнале, но это мне показалось слишком расточительным, поэтому я не стал усложнять, и соответственно ответственность за консистентность хэндлеров между разными запусками БД лежит на разработчике кода, использующего БД. Хэндлеры точно нельзя менять, если остановка приложения и БД была сопряжена с крашем. В этом случае надо сначала запустить БД и после этого штатно её остановить — будет создан новый снимок данных. Чтобы не запутаться советую в названии хэндлеров использовать номер версии.
Получение и обработка ответов
БД возвращает репорты с указанием статуса ответа и с данными. Поскольку кодов много, и писать switch с обработкой каждой из них хлопотно, может возникнуть желание проверять на ок. Так делать не следует. Дело в том, что код может иметь статус Ok, Error, Panic. С Ок всё понятно, а что с остальными двумя? Если статус Error, конкретная операция выполнена, или выполнена не полностью. Эту ошибку нужно соответствующим образом обработать в приложении. Однако работать с БД дальше можно (и нужно). Другое дело Panic — работу с БД следует прекратить.
Проверка IsCodeError
упрощает работу со всеми ошибками, поэтому если вас не интересуют детали, работайте дальше.
Проверка IsCodePanic
охватывает все кейсы, при которых работу с БД необходимо прекратить.
В простом случае для обработки ответа достаточно тройного switch:
IsCodeOk
— продолжаем работу в штатном режимеIsCodeError
— логируем ошибку из репорта и работаем дальшеIsCodePanic
— логируем ошибку из репорта и прекращаем работу с БД
Offtop
Для названия выбран один из вариантов перевода слова ящик
на английский язык, предпочёл бы конечно box
, но это слишком популярное слово, надеюсь, coffer
тоже сойдёт.
Тема с ACID мне кажется достаточно холиварная, поэтому я бы сказал, что Coffer стремится к этому, но не факт, и я не утверждаю, что у него это получилось.
Производительность
Я сразу писал БД с учётом параллелизма и конкуренции. Именно в таком режиме она проявляет свою эффективность (хотя это наверно слишком громко сказано). В лежащих ниже результатах бенчмарк демонстрирует пропускную способность в 200к rps. Это конечно искусственный бенч, и реальность будет совсем иной, т.к. многое зависит от размера записываемых данных, количества уже записанных данных, производительности железа и фазы луны. Но тенденция по крайней мере понятна. Если же БД использовать однопоточно, каждый запрос выполнять только после получения ответа на предыдущий, скорость будет медленной, и я бы посоветовал глядеть другие БД, но не Coffer.
- BenchmarkCofferTransactionSequence-4 2000 227928 ns/op
- BenchmarkCofferTransactionPar32HalfConcurent-4 100000 4199 ns/op
Кстати, если кто-то потратит время и склонирует себе репозиторий с Coffer, по возмодности, запустите лежащий в нём бенч. Мне очень интересно, на каких машинах какую производительность покажет БД. Прежде всего, конечно всё зависит от диска. Это мне особенно стало понятно, после того как я не так давно купил себе новый Samsung EVO. Но не беспокойтесь, это не на замену убитому диску. Старичок Toshiba продолжает исправно служить и хранит сейчас в себе мой видеоархив.
Встроенный инмемори стораж пока представлянт собой простой мэп, даже не поделенный на секции. Его конечно можно здорово усовершенствовать, например, чтобы сделать быстрыми выборки ключей по префиксам и суффиксам. Пока я этим не занимался, т.к. основной функционал, так сказать фишку БД я вижу в транзакциях, и узким местом в производительности для транзакций будет работа с диском, и уже потом, работа с памятью.
Лицензия
Сейчас лицензия позволяет хранить в базе до десяти миллионов записей, мне показалось, что это достаточная цифра. Дальнейшие планы по развитию БД в стадии формирования.
В общем случаем мне интересно, чтобы БД использовали как пакет, и ориентировались прежде всего на его API.
Выводы
В последнее время часто встречаюсь с задачей написания сервисов с характеристикой высокой доступности. К сожалению, из-за того, что это почти всегда подразумевает наличие нескольких инстансов, использовать при таком кейсе встраиваемую БД не стоит. Остаётся вариант обычного приложения или сервиса, существующего в одном экземпляре. Это мне кажется более редким кейсом, но тем не менее он есть, и на такой случай неплохо иметь БД, старающуюся по возможности, сберечь хранящиеся в неё данные. Созданный мной Coffer пытается решить такую задачу. Посмотрим, насколько у него это получается.
Благодарности
- Всем, кто дочитал статью до самого конца
- Комментаторам, пожелавшим поделиться своим мнением
- Приславшим в личку инфу по опечаткам и ошибкам в тексте
- Соседу, включающему музыку по ночам
Ссылки
Комментарии (18)
ainu
23.11.2019 22:17+1(вопрос по лицензии) Если у меня когдато станет больше 10 миллионов записей, сколько оно будет стоить? Алсо, 10 миллионов записей — это сколько в ОЗУ примерно (доупастим одна запись килобайт)? Пилю библиотеку поиска, работает в памяти, но ей нужен какой-нибудь способ на диск сбрасывать новые записи, чтобы не потерять что-либо при потере питания например. Сейчас — boltdb, который при добавлении большого количества данных мягко говоря тупит, ищу альтернативу.
claygod Автор
23.11.2019 23:08Попробуйте, подойдёт ли реально для ваших нужд, если да (а это было бы здорово), то я для вас расширю её (детали если что, можно в личке обсудить). Я особо не заморачивался на тему большого количества записей, больше волновал вопрос надёжности.
sgjurano
24.11.2019 00:26Прошу прощения, что вмешиваюсь, но если вы пишете систему поиска по векторам, то рекомендую присмотреться к Vearch.
https://github.com/vearch/
Это система, написанная в jd.com, она уже отлажена в проде на сотнях миллиардов векторов и работает достаточно стабильно.
vase
24.11.2019 00:31Я пока наблюдаю за pebble и надеюсь, что его доделают. Особенно интересно читать про внутренности rocksdb
LeshiyUrban
24.11.2019 12:19В свое время у меня была задача сделать подключение в Go хранения данных в KV хранилищах. Долго не мог выбрать, и в итоге сделал "универсальную" обертку с несколькими бэк-эндами. https://github.com/reddec/storages
Бонусом: всякая разная типо-безопасная кодогенерация. Возможно будет интересно.claygod Автор
24.11.2019 21:11Кстати, хорошая идея. А какое хранилище обычно чаще всего используете?
JekaMas
24.11.2019 13:31Простите, но ваша бд — это
type Records struct {
mtx sync.RWMutex
store *storage
}
Это не про параллелизм и оптимизацию на конкурентный код.
claygod Автор
24.11.2019 21:08Стораж здесь действительно очень упрощённый. Но дело в том, что он не является узким горлышком. Есть планы в перспективе добавить гибкости, перейти на какое-нибудь более lockfree решение (и рассчитанное кстати на более солидные объёмы данных), и стораж заблаговременно сокрыт за интерфейсом, чтобы поменять можно было одной строкой. Но думаю, особого влияния на производительность это не окажет, IMHO конечно.
Я как-то видел симпатичную инфографику в какой-то статье здесь на Хабре, в ней, если мне не изменяет память, в круговой диаграмме было разрисовано, сколько и чего делают современные БД, и работа с данными там занимала весьма небольшую часть круга. Но замечание ваше принимаю, и если и когда я сделаю такие изменения, я с удовольствием поделюсь этой новостью с вами и сообществом, возможно, результат будет гораздо оптимистичней, чем я ожидаю (вот и ещё один пункт в ToDo).JekaMas
24.11.2019 21:20При каких условиях не является? При скольких потоках выполнения и логических ядер?
Проблем несколько, одна из них в том, что лок один. И это быстро убивает прирост от увеличения ядер/процессоров(в гошном смысле).
Но как и всегда, всё надо проверять хорошими и воспроизводимыми бенчмарками.
Lockfree тоже не бесплатно и не панацея.
На мой взгляд, нет двух ключевых вещей: для кого эта база и обертка, каков у пользователя сценарий; зачем-то захардкожены зависимости, начиная с логгера.
*Базы и нужды бывают разные. Или вы про то, что если все базы усреднить? Тогда это вряд ли полезно будет.
claygod Автор
24.11.2019 21:46Насчёт захардкожено — всё подлючено через интерфейсы, начиная с упомянутого логгера. Это сделано с перспективой того, что много чего поменяется, и замена не будет вызывать много боли. Сейчас тем не менее всё собрано в одном месте, мне так просто удобней.
Про параллелизм: я думаю, что понимаю вашу мысль, т.к. мне приходилось делать библиотечку, рассчитанную выжать как можно больше из ядер, и чем их больше (ядер), тем лучше. Эта мысль логичная и не противоречит мои планам.
pawlo16
25.11.2019 12:18Из текста я так и не понял зачем нужно писать (и уж тем более использовать) ещё один kv сторадж на Go вместо того, чтобы использовать готовые, в которых есть весь упомянутый функционал плюс ещё очень много из того, что не упомянуто, и апи более логичный и чистый. Например, индексы, язык запросов, оптимизации. Cui prodest?
loki82
На уровне go можно управлять где хранить данные? Статью не читал, но интересно как?
claygod Автор
При конфигурировании БД вы указываете ей каталог, в котором хранить данные.