Введение
В интернетах довольно много информации и споров по поводу выбора sql/nosql подхода, а также плюсах и минусах того или иного KV-хранилища. То, что вы сейчас читаете, не является пособием по rocksdb или агитацией за использование именного этого хранилища и моего драйвера к нему. Я хотел бы поделиться промежуточным результатом работы по оптимизации процесса разработки NIF для Erlang. В данной статье представлен работоспособный драйвер для rocksdb, разработанный за пару вечеров.
Итак, в одном из проектов возникла задача надежной обработки большого объема событий. Каждое событие занимает от 50 до 350 байт, в день на один узел генерируется более 80 млн событий. Сразу хочется отметить, что вопросы отказоустойчивости доставки сообщений на узлы не рассматриваются. Также одним из ограничений обработки является атомарное и консистентное изменение группы событий.
Таким образом, основными требованиями к драйверу являются:
- Надежность
- Производительность
- Безопасность (в каноническом смысле)
- Функциональность:
- Все основные kv-функции
- Column families
- Транзакции
- Компрессия данных
- Поддержка гибкой настройки хранилища
- Минимальная кодовая база
Обзор существующих решений
- erocksdb – решение от разработчиков leofs. К плюсам можно отнести апробацию в реальном проекте. К минусам – устаревшую кодовую базу и отсутствие транзакционности. Данный драйвер основан на rocksdb 4.13.
- rockse имеет ряд ограничений, например, отсутствие опций конфигурации, но самое главное – все ключи и значения должны быть строками. Он попал в обзор лишь как пример целого ряда драйверов, которые реализуют тот или иной функционал и ограничивают другой.
- erlang-rocksdb – полнофункциональный проект, разработка которого началась в 2014 году. Как и erocksdb используется в реальных проектах. Имеет большую кодовую базу на С/C++ и широкий функционал. Данный драйвер подойдет для общей практики и использования в большинстве проектов.
После беглого анализа текущей ситуации с драйверами erlang для rocksdb стало понятно, что ни один из них не соответствует требованиям проекта полностью. Хотя можно было бы и использовать erlang-rocksdb, но появилась пара свободных вечеров, а после успешной разработки и внедрения фильтра Блума на Rust и любопытство: возможно ли реализовать все требования текущего проекта и воплотить большинство функций в NIF за короткий промежуток времени?
Rocker
Rocker – это NIF для Erlang, использующий Rust обертку для rocksdb. Ключевыми особенностями являются безопасность, производительность и минимальная кодовая база. Ключи и данные хранятся в бинарном виде, что не накладывает никаких ограничений на формат хранения. На данный момент проект пригоден для использования в сторонних решениях.
Исходный код находится в репозитории проекта.
Обзор API
Открытие базы
Работа с базой возможна в двух режимах:
-
Общее пространство ключей. В данном режиме все ваши ключи будут помещены в одно множество. Rocksdb позволяет гибко настраивать опции хранилища под текущие задачи. В зависимости от них базу можно открыть двумя способами:
-
используя стандартный набор опций
rocker:open_default(<<"/project/priv/db_default_path">>) -> {ok, Db}.
Результатом этой операции станет указатель для работы с базой, а база будет заблокирована для любых других попыток открытия. База будет автоматически разблокирована сразу после очистки данного указателя.
- либо настроить опции под задачу
{ok, Db} = rocker:open(<<"/project/priv/db_path">>, #{ create_if_missing => true, set_max_open_files => 1000, set_use_fsync => false, set_bytes_per_sync => 8388608, optimize_for_point_lookup => 1024, set_table_cache_num_shard_bits => 6, set_max_write_buffer_number => 32, set_write_buffer_size => 536870912, set_target_file_size_base => 1073741824, set_min_write_buffer_number_to_merge => 4, set_level_zero_stop_writes_trigger => 2000, set_level_zero_slowdown_writes_trigger => 0, set_max_background_compactions => 4, set_max_background_flushes => 4, set_disable_auto_compactions => true, set_compaction_style => universal }).
-
- Разбивка на несколько пространств. Ключи сохраняются в так называемые column families, причем каждая column family может иметь различные опции. Рассмотрим пример открытия базы со стандартными опциями для всех column families
{ok, Db} = case rocker:list_cf(BookDbPath) of {ok, CfList} -> rocker:open_cf_default(BookDbPath, CfList); _Else -> CfList = [], rocker:open_default(BookDbPath) end.
Удаление базы
Для корректного удаления базы данных необходимо вызвать rocker:destroy(Path).
При этом база не должна использоваться.
Восстановление базы после сбоя
В случае системного сбоя базу можно восстановить с помощью метода rocker:repair(Path)
, Данный процесс состоит из 4 шагов:
- поиск файлов
- восстановление таблиц путем воспроизведения WAL
- извлечение метаданных
- запись дескриптора
Создание column family
Cf = <<"testcf1">>,
rocker:create_cf_default(Db, Cf) -> ok.
Удаление column family
Cf = <<"testcf1">>,
rocker:drop_cf(Db, Cf) -> ok.
CRUD операции
Запись данных по ключу
rocker:put(Db, <<"key">>, <<"value">>) -> ok.
Получение данных по ключу
rocker:get(Db, <<"key">>) -> {ok, <<"value">>} | notfound
Удаление данных по ключу
rocker:delete(Db, <<"key">>) -> ok.
Запись данных по ключу в рамках CF
rocker:put_cf(Db, <<"testcf">>, <<"key">>, <<"value">>) -> ok.
Получение данных по ключу в рамках CF
rocker:get_cf(Db, <<"testcf">>, <<"key">>) -> {ok, <<"value">>} | notfound
Удаление данных по ключу в рамках CF
rocker:delete_cf(Db, <<"testcf">>, <<"key">>) -> ok
Итераторы
Как известно, одним из основных принципов работы rocksdb является упорядоченное хранение ключей. Данная особенность очень полезна в реальных задачах. Чтобы ей воспользоваться нам необходимы итераторы данных. В rocksdb есть несколько режимов прохода по данным (подробные примеры кода можно найти в тестах):
- С начала таблицы. За это в rocker отвечает итератор
{'start'}
- C конца таблицы:
{'end'}
- Начиная с определенного ключа вперед
{'from', Key, forward}
- Начиная с определенного ключа назад
{'from', Key, reverse}
Стоит отметить, что эти режимы также работают и для прохода по данным, хранящимся в column families.
Создание итератора
rocker:iterator(Db, {'start'}) -> {ok, Iter}.
Проверка итератора
rocker:iterator_valid(Iter) -> {ok, true} | {ok, false}.
Создание итератора для CF
rocker:iterator_cf(Db, Cf, {'start'}) -> {ok, Iter}.
Создание префиксного итератора
Префиксный итератор требует явного указания длины префикса при создании базы данных.
{ok, Db} = rocker:open(Path, #{
prefix_length => 3
}).
Пример создания итератора по префиксу “aaa”:
{ok, Iter} = rocker:prefix_iterator(Db, <<"aaa">>).
Создание префиксного итератора для CF
Аналогично предыдущему префиксному итератору, требует явного задания prefix_length
для column family
{ok, Iter} = rocker:prefix_iterator_cf(Db, Cf, <<"aaa">>).
Получения следующего элемента
Метод возвращает следующее ключ/значение, либо ok, если итератор завершился.
rocker:next(Iter) -> {ok, <<"key">>, <<"value">>} | ok
Транзакции
Довольно частое явление – требование одновременной записи изменений группы ключей. Rocker позволяет объединять CRUD операции как в рамках общего множества, так и в рамках CF.
Данный пример иллюстрирует работу с транзакциями:
{ok, 6} = rocker:tx(Db, [
{put, <<"k1">>, <<"v1">>},
{put, <<"k2">>, <<"v2">>},
{delete, <<"k0">>, <<"v0">>},
{put_cf, Cf, <<"k1">>, <<"v1">>},
{put_cf, Cf, <<"k2">>, <<"v2">>},
{delete_cf, Cf, <<"k0">>, <<"v0">>}
]).
Производительность
В наборе тестов можно найти тест производительности. Он показывает около 30к RPS на запись и 200к RPS на чтение на моей машине. В реальных условиях можно ожидать 15-20к RPS на запись и около 120к RPS на чтение при среднем размере данных около 1 Кб на ключ и общего количества ключей больше 1 млрд.
Заключение
Разработка и применение Rocker в нашем проекте позволила уменьшить время отклика системы, повысить надежность, уменьшить время перезапуска. Данные плюсы удалось получить с минимальными затратами на разработку и внедрение.
Лично для себя я сделал вывод, что для Erlang проектов, требующих оптимизации, применение Rust оптимально. На Erlang удается быстро и эффективно реализовать 95% кода, а на Rust переписать/дописать тормозящие 5% без снижения общей надежности системы.
P.S. Есть позитивный опыт разработки NIF для Arbitrary-precision arithmetic в Erlang, который можно оформить в виде отдельной статьи. Хотелось бы уточнить, интересна ли тема NIF на Rust сообществу?
Автор: mr_elzor