Мотивация: почему не BST
Бинарное дерево поиска (BST), AVL или Red-Black дают O(log₂ n) операций. При n = 10⁹ это ~30 уровней. Каждый переход по указателю -- потенциальный seek на диске. На HDD один seek ≈ 5-10 ms; 30 × 10 ms = 300 ms на одну точечную выборку. Неприемлемо.
Идея B-tree (Bayer & McCreight, 1972): сделать узел большим -- размером с дисковый блок (4-16 KB), содержащим десятки или сотни ключей. Тогда ветвление b ~ 100-1000, и высота h = log_b(n). При n = 10⁹ и b = 100 получаем h = log₁₀₀(10⁹) = 4.5 -- 5 уровней. Верхние уровни кэшируются в RAM, на диск идёт 1-2 чтения.
B-tree: основные определения
B-tree порядка m -- сбалансированное дерево со свойствами:
- Каждый узел содержит до m-1 ключей и до m детей.
- Каждый узел (кроме корня) содержит не меньше ⌈m/2⌉ - 1 ключей.
- Ключи в узле отсортированы.
- Все листья находятся на одном уровне (perfect balance).
- И в листьях, и во внутренних узлах хранятся values (или указатели на них).
B-tree (m=5):
[ 30 | 60 ] <-- internal node
/ | \
[10|20] [40|50] [70|80|90] <-- leaves with values
v10 v20 v40 v50 v70 v80 v90
B+ tree: ключевая модификация
B+ tree отличается двумя вещами:
- Values хранятся только в листьях. Внутренние узлы содержат только ключи-разделители.
- Листья связаны в связный список. Это делает range scan тривиальным: нашли начало диапазона и идём по next-указателям.
B+ tree (m=5):
[ 30 | 60 ] <-- router keys only
/ | \
[10|20] -> [30|40|50] -> [60|70|80] <-- leaves linked list
v10 v20 v30 v40 v50 v60 v70 v80
^^^^^^^^^^^^ values live here ^^^^^^^^^
B-tree vs B+ tree
| Свойство | B-tree | B+ tree |
|---|---|---|
| Где values | В любом узле | Только в листьях |
| Листья связаны | Нет | Да (linked list) |
| Высота | Чуть ниже (values заняли место) | Чуть выше |
| Range scan | Сложнее (in-order обход) | O(log n + k) через next |
| Point lookup | Может остановиться раньше | Всегда идёт до листа |
| Fanout internal | Ниже (values в узле) | Выше (только ключи) |
| Использование | В теории, старые БД | PostgreSQL, MySQL InnoDB, SQLite |
Большинство реальных СУБД используют B+ tree именно ради range scan'ов и большего fanout внутренних узлов.
Высота и branching factor
Высота B+ tree: h = ⌈log_b(n)⌉, где b -- среднее заполнение узла (обычно 0.67-0.75 × max).
При page size 8 KB, ключ 16 байт, указатель 8 байт:
- Внутренний узел: 8192 / (16 + 8) ≈ 340 детей.
- Для 10⁹ записей высота = log₃₄₀(10⁹) ≈ 3.5 -- то есть 4 уровня.
- 4 seek'а × 100 µs (SSD) = 400 µs на точечную выборку.
Disk-friendly дизайн
B-tree узел = page (страница). PostgreSQL использует 8 KB по умолчанию, MySQL InnoDB -- 16 KB. Это совпадает с типичным размером I/O unit файловой системы и делает чтение/запись узла атомарной операцией.
- Sequential writes выгоднее: но B-tree пишет в случайные места при split'ах -- это проблема для HDD. На SSD менее критично.
- WAL (Write-Ahead Log): прежде чем менять страницу, пишем запись в лог. Это защита от crash mid-write.
- Buffer pool: верхние уровни дерева почти всегда в RAM. В PostgreSQL это
shared_buffers.
Split: что происходит при вставке в полный узел
- Делим узел пополам: половина ключей остаётся, половина уходит в новый узел.
- Срединный ключ поднимается к родителю.
- Если родитель тоже полон, split продолжается рекурсивно.
- Если split'ится корень -- создаётся новый корень, высота растёт на 1.
Высота меняется только при split'е корня. Поэтому дерево растёт "сверху", а листья всегда на одном уровне.
Insert 45 into full leaf [30|40|50] in B+ tree (m=3, max 2 keys/leaf):
Before:
[60]
/ \
[30|40|50] [60|70]
After split (40 promoted):
[40|60]
/ | \
[30] [45|50] [60|70]
Merge/redistribute при удалении
Симметрично: если узел стал меньше ⌈m/2⌉ - 1 ключей, он либо заимствует ключ у соседа (redistribute), либо сливается с соседом (merge). При merge ключ-разделитель из родителя спускается вниз.
Многие реальные реализации (в том числе PostgreSQL) не делают merge при удалении -- страницы помечаются как содержащие мало данных, но физически не сливаются. Это упрощение; чистку делает vacuum.
PostgreSQL B-tree внутри
PostgreSQL B-tree (на самом деле B+ tree) -- это btree access method.
- Page size 8 KB.
- Поддерживает multi-column индексы, partial, expression.
INCLUDE-колонки хранятся только в листьях (covering index).- Deduplication (13+): одинаковые ключи хранятся один раз со списком TIDs.
- Leaf pages содержат
(key, tid), где TID = (page, offset) строки в heap. - Internal pages содержат
(key, child_page).
Типичная операция lookup:
- Прочитать root page (обычно в buffer pool).
- Binary search внутри страницы → найти child.
- Прочитать child page → повторить.
- В листе найти TID → прочитать heap page по этому TID.
Итого: h + 1 чтений (h = 3-4 для миллиардов записей).
MySQL InnoDB: clustered B+ tree
В InnoDB primary key index -- это и есть таблица. Листья primary B+ tree содержат сами строки, а не указатели на них. Это называется clustered index.
Секундарные индексы содержат (secondary_key, primary_key) и требуют второго lookup'а в primary для получения остальных колонок.
Плюсы:
- Range scan по primary key очень быстр (листья связаны, строки рядом).
- Нет extra indirection через heap.
Минусы:
- Вставки в случайный PK → fragmentation (рекомендация: monotonic PK типа AUTO_INCREMENT).
- Изменение PK = физическое перемещение строки.
Минимальная реализация
Ниже -- скелет B-tree узла с операциями search и insert (без split'а для краткости). Образовательный пример, не для продакшена.
<?php
declare(strict_types=1);
/**
* Minimal B-tree node for teaching purposes.
* Real implementations store nodes on disk and manage page I/O.
*/
final class BTreeNode
{
/** @var list<int> */
public array $keys = [];
/** @var list<BTreeNode> */
public array $children = [];
public function __construct(public bool $isLeaf = true) {}
public function isFull(int $order): bool
{
return count($this->keys) === $order - 1;
}
}
final class BTree
{
public BTreeNode $root;
public function __construct(public readonly int $order = 5)
{
$this->root = new BTreeNode(isLeaf: true);
}
/** Search for a key; returns node + index if found, null otherwise. */
public function search(int $key, ?BTreeNode $node = null): ?array
{
$node ??= $this->root;
$i = 0;
while ($i < count($node->keys) && $key > $node->keys[$i]) {
$i++;
}
if ($i < count($node->keys) && $node->keys[$i] === $key) {
return ['node' => $node, 'index' => $i];
}
if ($node->isLeaf) {
return null;
}
return $this->search($key, $node->children[$i]);
}
public function insert(int $key): void
{
$root = $this->root;
// If root is full, grow height by 1
if ($root->isFull($this->order)) {
$newRoot = new BTreeNode(isLeaf: false);
$newRoot->children[] = $root;
$this->splitChild($newRoot, 0);
$this->root = $newRoot;
$this->insertNonFull($newRoot, $key);
} else {
$this->insertNonFull($root, $key);
}
}
private function insertNonFull(BTreeNode $node, int $key): void
{
$i = count($node->keys) - 1;
if ($node->isLeaf) {
// Shift keys right and insert
while ($i >= 0 && $key < $node->keys[$i]) {
$node->keys[$i + 1] = $node->keys[$i];
$i--;
}
$node->keys[$i + 1] = $key;
return;
}
// Find child to descend into
while ($i >= 0 && $key < $node->keys[$i]) {
$i--;
}
$i++;
if ($node->children[$i]->isFull($this->order)) {
$this->splitChild($node, $i);
if ($key > $node->keys[$i]) {
$i++;
}
}
$this->insertNonFull($node->children[$i], $key);
}
private function splitChild(BTreeNode $parent, int $idx): void
{
$order = $this->order;
$full = $parent->children[$idx];
$mid = intdiv($order - 1, 2);
$newNode = new BTreeNode(isLeaf: $full->isLeaf);
$newNode->keys = array_slice($full->keys, $mid + 1);
if (!$full->isLeaf) {
$newNode->children = array_slice($full->children, $mid + 1);
$full->children = array_slice($full->children, 0, $mid + 1);
}
// Promote middle key to parent
$promoted = $full->keys[$mid];
$full->keys = array_slice($full->keys, 0, $mid);
array_splice($parent->keys, $idx, 0, [$promoted]);
array_splice($parent->children, $idx + 1, 0, [$newNode]);
}
}
- Very write-heavy workload (миллионы RPS на запись): split'ы становятся узким местом, LSM лучше.
- Time-series данные (монотонно растущий PK): хвост дерева вечно split'ится, лучше append-only log.
- Очень большие values: B-tree хранит строки в листьях, лучше heap file с указателями.
Выводы
- B-tree -- не BST: узлы сознательно большие (размер page), чтобы минимизировать глубину и число seek'ов.
- B+ tree -- де-факто стандарт реляционных БД: values только в листьях, листья связаны в список.
- Высота h = log_b(n); при b = 340 и n = 10⁹ достаточно 3-4 уровня.
- Split растит дерево "сверху", не снизу -- все листья на одном уровне.
- PostgreSQL, MySQL InnoDB, SQLite, Oracle, SQL Server -- все используют B+ tree как базовый индексный AM.
- Для write-heavy и time-series workload'ов LSM tree может быть лучше.