Напомню о том, что цель проекта Boson — это разработка встроенного движка базы данных документов JSON, написанный на C++. Основные возможности:
Стандартное хранилище JSON-документов в формате ключ/значениями с постоянным хранением на диске. Размер документов до 4Gb.
Быстрый поиск документов по ID с использованием индекса B+ дерева.
Поддержка курсоров для линейного обхода записей.
База данных в одном файле, без временных файлов.
Простое, чистое и легкое в использовании API.
Самодостаточный и не требующий настройки.
В предыдущих двух статьях мы прошли шаги от кэширования файлового ввода/вода (часть I) до построенного на его базе хранилища записей произвольной длины (часть II) с проверкой целостности, возможностью получения записей списком и повторным использованием свободного места. Теперь мы переходим к завершающей части и "сердцу" СУБД - индексу.
Зачем нужен индекс: предположим, что в базе есть 1 млрд не отсортированных записей документов, тогда поиск конкретного документа по ID потребует O(n) операций, то есть до 1 млрд операций в худшем случае. Однако, если бы документы в базе были бы отсортированы по ID, то поиск в сортированной базе, тем же бинарным поиском занял бы O(log n) занял бы 30 операций. Что, теоретически, на базе в 1 млрд записей будет в 33.3 млн раз быстрее.
Замечательная мысль, но осталось понять как в случае вставки документов с произвольным ID записи в базе всегда оставались отсортированными без необходимости "раздвигать" данные в базе при каждой вставке или обновлении записи. Это уже не так тривиально.
Для решения этой проблемы в 1972 году Rudolf Bayer и Edward McCreight была впервые представлена идея B-деревьев, которые являются основой для B+ деревьев - это расширение концепции B-деревьев, в которых все ключи на уровне листьев объединены в связанный список (Linked List), что делает их более эффективными для операций последовательного доступа. И, конечно, алгоритм B+ деревьев даёт выдающуюся производительность: поиск - O(log N), вставка - O(log N), удаление - O(log N).
Для начала немного погрузимся в теорию о том как устроены B+ деревья и какие операции нужны чтобы поддерживать дерево в целостном и сбалансированном виде:
B+ дерево состоит из внутренних узлов и листовых узлов. Внутренние узлы хранят только ключи для направления поиска, в то время как сами данные содержатся в листовых узлах:
Внутренние узлы (Inner Node): Содержат ключи и указатели на дочерние узлы, но не хранят реальные данные. Каждый внутренний узел может содержать от ⌈M/2⌉ до M-1 ключей и M дочерних узлов. Каждый ключ в узле разделяет дочерние узлы: левый дочерний узел содержит ключи, меньшие ключа родительского узла, а правый дочерний узел содержит ключи, большие или равные ключу родительского узла.
Листовые узлы (Leaf Node): Хранят пары ключей и указателей на данные. Связаны между собой в двусвязный список для эффективных диапазонных запросов. Каждый листовой узел может также содержать от ⌈M/2⌉ до M-1 ключей и M-1 значений.
Чтобы при произвольных вставках, удалениях и изменениях записей поддерживалась сбалансированность и отсортированность B+ дерева, выполняются следующие операции:
Операция поиска: Для поиска определенного ключа в B+ дереве начинают с корневого узла. Сравнивают целевой ключ с ключами в текущем узле, чтобы определить соответствующий дочерний узел. Если целевой ключ меньше, переходят к левому дочернему узлу; если больше или равен, переходят к правому дочернему узлу. Этот процесс повторяется, пока не будет достигнут нужный листовой узел, где и завершается поиск. Поскольку B+ дерево сбалансировано, сложность поиска составляет O(log n).
Операция вставки: Вставка ключа начинается с поиска подходящего листового узла с использованием процедуры поиска. Затем ключ вставляется в листовой узел в правильном отсортированном положении. Если узел превышает свою максимальную вместимость (M-1 ключей), происходит разделение. Узел делится на два, и половина ключей переходит в новый дочерний узел. Средний ключ поднимается в родительский узел. Если родительский узел также переполняется, это может вызвать дальнейшие разделения, которые могут распространиться до корня, потенциально увеличивая высоту дерева.
Операция удаления: Удаление ключа начинается с поиска целевого ключа в соответствующем листовом узле. Ключ удаляется, но если это приводит к тому, что узел содержит меньше ключей, чем минимум (⌈M/2⌉), требуется корректировка. Дерево может заимствовать ключ у соседнего узла, если у него больше минимального количества ключей. Если заимствование невозможно, узел сливается с соседним, и ключ из родительского узла удаляется или корректируется соответственно. Этот процесс слияния может распространяться вверх по дереву, если необходимо, чтобы сохранить баланс дерева.
Диапазонные запросы: B+ деревья особенно хорошо подходят для диапазонных запросов благодаря связной структуре листовых узлов. Чтобы выполнить диапазонный запрос, начинают с поиска первого ключа в нужном диапазоне, используя процедуру поиска. После нахождения начального ключа переходят по двусвязному списку листовых узлов, пока не достигнут конца диапазона. Эта связная структура делает сканирование последовательности значений эффективным и простым.
Операция обновления: Обновление значения ключа включает поиск ключа в соответствующем листовом узле с использованием поиска. После нахождения ключа связанное значение изменяется. Если значение требует перемещения из-за ограничений по вместимости записей, может быть выделена новая запись в хранилище без изменения ключа.
Операции вставки и удаления могут создавать ситуацию, когда возникает переполнение узла или, наоборот, недостаток элементов. В таких случаях выполняются операции поддерживающие сбалансированность B+ дерева:
-
Разделение (Split) — выполняется когда узел в B+ дереве превышает свою максимальную вместимость (M-1 ключей). Процесс деления заключается в следующем:
Узел делится на два, при этом половина ключей остается в исходном узле, а другая половина переносится в новый узел.
Продвижение ключа вверх (Push Key Up) — средний ключ из исходного узла перемещается в родительский узел. Это обеспечивает обновление родительского узла, чтобы отразить изменения структуры дерева.
Если родительский узел также переполняется, процесс разделения и продвижения ключа вверх может распространяться вверх по дереву, вплоть до корня (root). Если корень разделяется, создается новый корень, увеличивая высоту дерева. То есть дерево по количеству уровней растёт от корня, а не от листьев.
Пример: Если в узле M=5 и он содержит 5 ключей (1, 2, 3, 4, 5), при переполнении узел делится на два: (1, 2) и (4, 5), а ключ 3 продвигается вверх.
-
Слияние (Merge) — выполняется когда в результате удаления ключа количество ключей в узле становится меньше минимально допустимого значения (⌈M/2⌉). Процесс слияния включает:
Объединение узла с его соседним узлом (левым или правым), чтобы создать один узел.
Ключ из родительского узла, который разделял эти два узла, перемещается вниз и становится частью объединенного узла.
-
Если после слияния родительский узел остается с недостаточным количеством ключей, операция слияния может распространиться вверх по дереву.
Пример: Если два соседних листовых узла содержат ключи (1, 2) и (3, 4), они могут объединиться в один узел (1, 2, 3, 4), и разделяющий ключ из родительского узла удаляется.
-
Заимствование у соседнего узла (Borrow from Sibling) — это операция, выполняемая, когда узел содержит меньше минимально допустимого числа ключей (⌈M/2⌉), но его соседний узел (левый или правый) имеет больше ключей, чем минимум.
Ключ из родительского узла перемещается вниз в узел с недостаточным количеством ключей.
Соседний узел передает один из своих ключей в родительский узел для поддержания баланса.
Пример: Если узел содержит 1 ключ, а его соседний узел содержит 3 ключа, то один ключ может быть передан из соседнего узла, а родительский узел обновляется соответствующим образом.
Эти операции важны для поддержания сбалансированности B+ дерева, чтобы все узлы оставались сбалансированными и все листовые узлы находились на одном уровне, обеспечивая сложность операций O(log n).
Визуализацию как работает этот алгоритм B+ дерева можно посмотреть здесь.
Теперь приступим к реализации самого алгоритма, в данной статье я приведу лишь заголовок, а саму реализацию B+ Tree индекса вы можете посмотреть здесь.
Вот какой заголовочный файл получился у меня при реализации индекса B+ Tree:
namespace Boson {
constexpr uint64_t TREE_ORDER = 5;
constexpr uint64_t MAX_DEGREE = TREE_ORDER - 1;
constexpr uint64_t MIN_DEGREE = TREE_ORDER / 2;
constexpr uint32_t KEY_NOT_FOUND = -1;
typedef enum : uint32_t { INNER = 1, LEAF = 2 } NodeType;
typedef enum : uint32_t { KEYS = 1, CHILDREN = 2, VALUES = 2 } NodeArray;
//-------------------------------------------------------------------------
class NodeData {
public:
uint64_t parent;
uint64_t leftSibling;
uint64_t rightSibling;
NodeType nodeType;
uint32_t keysCount;
union {
uint32_t childrenCount;
uint32_t valuesCount;
};
uint64_t keys[TREE_ORDER];
union {
uint64_t children[TREE_ORDER];
uint64_t values[TREE_ORDER];
};
NodeData();
void pushBack(NodeArray mode, uint64_t value);
void insertAt(NodeArray mode, uint32_t index, uint64_t value);
void deleteAt(NodeArray mode, uint32_t index);
void resize(NodeArray mode, uint32_t newSize);
};
//-------------------------------------------------------------------------
class BalancedIndex;
class LeafNode;
class InnerNode;
class Node {
friend class BalancedIndex;
friend class LeafNode;
friend class InnerNode;
public:
Node(BalancedIndex& bi, NodeType type);
~Node();
uint64_t getPosition();
uint64_t persist();
NodeType getNodeType();
uint32_t getKeyCount();
bool isRootNode();
bool isOverflow();
bool isUnderflow();
bool canLendAKey();
uint64_t getKeyAt(uint32_t index);
void setKeyAt(uint32_t index, uint64_t key);
uint64_t getParent();
void setParent(uint64_t parentPosition);
uint64_t getLeftSibling();
void setLeftSibling(uint64_t siblingPosition);
uint64_t getRightSibling();
void setRightSibling(uint64_t siblingPosition);
uint64_t dealOverflow();
uint64_t dealUnderflow();
protected:
BalancedIndex& index; // reference to index
uint64_t position; // offset in file
NodeData data; // node data
bool isPersisted; // is data persisted to storage
Node(BalancedIndex& bi);
static std::shared_ptr<Node> loadNode(BalancedIndex& bi, uint64_t offsetInFile);
static void deleteNode(BalancedIndex& bi, uint64_t offsetInFile);
virtual uint32_t search(uint64_t key) = 0;
virtual uint64_t split() = 0;
virtual uint64_t pushUpKey(uint64_t key, uint64_t leftChild, uint64_t rightChild) = 0;
virtual uint64_t mergeChildren(uint64_t leftChild, uint64_t rightChild) = 0;
virtual void mergeWithSibling(uint64_t key, uint64_t rightSibling) = 0;
virtual uint64_t borrowFromSibling(uint64_t key, uint64_t sibling, uint32_t borrowIndex) = 0;
virtual void borrowChildren(uint64_t borrow0er, uint64_t lender, uint32_t borrowIndex) = 0;
virtual std::shared_ptr<std::string> toString() = 0;
};
//-------------------------------------------------------------------------
class InnerNode : public Node {
friend class BalancedIndex;
friend class Node;
friend class LeafNode;
public:
InnerNode(BalancedIndex& bi);
InnerNode(BalancedIndex& bi, uint64_t offsetInFile, NodeData& loadedData);
~InnerNode();
uint32_t search(uint64_t key);
uint64_t getChildAt(uint32_t index);
void setChildAt(uint32_t index, uint64_t childNode);
void insertAt(uint32_t index, uint64_t key, uint64_t leftChild, uint64_t rightChild);
void deleteAt(uint32_t index);
NodeType getNodeType();
std::shared_ptr<std::string> toString();
protected:
uint64_t split();
uint64_t pushUpKey(uint64_t key, uint64_t leftChild, uint64_t rightChild);
void borrowChildren(uint64_t borrower, uint64_t lender, uint32_t borrowIndex);
uint64_t borrowFromSibling(uint64_t key, uint64_t sibling, uint32_t borrowIndex);
uint64_t mergeChildren(uint64_t leftChild, uint64_t rightChild);
void mergeWithSibling(uint64_t key, uint64_t rightSibling);
};
//-------------------------------------------------------------------------
class LeafNode : public Node {
friend class BalancedIndex;
friend class Node;
friend class InnerNode;
public:
LeafNode(BalancedIndex& bi);
LeafNode(BalancedIndex& bi, uint64_t offsetInFile, NodeData& loadedData);
~LeafNode();
uint32_t search(uint64_t key);
std::shared_ptr<std::string> getValueAt(uint32_t index);
void setValueAt(uint32_t index, const std::string& value);
bool insertKey(uint64_t key, const std::string& value);
bool insertKey(uint64_t key, uint64_t valuePosition);
void insertAt(uint32_t index, uint64_t key, const std::string& value);
void insertAt(uint32_t index, uint64_t key, uint64_t valuePosition);
bool deleteKey(uint64_t key);
void deleteAt(uint32_t index);
NodeType getNodeType();
std::shared_ptr<std::string> toString();
protected:
uint64_t split();
uint64_t pushUpKey(uint64_t key, uint64_t leftChild, uint64_t rightChild);
void borrowChildren(uint64_t borrower, uint64_t lender, uint32_t borrowIndex);
uint64_t mergeChildren(uint64_t leftChild, uint64_t rightChild);
void mergeWithSibling(uint64_t key, uint64_t rightSibling);
uint64_t borrowFromSibling(uint64_t key, uint64_t sibling, uint32_t borrowIndex);
uint32_t searchPlaceFor(uint64_t key);
};
//-------------------------------------------------------------------------
class IndexHeader {
public:
uint64_t treeOrder; // Tree order
uint64_t rootPosition; // Root node position in the storage file
uint64_t recordsCount; // Total records count
uint64_t indexCounter; // Index key counter
};
class BalancedIndex {
friend class Node;
friend class LeafNode;
friend class InnerNode;
friend class BosonAPI;
public:
BalancedIndex(RecordFileIO& rf);
~BalancedIndex();
uint64_t size();
bool insert(uint64_t key, const std::string& value);
bool update(uint64_t key, const std::string& value);
std::shared_ptr<std::string> search(uint64_t key);
bool erase(uint64_t key);
std::pair<uint64_t, std::shared_ptr<std::string>> first();
std::pair<uint64_t, std::shared_ptr<std::string>> last();
std::pair<uint64_t, std::shared_ptr<std::string>> next();
std::pair<uint64_t, std::shared_ptr<std::string>> previous();
void printTree();
protected:
uint64_t getNextIndexCounter();
RecordFileIO& getRecordsFile();
std::shared_ptr<LeafNode> findLeafNode(uint64_t key);
void updateRoot(uint64_t newRootPosition);
void persistIndexHeader();
void printTreeLevel(std::shared_ptr<Node> node, int level);
private:
RecordFileIO& recordsFile;
IndexHeader indexHeader;
std::shared_ptr<Node> root;
std::shared_ptr<LeafNode> cursorNode;
uint32_t cursorIndex;
bool isTreeChanged;
};
}
Напишем реализацию NodeData, Node, InnerNode, LeafNode, BalancedIndex и проверим как строится B+ дерево...
Каждый алгоритм и его техническая реализации в отдельности не представляли большой сложности (бинарный поиск, двусвязные списки, деревья, рекурсии многие знают со школы), пока всё это делалось в оперативной памяти и по отдельности. Однако, чтобы отладить и заставить это работать на диске (вместо new/delete нужно было использовать аналог хранения на диске - RecordFileIO), всё вместе и слаженно - это был вызов, который занял почти месяц на осознание и разработку. Тут видимо уже мои интеллектуальные ограничения. Очень сильно выручили умные указатели (smart pointers) чтобы проуправлять постоянной загрузкой и выгрузкой узлов из памяти и сохранением их на диске не создав утечку памяти. Спасибо разработчикам языка C++.
Итак посмотрим, как работает реализация. Сработало. На логах в консоли можно разглядеть структуру дерева, и ссылки на позиции в файле базы данных:
Завершив реализацию посмотрим как это примерно работает. Для статьи и удобства разглядывания скриншота взял малое количество записей, в реальных тестах проверял на больших объемах. Вставим N записей, последовательно вытащим их (проверим, что поиск работает), удалим N записей, вставим заново N записей (проверив что свободное место используется), удалим и посмотрим как отработало.
Теперь когда все три основных слоя базы данных реализованы (cache, storage и index), можно реализовать простой API и завершить этот образовательный проект.
namespace Boson {
class BosonAPI {
public:
BosonAPI();
~BosonAPI();
bool open(char* filename, bool readOnly = false);
bool close();
uint64_t size();
bool isExists(uint64_t key);
uint64_t insert(std::string value);
bool insert(uint64_t key, std::string value);
std::shared_ptr<std::string> get(uint64_t key);
bool erase(uint64_t key);
std::pair<uint64_t, std::shared_ptr<std::string>> first();
std::pair<uint64_t, std::shared_ptr<std::string>> last();
std::pair<uint64_t, std::shared_ptr<std::string>> next();
std::pair<uint64_t, std::shared_ptr<std::string>> previous();
double getCacheHits();
void printTreeState();
private:
CachedFileIO* cachedFile;
RecordFileIO* recordFile;
BalancedIndex* balancedIndex;
bool isReadOnly;
};
}
Вот и всё! Цель проекта достигнута. Исходный код Boson вы можете посмотреть здесь. Спасибо что дочитали до конца!
Disclaimer: важно понимать, что это лишь учебный пример реализации СУБД движимый моим любопытством и поделиться впечатлениями от исследования. Для применения Boson в ваших реальных проектах необходимо реализовать защиту для многопоточности (locks) и на уровне хранилища/процессов. И нужно ли это делать - вопрос. Посмотрим.
Комментарии (28)
Vad344
09.11.2024 09:01...Строим свою СУБД. Для быстрого поиска нужен индекс. Индекс реализуется с использованием B+ дерева. А В+ дерево реализуется вот так: <...> Статья готова!
Рыбы это не млекопитающие. Шерстью не покрыты. Покрыты чешуей, но если
бы они были покрыты шерстью, то в ней бы водились блохи... <дальше про блох>
Basheyev Автор
09.11.2024 09:01Спасибо за обратную связь. А какой информации не хватило или какой вопрос не раскрыт? Алгоритм расписал. Думаю, вы язык и основы знаете хорошо. Хотел не перегружать читателя очевидностями, но в исходном коде почти каждую строчку прокомментировал (на английском), если будут интересны детали реализации. Даже специально жертвовал местами производительностью в пользу наглядности.
voldemar_d
09.11.2024 09:01Чем это дерево отличается от std::map?
Basheyev Автор
09.11.2024 09:01В двух словах:
1) std::map — сбалансированное бинарное дерево (красно-черное дерево), очень эффективно на относительно малых объемах данных в оперативной памяти, но менее эффективно по памяти и не оптимально для больших данных, тогда как B+ Tree имеет узлы с множеством ключей и связные листья, что обеспечивает лучшую плотность хранения и быстрые диапазонные запросы, оптимально для работы с большими объемами данных.
2) во вторых std::map - это реализация для оперативной памяти, а в Boson B+ Tree сохраняет на диске с оптимизацией количества операций ввода/вывода (снизу реализация хранилища записей и алгоритмы кэширования).
Мне так видится.
voldemar_d
09.11.2024 09:01А если в std::map подсунуть свой аллокатор, который хранит данные не в памяти, в на диске?
Например, с помощью memory mapped file. Не будет ли это примерно тем же самым?
Basheyev Автор
09.11.2024 09:01Теоретически можно. Чтобы увеличить эффективность придётся большей ключей/значений держать в узлах и добавить двусвязные списки. Получится B+ Tree.
Использование mmap упростит реализацию, но мы становимся зависимыми от механизмов подкачки в ОС - сложно применять оптимизационные стратегии управления кэшированием. Добавим сюда необходмые механизмы защиты для многопоточности на уровне узлов и страниц - всё станет сложнее.
Вот статья которая объясняет почему использование mmap в DBMS может быть не очень хорошей идеей:
https://www.cidrdb.org/cidr2022/papers/p13-crotty.pdf
zzzzzzerg
09.11.2024 09:01Есть перевод на Хабре - https://habr.com/ru/articles/820591/
А также есть комментарий Леонида (автор libmdbx - одной из лучших встраиваемых БД построенной на основе b+ деревьев и mmap) - https://t.me/libmdbx/5837
zzzzzzerg
09.11.2024 09:01Нет, не будет. Как автор выше написал rb-tree отличается от b+-tree, тем что последнее значения хранит только в листьях и «блоки» листьев между собой связаны.
Вот довольно не плохая иллюстрация для b+ деревьев:
schulzr
09.11.2024 09:01Добрый день, а не подскажите, почему B+ дерево предпочтительнее чем хеш?
И еще вопрос, а правильно я понимаю, что при удалении или модификации объекта память или место на диске не освобождаются? СпасибоBasheyev Автор
09.11.2024 09:01Спасибо за вопросы!
Hash Map использует хэш-функцию для определения позиции в фиксированном массиве, элементы которого могут быть связанными списками для разрешения коллизий хэш функции:
Hash Map не сохраняет порядок ключей, что делает его непригодным для диапазонных запросов и сортировки, в отличие от B+ Tree.
Hash Map менее эффективно использует дисковое пространство при работе с диском, так как требует предварительной аллокации массива, вне зависимости от количества элементов.
Basheyev Автор
09.11.2024 09:01Да, при удалении записей файл меньше не становится. Удаленные записи оставляют "пустоты" в файлах (файл базы данных не дефрагментируем, чтобы не проиграть в скорости удаления), но эти "пустоты" используются повторно при вставке новых записей, если ёмкость удаленной записи достаточна для хранения вновь создаваемой.
Это даёт некий сбалансированный компромис между скоростью работы и эффективностью использования свободного пространства на диске. Многие СУБД используют аналогичную логику.
Об этом подробно рассказывал в предыдущей части статьи вот тут:
pomponchik
09.11.2024 09:01А выводы-то какие по итогу? Да, автор познал некоторые базовые идеи под капотом у баз данных, я правда рад за него, но в чем тут ценность для меня?
slonpts
А сравнивали с JSON внутри sqlite? Какие есть преимущества у Boson?
Mingun
Написано же, какие преимущества: своё, родное.
Basheyev Автор
Преимущество - наглядность реализации. Хоть sqlite самая легкая СУБД, не самая простая, если почитаете исходный код, с ходу не поймете что и почему. В случае с Boson относительно не сложно разобраться, а потом и понять как работают промышленные СУБД.
ViacheslavNk
Boson относительно не сложно разобраться, а потом и понять как работают промышленные СУБД.
Как разработчик движка СУБД скажу, что B+ деревья занимают в лучшем случае 1% от всей разработки промышленной СУБД, более того это самая простая и понятная часть всей этой “истории”.
Реальные сложности начинаются в реализации непосредственно самого SQL движка, совсем космос это реализации оптимизатора там просто “темная магия”, так же сложная область транзакции, консистентность, WAL, если мы говорим про промышленную СУБД то тут появляются кластера, бэкапы (дифференциальные, логов транзакций), репликация, сжатие данных, отдельно, пользователи, роли, клиент-сервер и т.д.
Basheyev Автор
Вы правы. В масштабах промышленной СУБД образовательный пример Boson лишь малая часть - скорее всего 1%.
Из перечисленного о том как делать компилятор и виртуальную машину (VM) для условного Domain Specific Language (DSL) рассказывал в отдельной серии статей (можно и простое подмножество SQL сделать):
https://habr.com/ru/articles/571758/
WAL, транзакции, клиент/сервер - это большое путешествие которое на текущем этапе избыточно для исследования основ СУБД.
imitron
Было бы очень интересно прочесть статью, хотя бы с поверхностным обзором всего этого
ViacheslavNk
Неплоха обзорная статья (оригинал у меня почемуто не доступен)
Как работает реляционная БД
https://habr.com/ru/companies/vk/articles/266811/
Есть на мой взгляд фундаментальная книга (хоть и немного устаревшая)
Database Systems: The Complete Book
дальше можно почитать по конкретным СУБД, например по Ораклу, тоже уже старая книга, но хорошо излагается связь между структурами данных и производительностью запросов
Cost-Based Oracle Fundamentals (Expert's Voice in Oracle)
по MSSQL, достаточно неплохой обзор внутренней архитектуры
Pro SQL Server Internals
про внутреннее устройство PosgreSQL есть хорошие русскоязычные книги:
https://postgrespro.ru/education/books\
и, к слову, я не согласен с автором (если не его цитата то прошу прощения), что код sqlite сложный, на мой взгляд это просто вопрос времени и в нем можно разобраться и понять как работает sqlite, на ютубе есть видео где "на основе" sqlite создается база данных
Writing My Own Database From Scratch
https://www.youtube.com/watch?v=5Pc18ge9ohI&t=1753s&ab_channel=TonySaro
zzzzzzerg
Я уже упоминал ниже, но я бы к ваше списку добавил Database Internals (Распределенные Данные) Алексея Петрова. Там и обзорно и есть необходимые подробности и ссылки для дальнейшего изучения.