Привет, Хабр. Не так давно для одного моего проекта понадобилась встраиваемая база данных, которая бы хранила элементы в виде ключ-значение, обеспечивала поддержку транзакций, и, опционально, шифровала данные. После непродолжительных поисков, я наткнулся на проект Berkeley DB. Кроме нужных мне возможностей, эта БД предоставляет STL-совместимый интерфейс, который позволяет работать с базой данных, как с обычным (почти обычным) STL-контейнером. Собственно про этот интерфейс речь пойдет ниже.


Berkeley DB


Berkeley DB — это встраиваемая масштабируемая высокопроизводительная БД с открытым исходным кодом. Она доступна бесплатно для использования в open source проектах, но для проприетарных есть существенные ограничения. Поддерживаемые возможности:


  • транзакции
  • лог с упреждающей записью для восстановления после отказов
  • шифрование данных алгоритмом AES
  • репликация
  • индексы
  • средства синхронизации для многопоточных приложений
  • политика доступа — один писатель, множество читателей
  • кеширование

А так же многие другие.


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


Доступен выбор структуры хранения и доступа к данным:


  • Btree — реализация отсортированного сбалансированного дерева
  • Hash — имплементация линейного хеша
  • Heap — для хранения использует heap file, логически разбитый на страницы. Каждая запись идентифицируется страницей и смещением внутри нее. Хранилище организовано таким образом, что удаление записи не требует уплотнения. Это позволяет использовать его при недостатке физического места.
  • Queue — очередь, хранит записи фиксированной длины с логическим номером в качестве ключа. Она спроектирована для быстрой вставки в конец, и поддерживает специальную операцию, которая удаляет и возвращает запись из головы очереди за один вызов.
  • Recno — позволяет сохранить записи как фиксированной, так и переменной длины с логическим номером в качестве ключа. Обеспечивает доступ к элементу по его индексу.

Чтобы избежать неоднозначности, необходимо определить несколько понятий, которые используются при описании работы Berkeley DB.


База данных — хранилище данных в виде ключ-значение. Аналогом базы данных Berkeley DB в других СУБД может служить таблица.


Среда баз данных — оболочка для одной или нескольких баз данных. Определяет общие настройки для всех баз данных, такие как размер кеша, пути хранения файлов, использование и конфигурацию подсистем блокировки, транзакций, логирования.


В типичном случае использования создается и настраивается среда, а в ней одна или несколько баз данных.


STL-интерфейс


Berkeley DB представляет из себя библиотеку, написанную на С. Она имеет биндинги к таким языкам, как Perl, Java, PHP и другие. Интерфейс для С++ представляет из себя оболочку над С кодом с объектами и наследованием. Для того, чтобы сделать возможным доступ к базе данных аналогично операциям с STL-контейнерами, имеется STL-интерфейс, как надстройка над С++. В графическом виде слои интерфейсов выглядят так:



Так, STL-интерфейс позволяет получить элемент из базы данных по ключу (для Btree или Hash) или по индексу (для Recno) аналогично контейнерам std::map или std::vector, найти элемент в БД через стандартный алгоритм std::find_if, проитерировать по всей базе через цикл foreach. Все классы и функции STL-интерфейса Berkeley DB находятся в пространстве имен dbstl, для сокращения, под dbstl будет подразумеваться так же и STL-интерфейс.


Установка


База данных поддерживает большинство Linux-платформ, Windows, Android, Apple iOS и пр.


Для Ubuntu 18.04 достаточно установить пакеты:


  • libdb5.3-stl-dev
  • libdb5.3++-dev

Для сборки из исходников под Linux необходимо установить autoconf и libtool. Последнюю версию исходных кодов можно найти по ссылке.


Для примера я скачал архив с версией 18.1.32 — db-18.1.32.zip. Необходимо распаковать архив и перейти в папку с исходниками:


unzip db-18.1.32.zip
cd db-18.1.32

Далее перемещаемся в директорию build_unix и запускаем сборку и установку:


cd build_unix
../dist/configure --enable-stl --prefix=/home/user/libraries/berkeley-db
make
make install

Добавление в cmake-проект


Для иллюстрации примеров с Berkeley DB используется проект BerkeleyDBSamples.


Структура проекта выглядит следующим образом:


+-- CMakeLists.txt
+-- sample-usage
|   +-- CMakeLists.txt
|   +-- sample-map-usage.cpp
|
+-- submodules
|   +-- cmake
|   |    +-- FindBerkeleyDB

Корневой CMakeLists.txt описывает общие параметры проекта. Исходные файлы с примерами находятся в sample-usage. sample-usage/CMakeLists.txt выполняет поиск библиотек, определяет сборку примеров.


Для подключения библиотеки в проект cmake в примерах применяется FindBerkeleyDB. Он добавлен как подмодуль git в submodules/cmake. При сборке может потребоваться указать BerkeleyDB_ROOT_DIR. Например, для библиотеки выше, установленной из исходников, необходимо указать флаг cmake -DBerkeleyDB_ROOT_DIR=/home/user/libraries/berkeley-db.


В корневом файле CMakeLists.txt необходимо добавить в CMAKE_MODULE_PATH путь к модулю FindBerkeleyDB:


list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/submodules/cmake/FindBerkeleyDB")

После этого в sample-usage/CMakeLists.txt выполняется поиск библиотеки стандартным образом:


find_package(BerkeleyDB REQUIRED)

Далее, добавляем исполняемый файл и линкуем его с библиотекой Oracle::BerkeleyDB:


add_executable(sample-map-usage "sample-map-usage.cpp")
target_link_libraries(sample-map-usage PRIVATE Oracle::BerkeleyDB ${CMAKE_THREAD_LIBS_INIT} stdc++fs)

Практический пример


Для демонстрации применения dbstl разберем простой пример из файла sample-map-usage.cpp. Это приложение демонстрирует работу с контейнером dbstl::db_map в однопоточной программе. Сам контейнер аналогичен std::map и хранит данные в виде пары ключ/значение. В качестве нижележащей структуры БД может использоваться Btree или Hash. В отличии от std::map, для контейнера dbstl::db_map<std::string, TestElement> фактическим типом значения является dbstl::ElementRef<TestElement>. Этот тип возвращается, например, для dbstl::db_map<std::string, TestElement>::operator[]. Он определяет методы для сохранения объекта типа TestElement в БД. Одним из таких методов является operator=.


В примере работа с базой происходит следующим образом:


  • приложение вызывает методы Berkeley DB для доступа к данным
  • эти методы обращаются к кешу для чтения или записи
  • при необходимости идет обращение непосредственно к файлу с данными

Графически этот процесс показан на рисунке:



Для уменьшения сложности примера, в нем не используется обработка исключений. Некоторые методы контейнеров dbstl могут выбрасывать исключения при возникновении ошибок.


Разбор кода


Для работы c Berkeley DB необходимо подключить два заголовочных файла:


#include <db_cxx.h>
#include <dbstl_map.h>

Первый — добавляет примитивы интерфейса C++, а второй — определяет классы и функции для работы с БД, как с ассоциативным контейнером, а так же многие служебные методы. STL-интерфейс располагается в пространстве имен dbstl.


Для хранения используется структура Btree, в качестве ключа выступает std::string, а значением — пользовательская структура TestElement:


struct TestElement{
    std::string id;
    std::string name;
};

В функции main инициализируем библиотеку вызовом dbstl::dbstl_startup(). Он должен располагаться до первого использования примитивов STL-интерфейса.


После этого инициализируем и откроем среду баз данных в директории, которая задана переменной ENV_FOLDER:


auto penv = dbstl::open_env(ENV_FOLDER, 0u, DB_INIT_MPOOL | DB_CREATE);

Флаг DB_INIT_MPOOL отвечает за инициализацию подсистемы кеширования, DB_CREATE — за создание всех необходимых среде файлов. Так же команда регистрирует данный объект в менеджере ресурсов. Он несет ответственность за закрытие всех зарегистрированных объектов (в нем регистрируются так же объекты баз данных, курсоры, транзакции и пр) и очистку динамической памяти. Если уже есть объект среды баз данных и его нужно только зарегистрировать в менеджере ресурсов, то можно воспользоваться функцией dbstl::register_db_env.


Подобная операция выполняется и с базой данных:


auto db = dbstl::open_db(penv, "sample-map-usage.db", DB_BTREE, DB_CREATE, 0u);

Данные на диск будут записаны в файл sample-map-usage.db, который будет создан при отсутствии (благодаря флагу DB_CREATE) в директории ENV_FOLDER. Для хранения используется дерево (параметр DB_BTREE).


В Berkeley DB ключи и значения хранятся как массив байт. Для применения пользовательского типа (в нашем случае TestElement) необходимо задать функции для:


  • получения количества байт под хранения объекта;
  • маршалинга объекта в массив байт;
  • демаршалинга.

В примере этот функционал выполняют статические методы класса TestMarshaller. Он располагает объекты TestElement в памяти, следующим образом:


  • в начало буфера копируется значение длины поля id
  • следом побайтово размещается само содержимое поле id
  • после него копируется размер поля name
  • далее помещается само содержимое из поля name


Опишем функции TestMarshaller:


  • TestMarshaller::restore — заполняет данными из буфера объект TestElement
  • TestMarshaller::size — возвращает размер буфера, который необходим для сохранения указанного объекта.
  • TestMarshaller::store — сохраняет объект в буфере.

Для регистрации функций маршалинга/демаршалинга используется dbstl::DbstlElemTraits:


dbstl::DbstlElemTraits<TestElement>::instance()->set_size_function(&TestMarshaller::size);
dbstl::DbstlElemTraits<TestElement>::instance()->set_copy_function(&TestMarshaller::store);
dbstl::DbstlElemTraits<TestElement>::instance()->set_restore_function(
     &TestMarshaller::restore
);

Инициализируем контейнер:


dbstl::db_map<std::string, TestElement> elementsMap(db, penv);

Вот так выглядит копирование элементов из std::map в созданный контейнер:


std::copy(
  std::cbegin(inputValues), 
  std::cend(inputValues), 
  std::inserter(elementsMap, elementsMap.begin())
);

А вот таким образом можно распечатать содержимое БД на стандартный вывод:


std::transform(
  elementsMap.begin(dbstl::ReadModifyWriteOption::no_read_modify_write(), true), 
  elementsMap.end(), 
  std::ostream_iterator<std::string>(std::cout, "\n"), 
  [](const auto data) -> std::string {
       return  data.first + 
                   "=> { id: " + data.second.id + ", name: " + data.second.name + "}";
    });

Немного необычно выглядит вызов метода begin в примере выше: elementsMap.begin(dbstl::ReadModifyWriteOption::no_read_modify_write(), true).
Такая конструкция используется для получения итератора только на чтение. dbstl не определяет метода cbegin, вместо этого используется параметр readonly (второй по счету) в методе begin. Так же можно использовать константную ссылку на контейнер, чтобы получить итератор только на чтение. Такой итератор разрешает только операцию чтения, при выполнении записи он выбросит исключение.


Почему в коде выше используется итератор только на чтение? Во-первых, выполняется всего лишь операция чтения через итератор. Во-вторых, в документации говорится о том, что он имеет лучшую производительность, по сравнению с обычной версией.


Добавить новую пару ключ/значение, или, если ключ уже существует, обновить значение, так же просто, как и в std::map:


elementsMap["added key 1"] = {"added id 1", "added name 1"};

Как указывалось выше, интрукция elementsMap["added key 1"] возвращает класс-оболочку, у которой переопределен operator=, последующий вызов которого выполняет непосредственое сохранение объекта в базе.


Если необходимо вставить элемент в контейнер:


auto [iter, res] = elementsMap.insert(
    std::make_pair(std::string("added key 2"), TestElement{"added id 2", "added name 2"})
);

Вызов elementsMap.insert возвращает std::pair<итератор, флаг успешности>. Если вставить объект не удалось, то флаг успешности будет false. В противном случае флаг успешности содержит true, а итератор указывает на вставленный объект.


Еще одним способом найти значение по ключу является использование метода dbstl::db_map::find, аналогичному std::map::find:


auto findIter = elementsMap.find("test key 1");

Через полученный итератор можно выполнить доступ к ключу — findIter->first, к полям элемента TestElementfindIter->second.id и findIter->second.name. Для извлечения пары ключ/значение используется оператор разыменования -auto iterPair = *findIter;.


При применении к итератору оператора разыменования (*) или доступа к члену класса (->) происходит обращение к БД и извлечение из нее данных. Причем ранее извлеченные данные, даже если их модифицировали, затираются. Это означает, что в примере ниже, изменения, выполненные над итератором, будут отброшены, и на консоль выведено значение, хранившееся в базе данных.


findIter->second.id = "skipped id";
findIter->second.name = "skipped name";
std::cout << "Found elem for key  " << "test key 1" << 
   ": id: " << findIter->second.id << ", name: " << findIter->second.name  
  << std::endl;

Чтобы этого избежать необходимо получить враппер хранимого объекта из итератора вызовом findIter->second и сохранить его в переменную. Далее, все изменения произвести над этим враппером, а результат записать в БД вызвав метод враппера _DB_STL_StoreElement:


auto ref = findIter->second;
ref.id = "new test id 1";
ref.name = "new test name 1";
ref._DB_STL_StoreElement();

Обновить данные можно еще проще — просто получить враппер инструкцией findIter->second и присвоить ему нужный объект TestElement, как в примере:


if(auto findIter = elementsMap.find("test key 2"); findIter != elementsMap.end()){
        findIter->second = {"new test id 2", "new test name 2"};
}

Перед завершением программы необходимо вызвать dbstl::dbstl_exit(); для закрытия и удаления всех зарегистрированных объектов в менеджере ресурсов.


В заключении


В этой статье сделан краткий обзор основных возможностей контейнеров dbstl на примере dbstl::db_map в простой однопоточной программе. Это лишь небольшое введение и здесь не рассмотрены такие возможности, как транзакционность, блокировки, управление ресурсами, обработка исключений и выполнение в многопоточной среде.


Я не ставил своей целью подробно описать методы и их параметры, для этого лучше обратиться к соответствующей документации по C++ интерфейсу и по STL-интерфейсу

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


  1. yleo
    20.07.2019 20:29
    +4

    Безотносительно к самой статье, хочу раскритиковать как Berkeley DB, так и подход работы с БД как с stl-контейнером.




    Berkeley DB (далее BDB) — очень старый продукт в области, которая активно развивалась в последнее время, перенесла несколько революций и родила массу альтернатив с учетом набитых шишек. Лучшее враг хорошего и сейчас при (пожалуй) любом наборе критериев можно выбрать альтернативу превосходящую BDB. К этому добавляется ряд проблем/недочетов BDB: плохая производительность и/или deadlock-и при конкурентной обработки транзакций, полу-ручное восстановление БД после падений, смена лицензии на AGPL (неприемлема для некоторых проектов).


    BDB является встраиваемым движком хранения, который предлагает больше чем key-value. Но в результате получился комбайн, у которого что-то не так с каждой из features. Поэтому в последние годы (несмотря на усилия и деньги Oracle) разработчики предпочитают мигрировать с BDB, когда им нужна хотя-бы одна features (производительность, надежность, масштабируемость, репликация, шифрование и т.д.) на актуальном для индустрии уровне. Сомнительным, но всё-же аргументом, стало то, что при всей "чудесности" BDB (исходя из проспектов Oracle) удаленный из MySQL 5.1 бэкенд хранение так и не был возвращен (работы и багов много, а толку нет).


    Поэтому рекомендую читателям десять раз подумать и посмотреть на альтернативы, прежде чем запрягать этого "старого мерина" в новые проекты.




    Использование БД как stl-контейнера может порождать массу проблем, так как это разные абстракции. Основное отличие в том, что stl-контейнеры и их интерфейс предназначены для использования внутри одного процесса при монопольном доступе из одного потока выполнения.
    В том время как у БД другое время жизни, (как правило) есть транзакции и доступ из нескольких процессов.


    Тут можно резонно возразить, что достаточно вовремя открывать/закрывать БД и правильно расставить условные begin_transacton() и end_transaction(bool abort). Во многих случаях, особенно простых так и будет. Однако, дьявол в деталях:


    • У операций с БД другая стоимость, поэтому удобно применив stl-алгоритмы к stl-контейнеру вы можете получить неприемлемую производительность или огромное потребление ресурсов.
    • Вам могут потребоваться вложенные транзакции, которых нет у большинства встраиваемых storage engines и высокопроизводительных БД.
    • В некоторых БД вы можете обнаружить, что "запись" живет отдельно от "чтения" и после обновления виртуального stl-контейнера, чтение из него не видит изменений до коммита транзакции и/или сразу после.
    • В некоторых БД повторное чтение может дать другой результат, что позволит сделать трудно-обнаруживаемые ошибки при использовании stl-подхода.
    • В БД эксплуатирующих MVCC (коих большинство) долгое удержание транзакции на фоне изменений данных (скорее всего) приведет к накоплению мусора и/или деградации производительности. При этом вы можете обнаружить, что рестарт транзакции (переход от старого MVCC-снимка к новому) может потребовать "перечитать всё" и непонятно когда это делать.

    Поэтому не советую работать с БД как с stl-контейнером, кроме совсем тривиальных и прозрачных случаев.


    1. sqglobe Автор
      20.07.2019 22:06

      Какой key/value аналог BDB на С/С++ вы можете посоветовать для open source проекта?
      Необходимо, чтобы поддерживались:


      • транзакционность
      • вторичные индексы
      • кроссплатформенность
      • шифрование данных (опционально)


      1. justhabrauser
        20.07.2019 23:04

        Вопрос поставлен некорректно.
        * объем данных (количество записей/размер записи)
        * соотношение CRUD (создание/чтение/обновление/удаление)
        * достаточная скорость («максимальная» — неверный ответ)
        «Серебряной пули» в DB не существует.
        Где-то SQLite обгонит MySQL, где-то проиграет.
        С key-value — и BDB хорош, и Tokyo Cabinet хорош, и Round Robin хорош, и…
        Каждый хорош в своей нише.

        Вы же не пытаетесь заменить молоток, гаечные ключи, отвертку и разводной ключ на одни пассатижи?
        Нет, ну можно конечно… Теоретически. Но идея плохая.


      1. yleo
        20.07.2019 23:23

        Я бы не смешивал key-value и вторичные индексы. Понятно что через первое можно сделать второе, но именно поэтому это разные вещи.


        Следующий важный момент это — вопрос встраиваемости, включая неизбежный компромисс между легковесностью движка (простой встраивания) и предлагаемыми возможностями. Например: хорошая производительность во write-сценариях потребует WAL и запуск rollback/redo при открытии БД, LSM потребует фоновых потоков для выполнения слияния, а репликация потребует event loop + поддержку сети + массу настроек и средств диагностики.


        Если к обозначенным критериям добавить легкость встраивания, то начать можно с SQLite и libfpta, затем Firebird Embedded и MySQL Embedded. Уверен что есть что-то еще подходящее, но нужно смотреть по остальным критериями, начиная с лицензии (пошла мода на двойное лицензирования с бесплатностью для open-source проектов, т.е. все меняется и нужно смотреть по-факту). Другими словами, при встраивании full-featured движка хранения можно прийти к пропорции "один конь / один рябчик".




        UPDATE: Еще стоит подумать над "встраиванием" тарантула.


        1. sqglobe Автор
          21.07.2019 00:55

          Наверное стоило сразу очертить юзкейс. Я потихоньку пилю многопоточное приложение SecureDialogues. Некоторые сущности должны храниться на диске в зашифрованном виде. Между этими сущностями есть отношение один ко многим, в дочернем компоненте есть идентификатор родителя. Так же при удалении/обновлении/вставке объектов генерируется событие (вроде триггера), на которое подписаны другие компоненты.


          Вторичные индексы я планирую использовать для того, чтобы:


          • при удалении объекта, извлекать из базы все его дочерние (по индексу родителя).
          • удалять их из БД
          • выкидывать сообщение об удалении каждого дочернего объекта.

          В принципе скорость работы не так уж важна.


          а тарантул разве встраиваемый?


          1. yleo
            21.07.2019 01:22

            Тарантул не встраиваемый, но (думаю не в вашем случае) можно встроиться в таратнул, в том числе "размазав" логику приложения между lua и C/C++.


          1. yleo
            21.07.2019 01:45

            Какой движок вам подходит должны решить вы сами. Могу предложить вам (неполный) список не-очевидных вопросов, на которые стоит ответить при оценке вариантов:


            • Нужен ли вам WAL (думаю что нет, но всё-же)?
              pro: Наличие WAL позволяет движку обеспечить меньший Write Amplification Factor, в результате меньшее количество IOPS на транзакцию, в результате меньшую latency и более высокий RPS.
              cons: WAL требует обслуживания, в том числе будет отдельный "файл журнала" (или директория с файлами) и некая суета с этими журналами (rollback/redo после аварии, ротация, checkpoints).
            • Насколько большие объекты вам нужно хранить? Как это делает БД и как она себя поведёт в случае (например) "10000 писем по 10 мегабайт"?
            • Насколько большие ключи вам требуются? Каковы ограничения БД и как она себя при этом ведет?
            • Как часто (а самом деле) вам нужно сбрасывать данные на диск с получением гарантии, что последние изменения не будут потеряны при системной аварии (выключении питания)?
            • Насколько "мохнато" выглядит устройство БД при взгляде снаружи: какие внутренние треды там работают, насколько много своего управления памятью, как выглядит БД в файловой системе?
            • Что БД предлагает для сопутствующих подзадач: проверка целостности, резервное копирование, dump/restore, управление размером БД?
            • Насколько большое API (размер h-файла и кол-во функций), как много исходного кода, сможете ли вы в нём ориентироваться, понять что происходит при баг-репорте от пользователя?


          1. yleo
            21.07.2019 02:13

            Исходя из вашей задачи (хранение email-ов) думаю вам также стоит глянуть на libmdbx, либо подумать о её комбинации с упомянутой libfpta (второе работает поверх первого).


            MDBX может без проблем хранить большие объекты (десятки-сотни мегабайт), а ключей в ~1000 байт вам должно хватить. Кроме этого, MDBX используется в схожем проекте, а dartraiden (наверное) выдаст feedback по опыту использования.


            +Добавлю, что это будет в тренде наблюдаемой миграции с Berkeley DB на LMDB (OpenLDAP, Samba, OrangeFS, Postfix, Exim, Cyrus и т.д.), так как MDBX является развитием LMDB ;)


            1. sqglobe Автор
              21.07.2019 10:43

              Спасибо за развернутый ответ. Буду сравнивать варианты


      1. Hixon10
        20.07.2019 23:28

        А github.com/facebook/rocksdb случайно не подходит?


        1. yleo
          20.07.2019 23:51

          Там key-value, т.е. нет вторичных индексов и "колонок" (не путать с Column Families).


          1. RPG18
            22.07.2019 11:07

            В key-value никто не ограничивает формат ключей. Пример вторичного индекса по firstName.


            | Key             | Value                     |
            |-----------------|---------------------------|
            | {id]            | {id, firstName, lastName} |
            | {firstName, id} | {}                        |

            Сам запрос будет иметь вид {firstName, 0}, а дальше делаем range.


            1. yleo
              22.07.2019 11:30

              Все верно и есть несколько способов реализовать "вторичные индексы" поверх key-value.


              В вашем примере, когда внутри key-value только одно key-scpace, это выглядит обычно так:


              |  Key                  | Value                     |
              |-----------------------|---------------------------|
              | {0, 0, id]            | {id, firstName, lastName} |
              | {0, 1, firstName, id} | {}                        |
              | {0, 2, lastName, id}  | {}                        |

              Т.е. добавляются префиксы. Условно здесь первый дополнительный байтик — это номер таблицы, а второй — номер индекса (первичный, вторичный и т.д.). Минус в том, что увеличивается длина всех ключей (это отдельная тема).


              В MDBX доступны множественные key-spaces (aka sub-DB) и есть дополнительные "фишечки": ключи фиксированного размера (не хранится длина) и multi-value с хранением значений во вложенных B+Tree. Поэтому в libfpta индексы располагаются в отдельных пространствах key-value.


              space#0
              |  Key        | Value                     |
              |-------------|---------------------------|
              | {id}        | {id, firstName, lastName} |
              
              space#1
              |  Key        | Value                     |
              |-------------|---------------------------|
              | {firstName} | {id}                      |
              
              space#2
              |  Key        | Value                     |
              |-------------|---------------------------|
              | {lastName}  | {id}                      |
              


  1. yleo
    20.07.2019 23:15

    Пардон, промазал тредом.