Ящик для хранения данных в go-приложениях

image

Небольшая заметка о встраиваемой 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 пытается решить такую задачу. Посмотрим, насколько у него это получается.

Благодарности


  • Всем, кто дочитал статью до самого конца
  • Комментаторам, пожелавшим поделиться своим мнением
  • Приславшим в личку инфу по опечаткам и ошибкам в тексте
  • Соседу, включающему музыку по ночам


Ссылки

Репозиторий БД
Описание на русском языке

© Habrahabr.ru