3. B дерево и B+ дерево
В предыдущей статье 2. Красно черное дерево мы подробно изучили in-memory деревья поиска. Они демонстрируют выдающуюся эффективность до тех пор, пока весь рабочий объем данных свободно помещается в оперативную память (RAM). Однако когда массив информации разрастается до сотен гигабайт и терабайт, мы неизбежно сталкиваемся с необходимостью хранить его на вторичных энергонезависимых накопителях (NVMe SSD, HDD).
И именно в этой физической среде классические бинарные деревья (как AVL, так и RBT) терпят сокрушительное архитектурное фиаско. Чтобы понять, почему все ведущие промышленные реляционные СУБД (PostgreSQL, MySQL / InnoDB, Oracle, SQLite) отказались от бинарных деревьев в пользу B-семейства, мы обязаны проанализировать физику подсистемы дискового ввода-вывода (I/O).
Mechanical Sympathy: Проблема дискового IO
Оперативная память (RAM) позволяет адресовать данные по случайным байтовым адресам с ультранизкой задержкой порядка 50–100 наносекунд.
Физический диск устроен принципиально иначе. Во-первых, он на порядки медленнее (микросекунды для быстрого SSD, миллисекунды для вращающегося HDD). Во-вторых, операционная система и аппаратный дисковый контроллер никогда не считывают и не записывают единичные байты. Весь обмен данными на шине хранения происходит исключительно фиксированными блоками — Страницами (Pages) или Секторами. Стандартный размер страницы в ядре Linux и большинстве СУБД составляет 4 КБ (4096 байт) или 8 КБ.
Для преодоления дискового бутылочного горлышка потребовалась структура данных принципиально иной геометрии: не высокая и тонкая, а «широкая и плоская», где размер каждого узла идеально откалиброван под размер аппаратной страницы диска (4 КБ). Так на свет появилось B-дерево (B-Tree), предложенное Рудольфом Байером и Эдвардом Маккрейтом в 1970 году.
B-дерево: Широкое и плоское
В названии структуры литера "B" вовсе не означает "Binary" (бинарное). Авторская семантика термина трактуется как "Balanced" (сбалансированное), "Broad" (широкое) или "Boeing" (в честь исследовательской лаборатории Boeing Scientific Research Labs).
В отличие от бинарного дерева, где узел удерживает не более двух потомков, узел B-дерева может ветвиться на $M$ дочерних узлов (где $M$ — это порядок / Degree дерева).
Один узел B-дерева представляет собой непрерывный отсортированный массив ключей. Если размер страницы диска составляет 4096 байт, а один целочисленный ключ с указателем занимает 16 байт, мы можем упаковать в один узел около 255 ключей и 256 указателей на детей.
- Экстремально низкая высота дерева: Если каждый узел ветвится на 250 детей, то на глубине всего 3 уровней дерево способно адресовать $250^3 \approx 15.6$ миллионов записей!
- Минимум дисковых операций IO: Поиск ключа среди 15 миллионов записей потребует всего 3 чтений с диска (вместо 24 чтений в бинарном дереве поиска). Более того, корневой узел и верхние уровни постоянно удерживаются в кэше оперативной памяти СУБД (Buffer Pool), поэтому реальное физическое обращение к диску при поиске сводится к всего одному IO-запросу!
B+ дерево: Стандарт реляционных баз данных
Классическое B-дерево хранит полезную нагрузку (значения строк или указатели на кортежи) прямо во внутренних узлах вперемешку с ключами. Это порождает системный недостаток: «тяжелые» значения строк быстро съедают объем 4-килобайтной страницы, из-за чего в один узел помещается значительно меньше ключей-роутеров. Степень ветвления падает, высота дерева растет, а число дисковых чтений увеличивается.
Чтобы решить эту проблему, инженеры разработали модификацию — B+ дерево (B+ Tree). Сегодня это безоговорочный золотой стандарт де-факто для индексов в движке InnoDB (MySQL), СУБД PostgreSQL и файловых системах.
Отличия B+ дерева:
- Строгое разделение ролей: Внутренние узлы (Internal Nodes) хранят исключительно ключи (индексные роутеры-указатели). Они работают как дорожные указатели на развилках. Вся фактическая полезная нагрузка (строки базы данных или ссылки на них) хранится строго в Листьях (Leaf Nodes) на самом нижнем уровне иерархии.
- Связанные листья (Linked Leaves): Все листовые узлы нижнего уровня физически соединены между собой ссылками в непрерывный двусвязный список.
flowchart TD
Root["Корень (Root Page)<br/>Ключ: 50"]
L1_1["Внутренний узел L1<br/>Ключи: 20 | 30"]
L1_2["Внутренний узел L2<br/>Ключи: 70 | 90"]
Leaf1["Лист 1 (Page 1)<br/>Данные: [10, 15]"]
Leaf2["Лист 2 (Page 2)<br/>Данные: [20, 25]"]
Leaf3["Лист 3 (Page 3)<br/>Данные: [30, 40]"]
Leaf4["Лист 4 (Page 4)<br/>Данные: [50, 60]"]
Leaf5["Лист 5 (Page 5)<br/>Данные: [70, 80]"]
Leaf6["Лист 6 (Page 6)<br/>Данные: [90, 100]"]
Root -->|"< 50"| L1_1
Root -->|">= 50"| L1_2
L1_1 -->|"< 20"| Leaf1
L1_1 -->|"20 .. 29"| Leaf2
L1_1 -->|">= 30"| Leaf3
L1_2 -->|"< 70"| Leaf4
L1_2 -->|"70 .. 89"| Leaf5
L1_2 -->|">= 90"| Leaf6
Leaf1 <-->|"next/prev"| Leaf2
Leaf2 <-->|"next/prev"| Leaf3
Leaf3 <-->|"next/prev"| Leaf4
Leaf4 <-->|"next/prev"| Leaf5
Leaf5 <-->|"next/prev"| Leaf6
classDef rootNode fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef innerNode fill:#1e3a8a,stroke:#60a5fa,stroke-width:1.5px,color:#f8fafc;
classDef leafNode fill:#14532d,stroke:#10b981,stroke-width:1.5px,color:#f8fafc;
class Root rootNode;
class L1_1,L1_2 innerNode;
class Leaf1,Leaf2,Leaf3,Leaf4,Leaf5,Leaf6 leafNode;
Зачем связывать листья? (Range Queries)
Связный список листьев — главная архитектурная победа B+ дерева. В прикладном бэкенде мы непрерывно выполняем интервальные запросы (Range Queries):
SELECT * FROM orders WHERE created_at BETWEEN '2023-01-01' AND '2023-01-31'.
В классическом дереве для выборки диапазона движку пришлось бы непрерывно подниматься к родителям и спускаться по соседним веткам (Tree Traversal), совершая десятки хаотичных операций чтения.
В B+ дереве выполнение запроса протекает идеально:
- За время $O(\log_M N)$ движок спускается от корня строго к тому листу, где расположен первый ключ
'2023-01-01'(всего 2–3 чтения страниц). - Начиная с этого листа, СУБД больше не трогает дерево: она просто шагает вправо по указателю
nextдвусвязного списка, считывая строки подряд, пока не встретит дату'2023-01-31'.
Страницы листьев на диске СУБД старается размещать физически последовательно (Extent allocation). Поэтому обход листьев превращается в Sequential IO (Последовательное чтение), которое аппаратно выполняется контроллерами SSD и HDD в десятки раз быстрее случайного доступа.
Проектирование узла B+ дерева в Go
При реализации собственного дискового движка или in-memory кэша на Go необходимо структурировать узел так, чтобы исключить фрагментацию кучи и гарантировать Mechanical Sympathy:
package btree
import "sort"
// Degree определяет порядок дерева (максимальное число детей).
// В реальной БД подбирается так, чтобы sizeof(Node) == PageSize (напр. 4096 байт)
const Degree = 256
type Key int
type Value []byte // Данные строки для листовых узлов
// Node представляет узел B+ дерева.
type Node struct {
IsLeaf bool
NumKeys int // Текущее количество ключей в узле
Keys [Degree]Key // Монолитный массив отсортированных ключей
// В Go мы используем указатели на узлы в памяти.
// В реальном дисковом движке здесь хранятся uint64 — байтовые смещения (offsets) страниц в файле БД.
Children [Degree + 1]*Node
// Полезная нагрузка (хранится только если IsLeaf == true)
Values [Degree]Value
Next *Node // Указатель на следующий лист для интервального сканирования
}
// Search выполняет поиск значения по ключу внутри узла.
// Использует бинарный поиск, так как массив ключей строго отсортирован.
func (n *Node) Search(target Key) (Value, bool) {
// Ищем индекс первого ключа, который >= target
idx := sort.Search(n.NumKeys, func(i int) bool {
return n.Keys[i] >= target
})
if n.IsLeaf {
// Если мы находимся в листе и ключ совпал — возвращаем значение
if idx < n.NumKeys && n.Keys[idx] == target {
return n.Values[idx], true
}
return nil, false
}
// Если мы во внутреннем узле, используем idx для спуска к нужному ребенку
if idx < n.NumKeys && n.Keys[idx] == target {
idx++
}
// Рекурсивный спуск (на диске это означало бы чтение страницы по адресу Children[idx])
return n.Children[idx].Search(target)
}
Когда 4-килобайтная страница загружена в память, функция sort.Search выполняет бинарный поиск по массиву Keys всего за несколько наносекунд: плотный массив уже целиком находится в L1/L2 кэше CPU.
Сплит и балансировка (Split and Merge)
Вставка новых записей в B+ дерево всегда производится строго в листовой узел. Что происходит, когда лист переполняется (число ключей достигает предела Degree)?
Вместо вращений и перекрасок B+ дерево применяет элегантную операцию Расщепления (Split):
- Переполненный узел делится ровно пополам: аллоцируется новая страница (новый узел).
- Правая половина ключей перемещается в новый узел.
- Разделительный «средний» ключ (медиана) выталкивается на один уровень вверх — в родительский узел, где регистрируется как новый индексный ориентир (роутер).
- Если родительский узел также оказывается переполнен, операция сплита каскадно поднимается выше. Если расщепляется корень — над ним создается новый корень с двумя детьми, и вся глубина дерева увеличивается строго на +1 для абсолютно всех листьев одновременно!
Итог и ограничения B+ деревьев
B+ дерево — величайший триумф инженерной мысли в эпоху блочных устройств хранения (HDD и классических SSD). Оно минимизирует дисковый I/O, обеспечивает плоскую логарифмическую глубину с огромным основанием и безупречно ускоряет интервальные Range-запросы.
Однако развитие технологий и лавинообразный рост потоков данных (Big Data, IoT, распределенное логирование, кликстрим) вскрыли фундаментальное физическое ограничение B+ деревьев.
При интенсивной вставке данных с неупорядоченными ключами (например, случайными строками или UUID) B+ дерево производит непрерывные случайные дисковые записи (Random Writes): алгоритм вынужден обновлять случайные страницы в разных секторах файла базы данных. Для твердотельных накопителей SSD/NVMe случайные мелкие перезаписи губительны: возникает эффект усиления записи (Write Amplification), контроллер диска перегружается фоновой сборкой мусора (Garbage Collection), а пропускная способность резко падает.
Для систем, где нагрузка на запись превосходит чтение в соотношении 10:1 или 100:1 (Time-Series базы, сбор логов, Cassandra, ClickHouse, RocksDB), архитектура B+ деревьев оказывается узким местом. На смену им пришла совершенно иная парадигма, трансформирующая хаотичные случайные записи в строго непрерывный последовательный поток (Sequential IO). Эту инженерную революцию мы изучим в следующей статье: 4. LSM дерево.