Rocker — rocksdb драйвер для Erlang

в 11:12, , рубрики: erlang, Erlang/OTP, NIF, nosql, rocksdb, Rust, Программирование

Введение

В интернетах довольно много информации и споров по поводу выбора sql/nosql подхода, а также плюсах и минусах того или иного KV-хранилища. То, что вы сейчас читаете, не является пособием по rocksdb или агитацией за использование именного этого хранилища и моего драйвера к нему. Я хотел бы поделиться промежуточным результатом работы по оптимизации процесса разработки NIF для Erlang. В данной статье представлен работоспособный драйвер для rocksdb, разработанный за пару вечеров.

Итак, в одном из проектов возникла задача надежной обработки большого объема событий. Каждое событие занимает от 50 до 350 байт, в день на один узел генерируется более 80 млн событий. Сразу хочется отметить, что вопросы отказоустойчивости доставки сообщений на узлы не рассматриваются. Также одним из ограничений обработки является атомарное и консистентное изменение группы событий.

Таким образом, основными требованиями к драйверу являются:

  1. Надежность
  2. Производительность
  3. Безопасность (в каноническом смысле)
  4. Функциональность:
    • Все основные kv-функции
    • Column families
    • Транзакции
    • Компрессия данных
    • Поддержка гибкой настройки хранилища
  5. Минимальная кодовая база

Обзор существующих решений

  • erocksdb – решение от разработчиков leofs. К плюсам можно отнести апробацию в реальном проекте. К минусам – устаревшую кодовую базу и отсутствие транзакционности. Данный драйвер основан на rocksdb 4.13.
  • rockse имеет ряд ограничений, например, отсутствие опций конфигурации, но самое главное – все ключи и значения должны быть строками. Он попал в обзор лишь как пример целого ряда драйверов, которые реализуют тот или иной функционал и ограничивают другой.
  • erlang-rocksdb – полнофункциональный проект, разработка которого началась в 2014 году. Как и erocksdb используется в реальных проектах. Имеет большую кодовую базу на С/C++ и широкий функционал. Данный драйвер подойдет для общей практики и использования в большинстве проектов.

После беглого анализа текущей ситуации с драйверами erlang для rocksdb стало понятно, что ни один из них не соответствует требованиям проекта полностью. Хотя можно было бы и использовать erlang-rocksdb, но появилась пара свободных вечеров, а после успешной разработки и внедрения фильтра Блума на Rust и любопытство: возможно ли реализовать все требования текущего проекта и воплотить большинство функций в NIF за короткий промежуток времени?

Rocker

Rocker – это NIF для Erlang, использующий Rust обертку для rocksdb. Ключевыми особенностями являются безопасность, производительность и минимальная кодовая база. Ключи и данные хранятся в бинарном виде, что не накладывает никаких ограничений на формат хранения. На данный момент проект пригоден для использования в сторонних решениях.
Исходный код находится в репозитории проекта.

Обзор API

Открытие базы

Работа с базой возможна в двух режимах:

  1. Общее пространство ключей. В данном режиме все ваши ключи будут помещены в одно множество. 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
      }).

  2. Разбивка на несколько пространств. Ключи сохраняются в так называемые 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 шагов:

  1. поиск файлов
  2. восстановление таблиц путем воспроизведения WAL
  3. извлечение метаданных
  4. запись дескриптора

Создание 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

Источник

Поделиться

* - обязательные к заполнению поля