1. Введение

В первой части статьи мы обсуждали разработку самого нижнего слоя СУБД Boson - CachedFileIO. Как упоминалось, статистика такого явления как Locality of Reference говорит о том, что в реальных приложениях ~95% запросов к данным локализованы в 10-15% базы данных. При этом среднее соотношение чтения/записи - 70%/30%. Это делает эффективным использование кэша (cache) работающего на основе алгоритма Least Recently Used (LRU). Реализовав его, мы получили 260%-600% прироста скорости чтения при 87%-97% cache hits.

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

Архитектура слоёв СУБД Boson
Архитектура слоёв СУБД Boson

2. Функциональные требования

Следующим после кэша слоем СУБД Boson является хранилище записей RecordFileIO. Это уже первый прообраз базы данных, который начинает приносить прикладную пользу. Сформулируем верхнеуровневую спецификацию требований:

  • Хранение записей произвольной* длины (создание, чтение, изменение, удаление). Произвольную длину будем понимать как бинарные записи размером до 4Gb.

  • Навигация по записям как по двусвязному списку (первая, последняя, следующая, предыдущая), а также служебная возможность прямого указания позиции в файле.

  • Повторное использование пространства удалённых записей.

  • Проверка целостности данных и заголовков на основе контрольных сумм.

3. Структуры данных

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

Структура хранилища записей
Структура хранилища записей

Нарисовать диаграмму заняло гораздо больше времени, чем описать заголовок хранилища и заголовок записи в коде. Они будут иметь следующую простую структуру:

	//----------------------------------------------------------------------------
	// Boson storage header signature and version
	//----------------------------------------------------------------------------
	constexpr uint32_t BOSONDB_SIGNATURE = 0x42445342;
	constexpr uint32_t BOSONDB_VERSION   = 0x00000001;
	
	//----------------------------------------------------------------------------
	// Boson storage header structure (64 bytes)
	//----------------------------------------------------------------------------
	typedef struct {
		uint32_t      signature;           // BOSONDB signature
		uint32_t      version;             // Format version		
		uint64_t      endOfFile;           // Size of file

		uint64_t      totalRecords;        // Total number of records
		uint64_t      firstRecord;         // First record offset
		uint64_t      lastRecord;          // Last record offset

		uint64_t      totalFreeRecords;    // Total number of free records
		uint64_t      firstFreeRecord;     // First free record offset
		uint64_t      lastFreeRecord;      // Last free record offset
	} StorageHeader;


	//----------------------------------------------------------------------------
	// Record header structure (32 bytes)
	//----------------------------------------------------------------------------
	typedef struct {
		uint64_t    next;              // Next record position in data file
		uint64_t    previous;          // Previous record position in data file				
		uint32_t    recordCapacity;    // Record capacity in bytes
		uint32_t    dataLength;        // Data length in bytes				
		uint32_t    dataChecksum;      // Checksum for data consistency check		
		uint32_t    headChecksum;      // Checksum for header consistency check
	} RecordHeader;

На первый взгляд, может показаться, что часть полей в заголовке избыточна. Это так. Но он продиктован требованиями по возможности изменения записей, повторному использованию места удаленных записей, проверки целостности по контрольным суммам и возможности навигации как в прямом, так и обратном порядке. В конечном итоге, мы будем хранить JSON документы, поэтому ожидается, что размер большинства записей будет примерно в диапазоне 512-2048 байт и размер заголовка в 32 байта будет приемлемым.

4. Реализация RecordFileIO

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

  • Файл записей открывается на основе объекта CachedFile. Если файл не смогли открыть, кидаем исключение. Если файл оказался пустым, но доступен для записи, то создаем заголовки хранилища, чтобы можно было работать дальше. Если же файл не пустой или заголовок не корректный или битый (не сошлась контрольная сумма), то кидаем исключение.

  • Создание записи. Если список удаленных записей пустой, либо среди удаленных записей нет необходимой ёмкости, то добавляем новую запись в конец файла (по данным из заголовка). Связываем последнюю запись с новой. Обновляем заголовок хранилища с указанием нового конца файла и новой последней записи. Записываем данные. Сохраняем в заголовок контрольную сумму данных. Сохраняем в конце заголовка контрольную сумму заголовка. Если же в списке удаленных записей есть необходимой ёмкости (пробегаемся по списку), то связываем её с последней записью, записываем данные и обновляем её заголовок с контрольными суммами.

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

  • Удаление записи. Связываем предыдущую и следующую запись напрямую. А удаленную запись добавляем в конец списка удаленных записей.

  • Навигация по записям. Позиция первой и последней записи берётся из заголовка хранилища. Перемещение вперёд и назад по записям осуществляется на основе информации о предыдущей и следующей записи в заголовке текущей записи. Переход на абсолютную позицию в файле проверяет корректность контрольной суммы заголовка записи.

В первом приближении, определение класса RecordFileIO выглядит следующим образом:

class RecordFileIO {
	public:
		RecordFileIO(CachedFileIO& cachedFile, size_t freeDepth = NOT_FOUND);
		~RecordFileIO();
		uint64_t getTotalRecords();
		uint64_t getTotalFreeRecords();
		void     setFreeRecordLookupDepth(uint64_t maxDepth) { freeLookupDepth = maxDepth; }

		// records navigation
		bool     setPosition(uint64_t offset);
		uint64_t getPosition();
		bool     first();
		bool     last();
		bool     next();
		bool     previous();

		// create, read, update, delete (CRUD)
		uint64_t createRecord(const void* data, uint32_t length);
		uint64_t removeRecord();
		uint32_t getDataLength();
		uint32_t getRecordCapacity();
		uint64_t getNextPosition();
		uint64_t getPrevPosition();
		uint64_t getRecordData(void* data, uint32_t length);
		uint64_t setRecordData(const void* data, uint32_t length);

	private:
		CachedFileIO& cachedFile;
		StorageHeader storageHeader;
		RecordHeader  recordHeader;
		size_t        currentPosition;
		size_t        freeLookupDepth;

		void     initStorageHeader();
		bool     saveStorageHeader();
		bool     loadStorageHeader();
		uint64_t getRecordHeader(uint64_t offset, RecordHeader& result);
		uint64_t putRecordHeader(uint64_t offset, RecordHeader& header);
		uint64_t allocateRecord(uint32_t capacity, RecordHeader& result);
		uint64_t createFirstRecord(uint32_t capacity, RecordHeader& result);
		uint64_t appendNewRecord(uint32_t capacity, RecordHeader& result);
		uint64_t getFromFreeList(uint32_t capacity, RecordHeader& result);
		bool     putToFreeList(uint64_t offset);
		void     removeFromFreeList(RecordHeader& freeRecord);
		uint32_t checksum(const uint8_t* data, uint64_t length);
	};

Вот тут вы можете посмотреть саму реализацию класса RecordFileIO.cpp.

6. Функциональное тестирование

Сначала проверим базовый функционал, что записи добавляются. Ссылки корректны и данные целостны. Для этих целей запишем 10 записей (в данном случае строк текста с переменной длиной) в базу и закроем её. Откроем заново, прочитаем со служебной информацией и выведем в консоль.

Результат записи в хранилище записей и их чтения с выводом в консоль со служебной информацией
Результат записи в хранилище записей и их чтения с выводом в консоль со служебной информацией

Вроде бы, записи записываются и читаются ровно, ссылки корректные и длины записей корректны. Давайте посмотрим через HEX редактор, проверив поля заголовка хранилища и первой записи. По умолчанию мы пишем цифры в файле Big-Endian формате, когда младшие байты в начале, старшие в конце. На других системах может быть по другому, но пока будем тестировать под Win x64. Проверил заголовки записей, всё корректно.

Скриншот где в hex-редакторе проверяю корректность записи данных.
Скриншот где в hex-редакторе проверяю корректность записи данных.

Теперь проверим механизм удаления записей и проверим все ли ссылки будут корректны. Удалим четные записи и заодно прочитаем их в обратном порядке с конца хранилища. И посмотрим что получится (должно остаться 5 записей, ссылки верны, в обратном порядке):

Скриншот с тестом удаления записей и проверки целостности ссылок
Скриншот с тестом удаления записей и проверки целостности ссылок

Удаление работает корректно. А теперь вставим 3 записи заведомо меньше длины, чтобы использовалось место ранее удаленных записей. Выведем записи в консоль в прямом порядке. Посмотрим что получилось (порядок верный, новые записи заполнили место удаленных записей в позициях 64, 321, 508):

Сработало. Теперь посмотрим общую нагрузочный тест по записи и чтению миллиона записей. Пока проверим как есть.

Тест по нагрузке на коленках (запусков было много, примерно одни и те же результаты)
Тест по нагрузке на коленках (запусков было много, примерно одни и те же результаты)

В целом неплохо, с учётом того, что в нашем тесте чтение не последовательное и при каждой записи считается, а при чтении проверяется контрольная сумма заголовка и самих данных. Конечно же, в коде много есть где наводить порядок и оптимизировать производительность. Но сделаю это позже. Вот тут исходный код теста на коленках.

Таким образом, мы реализовали второй архитектурный слой СУБД Boson - Record File IO (хранилище записей), который решает следующие задачи: возможность создания, удаления, изменения записей, повторного использования места удаленных записей, проверки целостности по контрольным суммам и возможности навигации как в прямом, так и обратном порядке. Разработанный класс задачу решает и это отлично!

До встречи!

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


  1. gandjustas
    28.01.2023 12:33
    +1

    Я может чего-то не понимаю, но B+Tree нужно для индексов в СУБД. То есть когда у вас есть не просто поиск по ключу, а поиск по произвольному предикату и вы хотите этот поиск ускорить.

    Для поиска по ключу подойдет просто хранение в виде файлов на диске: ключ - имя файла, значение - содержимое.


    1. Basheyev Автор
      28.01.2023 12:48
      +2

      Да, можно на индексе самой файловой системы, но тут же вся соль в "реализации с нуля". Boson хоть и игрушечная, но всё таки СУБД. И в ней будет настоящий B+ Tree индекс с бинарным поиском в узлах, чтобы достичь сложности O(log2 t logt n). Пока реализация будет только для ID, но тот же механизм может быть применён и по другим полям документов в будущем.


      1. Ztare
        28.01.2023 16:25
        -1

        Вам намекают что от первичного ключа ожидается поведение O(1) - на то он и первичный и никакие b-tree этого не дадут


        1. gandjustas
          28.01.2023 16:40
          +4

          Не надо додумывать. Во всех базах обращение по ключу - O(logN) по большом основанию, если данные не находятся в памяти. В NTFS или ReFS индекс файлов в папке это тоже btree, поэтому там также будет O(logN).


          1. Ztare
            29.01.2023 05:57

            Во всех материалах где читал про бд - первичный ключ мапится напрямую на место хранения на диске, и именно по этому первичный ключ всегда только один. Это близко к О(1) и очень далеко от других метрик. Или вы счтаете что обращение к файлу по известному отступу это не О(1)?


            1. Basheyev Автор
              29.01.2023 07:03
              +1

              Только после того как вы по индексу нашли где запись.


            1. edo1h
              29.01.2023 13:50

              первичный ключ мапится напрямую на место хранения на диске, и именно по этому первичный ключ всегда только один

              вот смотрите, у вас первичный ключ по id. в базе миллиард записей с id от 1 до 10'000'000'000.
              как вы предлагаете с O(1) найти, скажем, запись с id=3'456'789'012?


              1. Ztare
                29.01.2023 14:06

                File.Seek(id×recordSize)

                Record=File.Read(recordSize)


                1. edo1h
                  29.01.2023 14:20

                  в базе миллиард записей с id от 1 до 10'000'000'000


                  1. Ztare
                    29.01.2023 15:05

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


                    1. edo1h
                      29.01.2023 16:08

                      формально я могу хранить пустые значения на диске.

                      хорошо, немного усложним задачу: первичный индекс по полю, хранящему uuid v4 (128-битное число, в котором 122 бит случайны)


                      1. Ztare
                        29.01.2023 19:03

                        плохой выбор - антипатерн для большинства БД
                        Сортировка такого поля смысла не делает - значит первичным ключом (в смысле адреса хранения) должно быть другое поле - это покрываем обычным индексом


                      1. edo1h
                        29.01.2023 20:08

                        btw, вы путаете первичный ключ и кластерный индекс.
                        но кластерный индекс (как минимум в mssql и mysql) это обычно то же дерево, что и прочие индексы. просто он хранит в себе все прочие поля таблицы.


                        P. S. использование uuid в качестве первичного ключа никакой не антипаттерн, а обычная практика. да, есть связанные с этим проблемы с производительностью, по этому поводу придуманы ulid и новый uuid


                1. gandjustas
                  29.01.2023 15:54
                  +1

                  Хорошее начало.

                  Как предлагаете удалять записи?

                  Что делать если записи переменой длины?

                  Что делать если ключ не равномерно возрастающий?


                  1. Ztare
                    29.01.2023 19:09
                    -1

                    все решения можете посмотреть в текущих популярных БД, в ms sql вроде используется страничная адресация. Ключ нужен для оптимизации доступа, а не для решения бизнес задач - соответственно БД предлагает тип ключа под который оптимизирована, или приводит к нему (явно или неявно)


                    1. gandjustas
                      30.01.2023 00:37

                      ну вы же в курсе что MS SQL делает поиск по ключу за O(logN) ?


        1. thevlad
          28.01.2023 19:50
          +4

          Амортизированный O(1) на поиск могут дать только хэш мапы, но думаю с подобным подходом другие проблемы.


          1. edo1h
            29.01.2023 14:05

            разумеется, там есть другие проблемы.
            во-первых, O(1) — лукавство. размер хэш-таблицы приходится увеличивать с ростом базы, время поиска в таблице на 100 записей и на 10 миллиардов записей будет совершенно разное — в первом случае хэш-таблица поместится в кэше процессора, во втором за ней придётся обращаться к накопителю (а то и к другой ноде).
            да и про коллизии не надо забывать, из-за них O(1) — это лучший случай.


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


            1. thevlad
              29.01.2023 14:16

              Так и b-tree тоже не в памяти, c хэш-мапами плохо что они не дают локальность рядом расположенные значений. O(1) это не лучший случай, а средний(амортизированный), и он вполне гарантирован если в таблице поддерживается разумный load factor.


              1. edo1h
                29.01.2023 14:30

                Так и b-tree тоже не в памяти

                разумеется. но там никто O(1) не обещает.


                O(1) это не лучший случай, а средний(амортизированный), и он вполне гарантирован если в таблице поддерживается разумный load factor

                а худший? разве есть способ, позволяющий поддерживать для произвольных входных данных отстутствие множественных коллизий без раздувания хэш-таблицы?


                1. thevlad
                  29.01.2023 14:39

                  Худший O(n), но в реальности это возможно только, если произвести целенаправленную атаку на хэш функцию. Если рассматривать это с вероятностной или практической точки зрения, то она бесконечно мала.

                  Не очень понял, что значит "раздувание", вам в любом случаи надо где-то хранить ключи и значения, обычно считают что при load factor <= 0.7, коллизий достаточно мало, и в среднем сложность будет O(1).


    1. codecity
      28.01.2023 13:37
      +2

      Для поиска по ключу подойдет просто хранение в виде файлов на диске: ключ - имя файла, значение - содержимое.

      Это привязка к тому же b-tree, как правило, но реализованному для конкретной файловой системы.


      1. gandjustas
        28.01.2023 16:41
        +1

        Да, и я вижу в этом только преимущество, так как не надо писать код.

        А для статьи было бы неплохо посоревноваться с таким решением.


      1. klimkinMD
        29.01.2023 09:58
        +1

        Вспомнилась os/400, где db/2 интегрирована с файловой системой


    1. zzzzzzerg
      28.01.2023 19:10

      B+Tree нужно для индексов в СУБД

      LMDB, libmdbx, BoltDB (из тех что первыми приходят в голову) построены на B+tree, т.е. это основная структура для хранения данных и обращения к ним по ключу (а не только индекс).


      1. gandjustas
        29.01.2023 16:03
        +1

        Эти базы решают чуть больше задач, чем get и put по ключу. В частности сделать транзакции без своей реализации btree сделать сложно


        1. zzzzzzerg
          29.01.2023 17:31

          Да, с этим я не спорю. Комментарий был как дополнение про то что b+tree не только для индексов используется.


          1. gandjustas
            29.01.2023 18:43
            +2

            Я наверное неправильно сформулировал.

            При таком интерфейсе БД как заявлено использование btree вовсе необязательно, средств файловой системы хватит. И с этого стоило начинать. Даже если цель итоговая - написать свой btree, то надо в нем хотя бы обогнать ФС на выборке по ключу.


  1. softaria
    28.01.2023 17:21
    +1

    Дефрагментацию не будете делать?


    1. Basheyev Автор
      28.01.2023 18:06
      +1

      Если сил хватит дожать заявленнный функционал, то обязательно. Алгоритм B+ Tree реализовал пока в оперативке, но пока парюсь с его работой в файле (сериализацией/десериализацией).