1. Введение
В первой части статьи мы обсуждали разработку самого нижнего слоя СУБД Boson - CachedFileIO. Как упоминалось, статистика такого явления как Locality of Reference говорит о том, что в реальных приложениях ~95% запросов к данным локализованы в 10-15% базы данных. При этом среднее соотношение чтения/записи - 70%/30%. Это делает эффективным использование кэша (cache) работающего на основе алгоритма Least Recently Used (LRU). Реализовав его, мы получили 260%-600% прироста скорости чтения при 87%-97% cache hits.
Если вспомнить упрощенную архитектуру 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. Проверил заголовки записей, всё корректно.
Теперь проверим механизм удаления записей и проверим все ли ссылки будут корректны. Удалим четные записи и заодно прочитаем их в обратном порядке с конца хранилища. И посмотрим что получится (должно остаться 5 записей, ссылки верны, в обратном порядке):
Удаление работает корректно. А теперь вставим 3 записи заведомо меньше длины, чтобы использовалось место ранее удаленных записей. Выведем записи в консоль в прямом порядке. Посмотрим что получилось (порядок верный, новые записи заполнили место удаленных записей в позициях 64, 321, 508):
Сработало. Теперь посмотрим общую нагрузочный тест по записи и чтению миллиона записей. Пока проверим как есть.
В целом неплохо, с учётом того, что в нашем тесте чтение не последовательное и при каждой записи считается, а при чтении проверяется контрольная сумма заголовка и самих данных. Конечно же, в коде много есть где наводить порядок и оптимизировать производительность. Но сделаю это позже. Вот тут исходный код теста на коленках.
Таким образом, мы реализовали второй архитектурный слой СУБД Boson - Record File IO (хранилище записей), который решает следующие задачи: возможность создания, удаления, изменения записей, повторного использования места удаленных записей, проверки целостности по контрольным суммам и возможности навигации как в прямом, так и обратном порядке. Разработанный класс задачу решает и это отлично!
До встречи!
gandjustas
Я может чего-то не понимаю, но B+Tree нужно для индексов в СУБД. То есть когда у вас есть не просто поиск по ключу, а поиск по произвольному предикату и вы хотите этот поиск ускорить.
Для поиска по ключу подойдет просто хранение в виде файлов на диске: ключ - имя файла, значение - содержимое.
Basheyev Автор
Да, можно на индексе самой файловой системы, но тут же вся соль в "реализации с нуля". Boson хоть и игрушечная, но всё таки СУБД. И в ней будет настоящий B+ Tree индекс с бинарным поиском в узлах, чтобы достичь сложности O(log2 t logt n). Пока реализация будет только для ID, но тот же механизм может быть применён и по другим полям документов в будущем.
Ztare
Вам намекают что от первичного ключа ожидается поведение O(1) - на то он и первичный и никакие b-tree этого не дадут
gandjustas
Не надо додумывать. Во всех базах обращение по ключу - O(logN) по большом основанию, если данные не находятся в памяти. В NTFS или ReFS индекс файлов в папке это тоже btree, поэтому там также будет O(logN).
Ztare
Во всех материалах где читал про бд - первичный ключ мапится напрямую на место хранения на диске, и именно по этому первичный ключ всегда только один. Это близко к О(1) и очень далеко от других метрик. Или вы счтаете что обращение к файлу по известному отступу это не О(1)?
Basheyev Автор
Только после того как вы по индексу нашли где запись.
edo1h
вот смотрите, у вас первичный ключ по id. в базе миллиард записей с id от 1 до 10'000'000'000.
как вы предлагаете с O(1) найти, скажем, запись с id=3'456'789'012?
Ztare
File.Seek(id×recordSize)
Record=File.Read(recordSize)
edo1h
Ztare
Это какраз к разработчику бд и вопрос. Насколько я знаю делается несколько уровней хеш таблиц с адресами участков. Но даже в моем случае проблемы нет ) формально я могу хранить пустые значения на диске.
edo1h
хорошо, немного усложним задачу: первичный индекс по полю, хранящему uuid v4 (128-битное число, в котором 122 бит случайны)
Ztare
плохой выбор - антипатерн для большинства БД
Сортировка такого поля смысла не делает - значит первичным ключом (в смысле адреса хранения) должно быть другое поле - это покрываем обычным индексом
edo1h
btw, вы путаете первичный ключ и кластерный индекс.
но кластерный индекс (как минимум в mssql и mysql) это обычно то же дерево, что и прочие индексы. просто он хранит в себе все прочие поля таблицы.
P. S. использование uuid в качестве первичного ключа никакой не антипаттерн, а обычная практика. да, есть связанные с этим проблемы с производительностью, по этому поводу придуманы ulid и новый uuid
gandjustas
Хорошее начало.
Как предлагаете удалять записи?
Что делать если записи переменой длины?
Что делать если ключ не равномерно возрастающий?
Ztare
все решения можете посмотреть в текущих популярных БД, в ms sql вроде используется страничная адресация. Ключ нужен для оптимизации доступа, а не для решения бизнес задач - соответственно БД предлагает тип ключа под который оптимизирована, или приводит к нему (явно или неявно)
gandjustas
ну вы же в курсе что MS SQL делает поиск по ключу за O(logN) ?
thevlad
Амортизированный O(1) на поиск могут дать только хэш мапы, но думаю с подобным подходом другие проблемы.
edo1h
разумеется, там есть другие проблемы.
во-первых, O(1) — лукавство. размер хэш-таблицы приходится увеличивать с ростом базы, время поиска в таблице на 100 записей и на 10 миллиардов записей будет совершенно разное — в первом случае хэш-таблица поместится в кэше процессора, во втором за ней придётся обращаться к накопителю (а то и к другой ноде).
да и про коллизии не надо забывать, из-за них O(1) — это лучший случай.
во-вторых, в реальной жизни часто нужен не только поиск по точному значению, b-tree же позволяет искать по интервалу и подобное.
thevlad
Так и b-tree тоже не в памяти, c хэш-мапами плохо что они не дают локальность рядом расположенные значений. O(1) это не лучший случай, а средний(амортизированный), и он вполне гарантирован если в таблице поддерживается разумный load factor.
edo1h
разумеется. но там никто O(1) не обещает.
а худший? разве есть способ, позволяющий поддерживать для произвольных входных данных отстутствие множественных коллизий без раздувания хэш-таблицы?
thevlad
Худший O(n), но в реальности это возможно только, если произвести целенаправленную атаку на хэш функцию. Если рассматривать это с вероятностной или практической точки зрения, то она бесконечно мала.
Не очень понял, что значит "раздувание", вам в любом случаи надо где-то хранить ключи и значения, обычно считают что при load factor <= 0.7, коллизий достаточно мало, и в среднем сложность будет O(1).
codecity
Это привязка к тому же b-tree, как правило, но реализованному для конкретной файловой системы.
gandjustas
Да, и я вижу в этом только преимущество, так как не надо писать код.
А для статьи было бы неплохо посоревноваться с таким решением.
klimkinMD
Вспомнилась os/400, где db/2 интегрирована с файловой системой
zzzzzzerg
B+Tree нужно для индексов в СУБД
LMDB, libmdbx, BoltDB (из тех что первыми приходят в голову) построены на B+tree, т.е. это основная структура для хранения данных и обращения к ним по ключу (а не только индекс).
gandjustas
Эти базы решают чуть больше задач, чем get и put по ключу. В частности сделать транзакции без своей реализации btree сделать сложно
zzzzzzerg
Да, с этим я не спорю. Комментарий был как дополнение про то что b+tree не только для индексов используется.
gandjustas
Я наверное неправильно сформулировал.
При таком интерфейсе БД как заявлено использование btree вовсе необязательно, средств файловой системы хватит. И с этого стоило начинать. Даже если цель итоговая - написать свой btree, то надо в нем хотя бы обогнать ФС на выборке по ключу.