SimplicityDB, предельно простая встраиваемая KV-базулька, написанная на чистом Erlang

17 марта 2014

Дело было вечером, делать было нечего, и я пошел гулять по интернетам в поисках существующих встраиваемых (embedded) баз данных для Erlang. Вот, скажем, есть у нас приложенька, которая работает с небольшими (десяток-другой гигабайт) объемами не саммых ценных в системе данных. Ну не поднимать же Riak кластер только ради этой приложеньки и не таскать же за ней повсюду PostgreSQL? В общем, посмотрел я на существующие решения (DETS, Mnesia, Bitcask, LevelDB, SQLite, Innostore, HanoiDB) и пошел классно писать очередной ненужный KV-велосипед.

SimplicityDB — это очень простая фигня, даже не база данных на самом деле. Вы пишите в конфиге что-то вроде:

[
    {simplicitydb, [
        {dir, "/var/tmp/simplicitydb"},
        {pool_size, 32},
        {sync, true}
    ]}
].

Здесь dir задает путь к каталогу, куда будут писаться все данные, pool_size задает количество воркеров, которые будут обслуживать базу, а sync говорит этим воркерам делать fsync после каждой записи данных.

Затем запускаете приложеньку:

$ erl -pa ./apps/simplicitydb/ebin -config test.config
1> application:ensure_all_started(simplicitydb).

Запуск может растянутся секунд на пять или больше, если у вас не SSD. При запуске в dir будет создано 256 каталогов, каждый из которых внутри также имеет 256 пока что пустых каталогов. Такая инициализация сделана чтобы воркерам во время работы приходилось делать меньше проверок и созданий каталогов. Воркеров, соответственно, может быть не более 256. Каждый из них работает в своем подмножестве каталогов и не мешается другим.

Теперь вы что-то пишите в базу:

2> simplicitydb:write([some,key], [ {k1, v1}, {k2, v2} ]).

При этом происходит следующее. Ключ сериализуется с помощью term_to_binary, от результата считается crc32, на основе контрольной суммы выбирается воркер и ему делается gen_server:call. Также, чтобы воркер выполнял меньше работы в handle_call, на стороне клиента определяется полное имя файла, в который нужно записать значение. В нашем случае имя файла будет таким:

3> T = term_to_binary([some,key]).
<<131,108,0,0,0,2,100,0,4,115,111,109,101,100,0,3,107,101,121,106>>
4> simplicitydb_utils:hash_to_filepath(erlang:crc32(T)).
"/217/20/458/"
5> simplicitydb_utils:key_to_filename(T).
"idgmaaaaaaacgeaaaehdgpgngfgeaaadglgfhjgk"

Здесь имя файла — это base16 от сериализованного ключа. Благодаря такой схеме базулька может работать на ФС с case insensitive именами файлов. Кроме того, поскольку ключ однозначно отображается в имя файла, не нужно разрешать никаких конфликтов, как пришлось бы, если бы использовались какие-то хэши. С другой стороны, в теории могут возникнуть сложности при использовании слишком длинных ключей.

Получив запрос на чтение, воркер (simplicitydb_storage_srv) проверяет, есть ли уже файл с таким именем и если есть, переименовывает его в старое_имя.bak. Мы не можем просто перезаписывать файлы, ибо если приложение упадет в процессе записи, данные будут потеряны. Затем воркер пишет данные в файл, делает file:datasync/1, если в конфиге прописано sync = true, закрывает файл, удаляет .bak.

Теперь данные можно считать из базы по заданному ключу:

6> simplicitydb:read([ some, key ]).
{ok,[{k1,v1},{k2,v2}]}

Если данных нет, возвращается undefined. Во время чтения воркер проверяет, есть ли файл, соответствующий ключу, с именем чтото.bak. Если есть, значит во время последней записи что-то пошло не так, файл восстанавливается, затем из него производится чтение. На самом деле, при записи такая проверка тоже происходит, в итоге код получается забавным:

file:rename(Backup, Filename),
file:rename(Filename, Backup),

Помимо самих данных каждый файл содержит crc32 хранимых данных. Если при чтении контрольная сумма не сходится, значит данные каким-то образом оказались повреждены и simplicitydb:read/1 возвращает undefined, как будто данных в базе нет. Понятное дело, выполняются все вычисления контрольных сумм на клиенте, чтобы разгрузить gen_server.

Также данные можно удалить:

7> simplicitydb:delete([ some, key ]).
ok

Еще база умеет замораживаться (freeze/0) и размораживатся (unfreeze/0). Когда база заморожена (is_frozen() =:= true), писать в нее нельзя, write/2 вернет frozen. Эту возможность можно использовать при тестировании или в скриптах для создания бэкапов.

В общем-то, это все.

SimplicityDB хороша тем, что это реально очень тупая база данных на файликах. Поэтому в ней можно эффективно хранить всякие там mp3, аттачи к электронной почте и так далее. При этом размер базы ограничен только свободным местом на диске. В теории, можно записать очень-очень много маленьких значений, что заставит базу сильно тупить, но по моим расчетам для этого придется занять около 1 Тб места на диске (256 каталогов * 256 каталогов * 1024 каталога * 4096 файлов, по одному байту данных в каждом + 4 байта на crc32 = 1280 Гб), не считая накладных расходов на каталоги и файлы.

При использовании SimplicityDB вы вряд ли столкнетесь с проблемами, что какие-то индексы перестали помещаться в память (как в случае с Bitcask), что чтение данных иногда занимает 5 мс, а иногда — 5 секунд, что кончился счетчик первичных ключей, или что база выделяет место на диске, но не освобождает его. Кроме того, база позволяет использовать в качестве ключей и значений обычные эрланговые термы, что есть удобно. Наконец, структура базы так проста, что в нее в случае необходимости не страшно вонзиться руками. Реализация всего этого добра занимает 150 не пустых строк кода на Erlang без каких-либо зависимостей.

Плоха SimplicityDB тем, что она нефига не проверена в бою. Мне кажется, что я все предусмотрел, но кто знает. В общем, бэкапы, бэкапы. Еще SimplicityDB медленная. У меня на SSD, в несколько потоков и при хорошей погоде с sync = true получалось 230 записей и 3700 чтений в секунду. Если поставить sync = false, скорость записи сильно возрастает (я видел 1700 записей в секунду), но в этом случае вы пишите в кэш ФС, а не на диск, со всеми вытекающими. В общем, для работы с временными рядами базулька не годится. Но если у вас небольшие нагрузки, много данных и меняются они раз в пол года, я бы присмотрелся.

Интересно в базульке то, что поверх нее можно наделать разных прикольных динамических оперденей. Можно прикрутить кэшик и увеличить скорость, можно поднять менеджер локов и получить некое подобие транзакций. Можно без труда реализовать подписку на обновление БД, что, например, не совсем понятно, как сделать в случае с Riak’ом. Можно даже попытаться изобразить подобие индексов на каких-нибудь dict или ETS.

Исходники вы найдете в этом репозитории. Шлите багрепорты и пуллреквесты, не стесняйтесь. Буду рад, если кому-нибудь это пригодится на практике. Если нет — все равно буду рад, потому что писать SimplicityDB было занятным code kata.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.