MidТеория9 min

B-tree и B+ tree

Структура B-tree и B+ tree, disk-friendly дизайн, split/merge, применение в PostgreSQL и MySQL InnoDB

Мотивация: почему не 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 отличается двумя вещами:

  1. Values хранятся только в листьях. Внутренние узлы содержат только ключи-разделители.
  2. Листья связаны в связный список. Это делает 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: что происходит при вставке в полный узел

  1. Делим узел пополам: половина ключей остаётся, половина уходит в новый узел.
  2. Срединный ключ поднимается к родителю.
  3. Если родитель тоже полон, split продолжается рекурсивно.
  4. Если 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:

  1. Прочитать root page (обычно в buffer pool).
  2. Binary search внутри страницы → найти child.
  3. Прочитать child page → повторить.
  4. В листе найти 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]);
    }
}
## Когда B-tree плохой выбор
  • 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 может быть лучше.