32.1 Сбалансированное дерево по страницам
B-tree - дерево, где все листья на одной глубине, а каждый узел - это страница 8 КБ. Есть три роли страниц:
- meta page (страница 0) - служебная, хранит указатель на корень и уровень дерева;
- внутренние страницы - содержат разделители и ссылки вниз (downlinks) на дочерние страницы;
- листовые страницы - содержат записи индекса: значение ключа и
ссылку
ctidна строку в таблице.
Поиск всегда начинается с meta page, идёт к корню, спускается по
внутренним страницам и заканчивается на листе, где лежит ctid.
Сбалансированность означает, что путь от корня до любого листа
одинаковой длины - поэтому время поиска предсказуемо.
32.2 Спуск своими глазами
pageinspect показывает страницы индекса так же, как heap. Сначала
meta page:
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT root, level FROM bt_metap('tickets_pkey');root - номер страницы корня, level - высота дерева (0 значит,
корень и есть лист). Затем смотрим содержимое страницы:
SELECT itemoffset, ctid, data
FROM bt_page_items('tickets_pkey', 1);На внутренней странице ctid в записи указывает на дочернюю
страницу (downlink); на листовой - на строку в таблице. Спускаясь
от корня по downlink к нужному диапазону, ты вручную пройдёшь тот
же путь, что и планировщик за тебя.
32.3 Почему дерево такое мелкое
Ключевое свойство B-tree - огромный коэффициент ветвления (fanout). На одной 8-КБ внутренней странице помещаются сотни разделителей, значит у каждого узла сотни детей. Глубина дерева - это логарифм по основанию fanout:
глубина ≈ log_fanout(число строк)
При fanout около 200-300 трёх уровней хватает на десятки миллионов строк, четырёх - на миллиарды. Поэтому поиск по индексу - это 3-4 чтения страниц, а не «обход дерева». Отсюда и стоимость в модели планировщика: индексный доступ дёшев, пока строк отбирается мало (см. btree-structure).
32.4 Split и правые ссылки
Когда в листовую страницу вставляют ключ, а места нет, страница делится (split): примерно половина записей уезжает в новую страницу, а наверх, в родителя, поднимается разделитель. Если родитель тоже полон - делится и он, вплоть до корня; split корня повышает дерево на уровень.
Чтобы поиск и вставки работали параллельно без блокировки всего дерева, PostgreSQL использует приём Лемана-Яо: на каждой странице есть high key (верхняя граница её диапазона) и right link - ссылка на правого соседа.
[ ... | high key=50 ] --right--> [ 51 | 52 | ... | high key=99 ]
Если во время спуска страница как раз делилась и нужный ключ уехал вправо, поиск по right link догоняет его, не начиная заново. Так достигается параллельный доступ без тяжёлых блокировок дерева.
32.5 Дедупликация повторяющихся ключей
Если в колонке много повторов (например, status с десятком
значений на миллионы строк), наивный индекс хранил бы значение
заново в каждой записи. Дедупликация это устраняет: одинаковые
ключи хранятся один раз, а к ним прикреплён posting list - список
всех ctid с этим значением.
без дедупликации: (done, ctid1)(done, ctid2)(done, ctid3)...
с дедупликацией: (done → [ctid1, ctid2, ctid3, ...])
Это резко уменьшает размер индекса на колонках с низким числом
различных значений и ускоряет их чтение. Дедупликация работает
автоматически, её видно по размеру индекса и в bt_page_items как
записи с posting list.
32.6 Suffix truncation
Внутренним страницам не нужен полный ключ - им нужен лишь разделитель, достаточный, чтобы решить «влево или вправо». Поэтому при подъёме разделителя в родителя PostgreSQL обрезает его до минимального различающего префикса (suffix truncation).
Например, чтобы отделить aardvark от azimuth, в разделителе
хватит a плюс второй буквы - остаток не нужен. Обрезка делает
внутренние записи короче, на странице помещается больше
разделителей, fanout растёт, дерево становится ещё мельче. Это одна
из причин, по которой B-tree в PostgreSQL такой компактный на
длинных ключах.
32.7 INCLUDE: покрывающий индекс
В лист можно положить дополнительные колонки, не делая их частью
ключа, - через INCLUDE. Они не участвуют в сортировке и спуске,
но доступны для чтения прямо из индекса.
CREATE INDEX ON tickets (flight_id) INCLUDE (passenger);
Теперь SELECT flight_id, passenger WHERE flight_id = 7 может пойти
по Index-Only Scan: обе колонки есть в листе, в таблицу идти не
надо (при выставленной visibility map, см. главу 27). INCLUDE
нужен именно для покрытия запроса, а не для поиска: класть в ключ
колонку, по которой не ищешь, было бы лишней нагрузкой на спуск и
split. Подробнее про эти механизмы - в btree-internals.
32.7.1 Копнуть глубже: почему глубину почти не нужно считать
На практике глубину B-tree редко считают точно - она почти всегда 3-4. Из-за большого fanout и suffix truncation добавление нулей к числу строк добавляет к глубине доли уровня. Дерево на тысяче строк и на миллиарде различаются по высоте на один-два уровня, не на порядки.
Практический вывод: переживать о «глубоком дереве» не нужно - B-tree остаётся мелким при любом разумном объёме. Реальные проблемы индекса не в глубине, а в другом: раздувание от обновлений, неподходящий operator class, индекс, который не покрывает запрос. Этим и займёмся в главе про проектирование.
Уроки в sandbox
lab-32.1. Спуститься по B-дереву через pageinspect
Пройдём от корня к листу за конкретным ключом и подтвердим путь через ctid. Перед чтением листа предскажи, на какой странице окажется искомое значение.
Подключи линзу:
CREATE EXTENSION IF NOT EXISTS pageinspect;.Узнай корень и высоту:
SELECT root, level FROM bt_metap('tickets_pkey');. Запиши номер корневой страницы.Посмотри содержимое корня:
SELECT itemoffset, ctid, data FROM bt_page_items('tickets_pkey', <root>);. На внутренней странице ctid - это downlink на дочернюю.Спустись на уровень ниже по нужному downlink и читай bt_page_items этой страницы, пока не дойдёшь до листа.
На листе найди запись с искомым ticket_no - её ctid указывает на строку в таблице. Сверь:
SELECT ctid, ticket_no FROM tickets WHERE ticket_no = '0000000000007';.Создай покрывающий индекс
CREATE INDEX ON tickets (flight_id) INCLUDE (passenger);и проверь Index Only Scan дляSELECT flight_id, passenger ... WHERE flight_id = 7.
sandbox с автопроверкой - открыть в песочнице
Резюме
- B-tree - сбалансированное дерево из 8-КБ страниц: meta page указывает на корень, внутренние страницы хранят разделители и downlink, листья - ключ и ctid.
- Спуск идёт meta → корень → внутренние → лист; pageinspect (bt_metap, bt_page_items) позволяет пройти его руками.
- Большой fanout (сотни детей на странице) делает дерево мелким: 3-4 уровня хватает на миллиарды строк, поиск - 3-4 чтения страниц.
- При переполнении страница делится (split), разделитель поднимается вверх; high key и right link (Леман-Яо) дают параллельный доступ без блокировки дерева.
- Дедупликация хранит повторяющийся ключ один раз с posting list из ctid - резко уменьшает индекс на колонках с малым числом различных значений.
- Suffix truncation обрезает разделители во внутренних страницах до различающего префикса, повышая fanout и делая дерево ещё компактнее.
- INCLUDE кладёт в лист payload-колонки для покрытия запроса (Index-Only Scan), не делая их частью ключа и не нагружая спуск.
Контрольные вопросы
Почему B-tree из миллиарда строк имеет всего 3-4 уровня?
Показать ответ
Из-за большого коэффициента ветвления (fanout). На одной 8-КБ внутренней странице помещаются сотни разделителей, значит у каждого узла сотни детей. Глубина дерева - это логарифм числа строк по основанию fanout. При fanout 200-300 три уровня покрывают десятки миллионов строк, четыре - миллиарды. Suffix truncation ещё повышает fanout, обрезая разделители. Поэтому поиск по индексу - это 3-4 чтения страниц независимо от объёма таблицы.
Что такое high key и right link и зачем они нужны?
Показать ответ
High key - верхняя граница диапазона ключей страницы; right link - ссылка на правого соседа того же уровня. Это приём Лемана-Яо для параллельного доступа. Если во время спуска страница как раз делилась и искомый ключ уехал в новую правую страницу, поиск сравнивает ключ с high key, понимает, что нужно правее, и идёт по right link - не начиная спуск заново и не блокируя дерево. Так вставки и поиски работают одновременно без тяжёлых блокировок.
Как дедупликация уменьшает размер B-tree?
Показать ответ
Когда в колонке много повторяющихся значений, наивный индекс хранил бы ключ заново в каждой записи. Дедупликация хранит каждое уникальное значение один раз и прикрепляет к нему posting list - список всех ctid строк с этим значением. На колонке с малым числом различных значений (статус, флаг) это сокращает индекс в разы и ускоряет его чтение. Работает автоматически; записи с posting list видны в bt_page_items.
Чем INCLUDE-колонка отличается от обычной колонки индекса?
Показать ответ
INCLUDE-колонка лежит только в листовых страницах как payload и не участвует ни в сортировке, ни в спуске по дереву. Обычная (ключевая) колонка участвует в упорядочивании и используется при поиске. INCLUDE нужен, чтобы покрыть запрос для Index-Only Scan: колонка доступна для чтения из индекса, но не нагружает спуск и split. Класть в ключ колонку, по которой не ищешь, было бы лишней нагрузкой - INCLUDE решает это аккуратно.
Стоит ли беспокоиться, что индекс «слишком глубокий»?
Показать ответ
Почти никогда. Из-за большого fanout и suffix truncation глубина B-tree остаётся 3-4 уровня при любом разумном объёме - дерево на тысяче и на миллиарде строк отличаются по высоте на уровень-другой, не на порядки. Реальные проблемы индекса не в глубине: это раздувание от частых обновлений, неподходящий operator class (оператор не поддержан), или индекс, не покрывающий запрос. На них и стоит тратить внимание, а не на высоту дерева.