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



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


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

Ссылки


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

Комментарии (18)


  1. loki82
    23.11.2019 18:45

    На уровне go можно управлять где хранить данные? Статью не читал, но интересно как?


    1. claygod Автор
      23.11.2019 19:30

      При конфигурировании БД вы указываете ей каталог, в котором хранить данные.


  1. loki82
    23.11.2019 19:39

    Я не про это. Как в go можно объяснить что нужно хранить в памяти а не наhdd


    1. ainu
      23.11.2019 22:13

      Просто в переменные писать.


  1. ainu
    23.11.2019 22:17
    +1

    (вопрос по лицензии) Если у меня когдато станет больше 10 миллионов записей, сколько оно будет стоить? Алсо, 10 миллионов записей — это сколько в ОЗУ примерно (доупастим одна запись килобайт)? Пилю библиотеку поиска, работает в памяти, но ей нужен какой-нибудь способ на диск сбрасывать новые записи, чтобы не потерять что-либо при потере питания например. Сейчас — boltdb, который при добавлении большого количества данных мягко говоря тупит, ищу альтернативу.


    1. claygod Автор
      23.11.2019 23:08

      Попробуйте, подойдёт ли реально для ваших нужд, если да (а это было бы здорово), то я для вас расширю её (детали если что, можно в личке обсудить). Я особо не заморачивался на тему большого количества записей, больше волновал вопрос надёжности.


    1. sgjurano
      24.11.2019 00:26

      Прошу прощения, что вмешиваюсь, но если вы пишете систему поиска по векторам, то рекомендую присмотреться к Vearch.
      https://github.com/vearch/


      Это система, написанная в jd.com, она уже отлажена в проде на сотнях миллиардов векторов и работает достаточно стабильно.


  1. vase
    24.11.2019 00:31

    Я пока наблюдаю за pebble и надеюсь, что его доделают. Особенно интересно читать про внутренности rocksdb


  1. LeshiyUrban
    24.11.2019 12:19

    В свое время у меня была задача сделать подключение в Go хранения данных в KV хранилищах. Долго не мог выбрать, и в итоге сделал "универсальную" обертку с несколькими бэк-эндами. https://github.com/reddec/storages
    Бонусом: всякая разная типо-безопасная кодогенерация. Возможно будет интересно.


    1. claygod Автор
      24.11.2019 21:11

      Кстати, хорошая идея. А какое хранилище обычно чаще всего используете?


      1. LeshiyUrban
        25.11.2019 09:08
        +1

        leveldb для себя и S3 + redis для работы


  1. FreeBa
    24.11.2019 12:39

    А в чем принципиальное отличие от словаря (он же ассоциативный массив) обернутого спинлоком?


    1. JekaMas
      24.11.2019 13:32

      Исходя из кода — ни в чем. Просто навернули слоёв и api. Много захардкожено.


  1. JekaMas
    24.11.2019 13:31

    Простите, но ваша бд — это
    type Records struct {
    mtx sync.RWMutex
    store *storage
    }


    Это не про параллелизм и оптимизацию на конкурентный код.


    1. claygod Автор
      24.11.2019 21:08

      Стораж здесь действительно очень упрощённый. Но дело в том, что он не является узким горлышком. Есть планы в перспективе добавить гибкости, перейти на какое-нибудь более lockfree решение (и рассчитанное кстати на более солидные объёмы данных), и стораж заблаговременно сокрыт за интерфейсом, чтобы поменять можно было одной строкой. Но думаю, особого влияния на производительность это не окажет, IMHO конечно.

      Я как-то видел симпатичную инфографику в какой-то статье здесь на Хабре, в ней, если мне не изменяет память, в круговой диаграмме было разрисовано, сколько и чего делают современные БД, и работа с данными там занимала весьма небольшую часть круга. Но замечание ваше принимаю, и если и когда я сделаю такие изменения, я с удовольствием поделюсь этой новостью с вами и сообществом, возможно, результат будет гораздо оптимистичней, чем я ожидаю (вот и ещё один пункт в ToDo).


      1. JekaMas
        24.11.2019 21:20

        При каких условиях не является? При скольких потоках выполнения и логических ядер?
        Проблем несколько, одна из них в том, что лок один. И это быстро убивает прирост от увеличения ядер/процессоров(в гошном смысле).


        Но как и всегда, всё надо проверять хорошими и воспроизводимыми бенчмарками.


        Lockfree тоже не бесплатно и не панацея.


        На мой взгляд, нет двух ключевых вещей: для кого эта база и обертка, каков у пользователя сценарий; зачем-то захардкожены зависимости, начиная с логгера.


        *Базы и нужды бывают разные. Или вы про то, что если все базы усреднить? Тогда это вряд ли полезно будет.


        1. claygod Автор
          24.11.2019 21:46

          Насчёт захардкожено — всё подлючено через интерфейсы, начиная с упомянутого логгера. Это сделано с перспективой того, что много чего поменяется, и замена не будет вызывать много боли. Сейчас тем не менее всё собрано в одном месте, мне так просто удобней.

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


  1. pawlo16
    25.11.2019 12:18

    Из текста я так и не понял зачем нужно писать (и уж тем более использовать) ещё один kv сторадж на Go вместо того, чтобы использовать готовые, в которых есть весь упомянутый функционал плюс ещё очень много из того, что не упомянуто, и апи более логичный и чистый. Например, индексы, язык запросов, оптимизации. Cui prodest?