Раздел 10 · Databases

Своя СУБД на TS · урок 4: B+Tree индекс

senior~120 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Своя СУБД на TS · урок 4: B+Tree индекс

Кому это. К этому уроку у тебя есть три нижних слоя движка. В уроке 1 появилась страница и DiskManager, в уроке 2 над диском встал буферный пул, в уроке 3 поверх пула выросла куча: таблица как набор страниц, строка адресуется стабильным RID, sequential scan обходит всё. Но у кучи есть одна большая дыра, и сегодня мы её закрываем.

Зачем. Найти строку по ключу в куче можно только одним способом: прочитать всю таблицу и сравнить каждую строку. Это O(числа строк), и на миллионе строк один SELECT ... WHERE id = 42 поднимает с диска всю таблицу. Так работать нельзя. Нужна структура, которая по ключу приводит прямо к RID за считанные обращения к диску и при этом умеет отдавать ключи по порядку для диапазонных запросов. Эта структура это B+Tree, рабочая лошадка индексов почти во всех реляционных СУБД.

Важно про код. Как и прежде, ты пишешь реализацию с нуля, листинги приведены целиком и разобраны построчно. Это самый алгоритмически плотный урок воркшопа: расщепление узла с подъёмом разделителя легко понять на картинке и легко напутать в коде на границах. Печатай руками, прогоняй тесты после каждого блока, и обязательно поиграй с виджетом ниже: увидеть split вживую сильно дешевле, чем отлаживать его по логам.

Где мы в движке

┌────────────────────────────────────────────────┐
│  Бинарный протокол + TCP-сервер                  │  урок 10
├────────────────────────────────────────────────┤
│  Исполнитель (Volcano) + EXPLAIN                 │  урок 6
│  Планировщик, binder, парсер, лексер             │  уроки 5, 6
├────────────────────────────────────────────────┤
│  Vacuum                                          │  урок 9
│  MVCC (видимость, снимки)                        │  урок 8
│  Блокировки (2PL), менеджер транзакций           │  уроки 7, 8
│  WAL (журнал, redo, recovery)                    │  урок 7
├────────────────────────────────────────────────┤
│  B+Tree индекс                                   │  урок 4  ← мы здесь
│  Куча (slotted pages, seq scan)                  │  урок 3
├────────────────────────────────────────────────┤
│  Буферный пул (кэш страниц, вытеснение)           │  урок 2
│  DiskManager (страницы поверх файла)              │  урок 1
└────────────────────────────────────────────────┘

Индекс встаёт ровно туда же, куда куча: поверх буферного пула. Узел дерева это страница, и за чтение, запись и аллокацию страниц отвечает всё тот же пул из урока 2. Индекс не знает про диск напрямую, он знает только про fetchPage, unpinPage, newPage. Куча отображала RID в строку, индекс отображает ключ в RID. Связка получается такая: индекс по ключу находит RID, куча по RID отдаёт строку.

Почему именно B+Tree

Прежде чем городить байты, стоит понять, почему индекс это не обычное двоичное дерево поиска, которое ты писал в разделе про алгоритмы.

На диске единица обмена это не байт и не ключ, а целая страница в 8 КБ. Прочитать один байт стоит ровно столько же, сколько прочитать всю страницу: диск так устроен. Двоичное дерево хранит по одному ключу в узле, значит на спуске к листу оно трогает по узлу на каждый бит высоты, и при миллионе ключей это около 20 узлов, то есть до 20 обращений к диску. Беда не в сравнениях, беда в походах на диск.

B+Tree бьёт по этой беде в лоб: он кладёт в один узел не один ключ, а сотни, ровно столько, сколько влезает в страницу. Это число называется fanout, и именно он делает дерево низким. Высота дерева это логарифм числа ключей по основанию fanout. При fanout в несколько сотен миллионы ключей умещаются в три-четыре уровня. Поиск по ключу это спуск от корня к листу, то есть три-четыре прочитанные страницы вместо двадцати. Вот зачем индекс это широкое дерево, а не двоичное.

Буква “плюс” в названии означает конкретное устройство. В B+Tree сами значения (у нас это RID) лежат только в листьях. Внутренние узлы хранят лишь разделители: ключи-указатели, которые говорят, в какую ветку спускаться. Это даёт два выигрыша. Первый: внутренние узлы не тащат значения, поэтому в страницу влезает больше разделителей, fanout ещё выше, дерево ещё ниже. Второй, и для нас главный: листья связаны в цепочку слева направо. Диапазонный запрос (WHERE id BETWEEN 25 AND 60) спускается к первому подходящему листу и дальше просто бежит по цепочке, не возвращаясь вверх. В обычном B-дереве, где значения размазаны по всем уровням, range scan устроен куда мучительнее.

И последнее свойство, которое делает B+Tree деревом, а не просто отсортированным списком страниц: оно всегда сбалансировано. Все листья лежат на одной глубине, и эта глубина растёт только когда расщепляется корень. Балансировку дерево держит само, через расщепление переполненных узлов снизу вверх, без отдельного прохода ребалансировки. Поиграй: вставляй ключи и смотри, как лист переполняется, делится, как разделитель всплывает наверх и как в какой-то момент корень расщепляется и дерево становится выше.

Если повозился с виджетом, ты уже видел три вещи, которые сейчас запрограммируешь. Первая: лист переполняется и делится на два, минимальный ключ правого листа копируется наверх как разделитель. Вторая: новые листья встают в цепочку, поэтому скан по порядку идёт слева направо. Третья: высота растёт только при расщеплении корня, и все листья всегда на одном уровне. Теперь уложим это в байты страниц.

Контракт байтов узла

Узел B+Tree это страница, но размечена она иначе, чем страница кучи. Куча использовала slotted-раскладку: заголовок, массив слотов, данные с конца. Узлу слоты не нужны, ему нужен компактный отсортированный массив ячеек. Поэтому узел переиспользует страницу так: заголовок узла лежит не в начале, а в special-области в конце страницы, а ячейки пакуются подряд от начала области данных вверх к этому заголовку. Числа little-endian, как и весь дисковый формат (см. контракт).

Заголовок узла это 8 байт, он начинается со смещения special = PAGE_SIZE - 8:

смещение  размер  поле
0         2       node_type   u16   0 = internal, 1 = leaf
2         2       key_count   u16   число разделителей (внутр.) или ячеек (лист)
4         4       next_leaf   u32   page id следующего листа (0 = нет); только у листьев

Лист хранит отсортированные по ключу ячейки (key, rid) фиксированной длины 14 байт. Ключ это знаковый int (тот же тип, что в куче), RID это пара (page id кучи, слот):

смещение  размер  поле
0         8       key        i64   ключ (знаковый)
8         4       rid_page   u32   page id кучи
12        2       rid_slot   u16   слот кучи

Листья связаны через next_leaf в цепочку по возрастанию ключей: это и есть тот рельс, по которому катится range scan.

Внутренний узел устроен чуть хитрее. Детей у него на один больше, чем разделителей, потому что разделитель стоит между двумя ветками. Поэтому первый, самый левый ребёнок (назовём его child0) хранится отдельным ведущим указателем, а дальше идут ячейки (separator, child):

смещение            размер  поле
16                  4       child0      u32   самый левый ребёнок
20 + i*12           8       separator   i64   разделитель s_i
20 + i*12 + 8       4       child       u32   ребёнок c_(i+1)

Разделитель s_i это минимальный ключ поддерева child(i+1), а child0 держит ключи меньше s_0. Спуск по ключу K это число разделителей, у которых s_i <= K: равенство уходит вправо, ровно туда, куда вставка кладёт новые равные ключи. Дубликаты ключей разрешены, поэтому начало range scan спускается по самому левому ребёнку с s_i >= K, чтобы не проскочить дубликаты в листьях левее.

Наконец, файл индекса самоописывающий. Page 0 это метастраница, которая держит указатель на корень, остальные страницы это узлы. Корень переживает перезапуск именно через метастраницу:

смещение  размер  поле
0         4       magic       u32   0x42545231 ("BTR1")
4         4       root        u32   page id корня (переживает перезапуск)

Корнем при создании индекса становится лист (page 1). Когда корень расщепляется, заводится новый внутренний корень, и его page id переписывается в метастраницу.

Константы узла

Добавь к constants.ts из прошлых уроков (там уже лежат PAGE_SIZE, HEADER_SIZE, SLOT_SIZE и смещения заголовков страницы и кортежа) константы B+Tree.

// src/constants.ts (добавляем к написанному в уроках 1 и 3)

/** Размер заголовка узла: nodeType u16, keyCount u16, nextLeaf u32. */
export const NODE_HEADER_SIZE = 8;

/** Начало special-области узла (туда кладётся заголовок узла). */
export const BTREE_SPECIAL = PAGE_SIZE - NODE_HEADER_SIZE;

/** Тип узла (хранится в заголовке узла). */
export const NodeType = {
  internal: 0,
  leaf: 1,
} as const;

/** Смещения полей заголовка узла внутри special-области. */
export const NodeHeaderOffset = {
  nodeType: 0,
  keyCount: 2,
  nextLeaf: 4,
} as const;

/** Ячейка листа: key i64 (8) + rid.pageId u32 (4) + rid.slot u16 (2). */
export const LEAF_CELL_SIZE = 14;

/** Смещение ведущего указателя child0 во внутреннем узле (сразу за заголовком страницы). */
export const INTERNAL_CHILD0_OFFSET = HEADER_SIZE;

/** Ячейка внутреннего узла: separator i64 (8) + child u32 (4). */
export const INTERNAL_CELL_SIZE = 12;

/** Сентинел "нет соседнего листа" в nextLeaf. Page 0 это метастраница, не лист. */
export const NO_SIBLING = 0;

/** Магия метастраницы индекса (page 0): ASCII "BTR1". */
export const BTREE_META_MAGIC = 0x42_54_52_31;

/** Смещения полей метастраницы индекса. */
export const MetaOffset = {
  magic: 0,
  root: 4,
} as const;

Заметь два сентинела. NO_SIBLING это 0, и это безопасно: следующим листом не может быть page 0, потому что page 0 это метастраница, а не узел. BTREE_META_MAGIC это четыре ASCII-байта BTR1: при открытии индекса мы сверим магию и упадём с понятной ошибкой, если открыли не тот файл, а не прочитаем мусор как номер корня.

BTreeNode: вид на страницу как на узел

Как Tuple в уроке 3 был тонким видом на байты кортежа, так BTreeNode это тонкий типизированный вид на байты страницы как на узел дерева. Без всякого ввода-вывода: читать и писать страницу на диск это дело пула, узел только размечает её содержимое. Начнём с каркаса и инициализации.

// src/btree-node.ts
import {
  BTREE_SPECIAL,
  HEADER_SIZE,
  INTERNAL_CELL_SIZE,
  INTERNAL_CHILD0_OFFSET,
  LEAF_CELL_SIZE,
  NO_SIBLING,
  NodeHeaderOffset,
  NodeType,
} from '~/constants.ts';
import type { Rid } from '~/heap-file.ts';
import type { Page } from '~/page.ts';

/** Смещение поля rid.pageId внутри ячейки листа. */
const LEAF_PAGE_ID_OFFSET = 8;
/** Смещение поля rid.slot внутри ячейки листа. */
const LEAF_SLOT_OFFSET = 12;
/** Смещение поля child внутри ячейки внутреннего узла. */
const INTERNAL_CHILD_OFFSET = 8;

export class BTreeNode {
  readonly page: Page;
  private readonly buffer: Buffer;

  constructor(page: Page) {
    this.page = page;
    this.buffer = page.buffer;
  }

  /** Инициализировать страницу как пустой лист. Сначала выставляем special. */
  static initLeaf(page: Page): BTreeNode {
    page.special = BTREE_SPECIAL;
    const node = new BTreeNode(page);
    node.writeNodeType(NodeType.leaf);
    node.keyCount = 0;
    node.nextLeaf = NO_SIBLING;
    return node;
  }

  /** Инициализировать страницу как пустой внутренний узел. */
  static initInternal(page: Page): BTreeNode {
    page.special = BTREE_SPECIAL;
    const node = new BTreeNode(page);
    node.writeNodeType(NodeType.internal);
    node.keyCount = 0;
    node.nextLeaf = NO_SIBLING;
    return node;
  }

  get isLeaf(): boolean {
    return this.buffer.readUInt16LE(BTREE_SPECIAL + NodeHeaderOffset.nodeType) === NodeType.leaf;
  }

  get keyCount(): number {
    return this.buffer.readUInt16LE(BTREE_SPECIAL + NodeHeaderOffset.keyCount);
  }
  set keyCount(value: number) {
    this.buffer.writeUInt16LE(value, BTREE_SPECIAL + NodeHeaderOffset.keyCount);
  }

  get nextLeaf(): number {
    return this.buffer.readUInt32LE(BTREE_SPECIAL + NodeHeaderOffset.nextLeaf);
  }
  set nextLeaf(value: number) {
    this.buffer.writeUInt32LE(value, BTREE_SPECIAL + NodeHeaderOffset.nextLeaf);
  }

  // методы листа и внутреннего узла ниже

  private writeNodeType(type: number): void {
    this.buffer.writeUInt16LE(type, BTREE_SPECIAL + NodeHeaderOffset.nodeType);
  }

  private leafCellOffset(index: number): number {
    return HEADER_SIZE + index * LEAF_CELL_SIZE;
  }

  private internalCellOffset(index: number): number {
    return INTERNAL_CHILD0_OFFSET + 4 + index * INTERNAL_CELL_SIZE;
  }
}

Обрати внимание на порядок в initLeaf: сначала page.special = BTREE_SPECIAL, потом всё остальное. Заголовок узла лежит в special-области, а её начало мы только что объявили полем страницы. keyCount и nextLeaf это просто аксессоры к двум полям в special, а leafCellOffset/internalCellOffset считают, где лежит ячейка с данным индексом: ячейки идут подряд от HEADER_SIZE (для внутреннего узла после ведущего child0, поэтому плюс 4). Дальше всё ветвление будет про две раскладки: лист и внутренний узел.

Методы листа

Лист это отсортированный массив ячеек (key, rid). Чтение ячейки, бинарный поиск по ключу, вставка со сдвигом хвоста и расщепление.

  // -------- лист --------

  /** Ключ ячейки листа по индексу. */
  keyAt(index: number): bigint {
    return this.buffer.readBigInt64LE(this.leafCellOffset(index));
  }

  /** RID ячейки листа по индексу. */
  ridAt(index: number): Rid {
    const offset = this.leafCellOffset(index);
    return {
      pageId: this.buffer.readUInt32LE(offset + LEAF_PAGE_ID_OFFSET),
      slot: this.buffer.readUInt16LE(offset + LEAF_SLOT_OFFSET),
    };
  }

  /** Первый индекс, где keyAt(index) >= key (lower_bound). Дубликаты кластеризуются. */
  lowerBound(key: bigint): number {
    let low = 0;
    let high = this.keyCount;
    while (low < high) {
      const middle = (low + high) >>> 1;
      if (this.keyAt(middle) < key) {
        low = middle + 1;
      } else {
        high = middle;
      }
    }
    return low;
  }

  /** Вставить ячейку листа в позицию index, сдвинув хвост вправо. */
  leafInsertAt(index: number, key: bigint, rid: Rid): void {
    const count = this.keyCount;
    const target = this.leafCellOffset(index);
    if (index < count) {
      this.buffer.copyWithin(this.leafCellOffset(index + 1), target, this.leafCellOffset(count));
    }
    this.buffer.writeBigInt64LE(key, target);
    this.buffer.writeUInt32LE(rid.pageId, target + LEAF_PAGE_ID_OFFSET);
    this.buffer.writeUInt16LE(rid.slot, target + LEAF_SLOT_OFFSET);
    this.keyCount = count + 1;
  }

  /**
   * Разрезать лист: ячейки [mid..) переезжают в right, в текущем остаётся [0..mid).
   * Связь листьев: right.nextLeaf = старый this.nextLeaf, затем this.nextLeaf =
   * rightPageId. Порядок важен, иначе порвём цепочку для range scan.
   */
  leafSplitInto(right: BTreeNode, rightPageId: number, mid: number): void {
    const count = this.keyCount;
    this.buffer.copy(
      right.buffer,
      HEADER_SIZE,
      this.leafCellOffset(mid),
      this.leafCellOffset(count),
    );
    right.keyCount = count - mid;
    right.nextLeaf = this.nextLeaf;
    this.keyCount = mid;
    this.nextLeaf = rightPageId;
  }

Разберём ключевое.

lowerBound это бинарный поиск первого ключа, не меньшего искомого. Он вернёт либо позицию точного совпадения, либо место, куда ключ надо вставить, чтобы массив остался отсортированным. На этом стоит и точечный поиск (там после lowerBound мы сверяем, что ключ действительно равен искомому), и вставка (туда мы и вставляем). Поскольку дубликаты разрешены, lowerBound находит самый левый из равных, и они оказываются рядом, кластером.

leafInsertAt сдвигает хвост вправо через copyWithin, освобождая место под новую ячейку, и пишет её. copyWithin это копирование в пределах одного буфера, оно корректно работает на пересекающихся диапазонах, поэтому сдвиг вправо не затирает данные. Если вставляем в конец (index === count), сдвигать нечего.

leafSplitInto это сердце расщепления листа. Верхняя половина ячеек [mid..) копируется в новый правый лист, в текущем остаётся нижняя [0..mid). Дальше критичные две строки про цепочку: сначала правый лист наследует старого соседа текущего (right.nextLeaf = this.nextLeaf), и только потом текущий начинает указывать на правый (this.nextLeaf = rightPageId). Поменяешь порядок, и right укажет сам на себя или потеряет хвост цепочки, и range scan либо зациклится, либо оборвётся. Порядок тут не стилистика, а корректность.

Методы внутреннего узла

Внутренний узел это ведущий child0 плюс массив ячеек (separator, child). Чтение детей и разделителей, два варианта маршрутизации, вставка разделителя и расщепление.

  // -------- внутренний узел --------

  /** Дочерний указатель по индексу. child0 это ведущий указатель, остальные в ячейках. */
  childAt(index: number): number {
    if (index === 0) {
      return this.buffer.readUInt32LE(INTERNAL_CHILD0_OFFSET);
    }
    return this.buffer.readUInt32LE(this.internalCellOffset(index - 1) + INTERNAL_CHILD_OFFSET);
  }

  setChildAt(index: number, child: number): void {
    if (index === 0) {
      this.buffer.writeUInt32LE(child, INTERNAL_CHILD0_OFFSET);
      return;
    }
    this.buffer.writeUInt32LE(child, this.internalCellOffset(index - 1) + INTERNAL_CHILD_OFFSET);
  }

  /** Разделитель по индексу: минимальный ключ поддерева childAt(index + 1). */
  separatorAt(index: number): bigint {
    return this.buffer.readBigInt64LE(this.internalCellOffset(index));
  }

  /**
   * Индекс ребёнка для спуска по ключу: число разделителей с s <= key (upper_bound).
   * Равенство уходит вправо, ровно куда вставка кладёт новые равные ключи. Это путь
   * для insert и точечного search.
   */
  route(key: bigint): number {
    let low = 0;
    let high = this.keyCount;
    while (low < high) {
      const middle = (low + high) >>> 1;
      if (this.separatorAt(middle) <= key) {
        low = middle + 1;
      } else {
        high = middle;
      }
    }
    return low;
  }

  /**
   * Индекс самого левого ребёнка, который может содержать key: число разделителей с
   * s < key (lower_bound, строго). Равенство уходит влево. Это путь для начала range
   * scan, чтобы не пропустить дубликаты в листьях левее.
   */
  routeLeftmost(key: bigint): number {
    let low = 0;
    let high = this.keyCount;
    while (low < high) {
      const middle = (low + high) >>> 1;
      if (this.separatorAt(middle) < key) {
        low = middle + 1;
      } else {
        high = middle;
      }
    }
    return low;
  }

  /** Вставить разделитель в позицию index, новый правый ребёнок встаёт на index + 1. */
  internalInsertAt(index: number, separator: bigint, child: number): void {
    const count = this.keyCount;
    const target = this.internalCellOffset(index);
    if (index < count) {
      this.buffer.copyWithin(
        this.internalCellOffset(index + 1),
        target,
        this.internalCellOffset(count),
      );
    }
    this.buffer.writeBigInt64LE(separator, target);
    this.buffer.writeUInt32LE(child, target + INTERNAL_CHILD_OFFSET);
    this.keyCount = count + 1;
  }

  /**
   * Разрезать внутренний узел: средний разделитель s[mid] поднимается наверх (НЕ
   * остаётся), left держит s[0..mid) и c[0..mid], right получает c[mid+1] как child0
   * плюс s[mid+1..) и c[mid+2..). Возвращает поднимаемый разделитель.
   */
  internalSplitInto(right: BTreeNode, mid: number): bigint {
    const count = this.keyCount;
    const promoted = this.separatorAt(mid);
    right.setChildAt(0, this.childAt(mid + 1));
    let rightIndex = 0;
    for (let source = mid + 1; source < count; source++) {
      right.writeSeparatorAt(rightIndex, this.separatorAt(source));
      right.setChildAt(rightIndex + 1, this.childAt(source + 1));
      rightIndex++;
    }
    right.keyCount = count - mid - 1;
    this.keyCount = mid;
    return promoted;
  }

  private writeSeparatorAt(index: number, separator: bigint): void {
    this.buffer.writeBigInt64LE(separator, this.internalCellOffset(index));
  }

Здесь два момента, в которых легко запутаться, поэтому разберём подробно.

childAt и сдвиг на один. Детей на один больше, чем разделителей. Ведущий child0 лежит отдельно (по INTERNAL_CHILD0_OFFSET), а ребёнок с индексом i для i >= 1 лежит в ячейке i - 1 рядом со своим разделителем. Поэтому childAt(0) читает ведущий указатель, а childAt(i) для остальных лезет в ячейку. Этот сдвиг на единицу между нумерацией детей и нумерацией разделителей это самый частый источник ошибок во всём узле, держи его в голове.

Два маршрутизатора: route и routeLeftmost. Они отличаются одним знаком сравнения, и это не придирка, а корректность дубликатов. route это upper_bound: разделители с s <= key уводят вправо, поэтому при равенстве ключа разделителю спуск идёт в правую ветку. Туда же вставка кладёт новые равные ключи, значит поиск и вставка ходят одним путём. routeLeftmost это строгий lower_bound: при равенстве он уходит влево, к самому левому листу, где может лежать дубликат искомого ключа. Им мы начинаем range scan, чтобы не проскочить равные ключи, осевшие в листе левее. Поставишь не тот знак, и точечный поиск дубля иногда промахнётся, а range scan потеряет первые совпадения. Это тот самый баг, который проявляется только на дубликатах и которого нет в простых тестах.

internalSplitInto. В отличие от листа, у внутреннего узла средний разделитель не копируется, а переезжает наверх и не остаётся ни в левом, ни в правом. Это и логично: разделитель листа это реальный ключ, который продолжает жить в листе, а разделитель внутреннего узла это чистый указатель-граница, и подняв его наверх, мы как раз делаем его границей между левой и правой половинами на уровень выше. Правый узел получает child(mid+1) ведущим, дальше разделители и детей после mid.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.