Раздел 10 · Databases

Своя СУБД на Rust · урок 10: vacuum, заморозка и сборка мёртвых версий

senior~70 мин

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

Своя СУБД на Rust · урок 10: vacuum, заморозка и сборка мёртвых версий

Кому это. В уроке 8 удаление стало MVCC-пометкой, а в уроке 9 транзакции начали конкурировать. DELETE ставит xmax и оставляет слот живым, UPDATE пишет xmax старой версии и вставляет новую. Мёртвые версии копятся на страницах, а таблица распухает.

Зачем. Сегодня пишем vacuum: проход по куче, который находит мёртвые версии, уплотняет страницу, чистит индекс и замораживает старые живые версии.

Зачем именно Rust. Vacuum это проход, которому нужно сразу пять вещей: пул страниц, схема для разбора кортежей, реестр статусов, горизонт и lsn для штампа. Вместо функции с пятью аргументами в Rust удобнее собрать их в структуру ссылок Vacuum<'a> и дать ей метод run. Решение о судьбе версии становится маленьким enum Verdict с тремя вариантами, а классификатор остаётся чистой функцией, которой хватает &Tuple. И снова сработает дисциплина latch-ей из прошлых уроков: страницу сначала читаем под guard.read() в отдельном блоке, и только когда этот latch умер, берём guard.write().

Важно про объём. Новый файл один, vacuum.rs. Остальное это небольшие добавки: бит frozen в кортеже, одна ветка в видимости, метод горизонта у менеджера транзакций, примитив переписи страницы. Наш vacuum, как и checkpoint, требует отсутствия открытых транзакций. В отличие от Effect-трека, Rust-эталон сразу чистит индекс и сбрасывает карту свободного места: удаление из B+Tree и FSM он уже умеет.

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

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

Идея vacuum

Мёртвая версия не видна и уже не станет видна никакому живому снимку. Таких два рода: вставка откатанной транзакции и версия, удалённая зафиксированной транзакцией, чьё удаление видят уже все. Их можно физически стереть.

Горизонт удаляемости это граница, от которой отсчитывается «давно». Нет открытых транзакций: горизонт это следующий xid, удаляемо всё зафиксированное. Есть: горизонт это минимальный xmin их снимков, и старая открытая транзакция держит версии, которые ещё вправе увидеть.

Заморозка решает отдельную проблему. xid у нас это u32, и однажды он переполнится. Древний xmin, который был «маленьким и давно зафиксированным», вдруг окажется «большим», будто из будущего, и видимость спрячет вечно живую строку. Это xid-wraparound. Защита простая: версию, чей xmin зафиксирован и старше горизонта, помечаем битом frozen, и видимость перестаёт сравнивать её xmin со снимком.

Кстати, в Rust переполнение u32 не случится молча. self.next_xid += 1 на u32::MAX в отладочной сборке паникует, а в релизной заворачивается по модулю. Заморозка нужна именно для второго случая: реальный движок обязан переживать заворачивание счётчика, а не падать на нём.

Бит frozen в кортеже

В заголовке кортежа из урока 3 есть поле infomask на 16 бит. Один бит там уже занят флагом «есть NULL». Второй достаётся заморозке:

// src/tuple.rs

pub const INFOMASK_XMIN_FROZEN: u16 = 0x0002;

/// Логическая строка плюс заголовок версии (MVCC).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Tuple {
    pub values: Vec<Value>,
    pub xmin: Xid,
    pub xmax: Xid,
    /// xmin заморожен vacuum-ом: версия видна по xmin всегда (урок 10).
    pub frozen: bool,
}

// в encode:
    let infomask = if has_null { INFOMASK_HAS_NULL } else { 0 }
        | if frozen { INFOMASK_XMIN_FROZEN } else { 0 };

// в deserialize:
        Ok(Tuple {
            values,
            xmin,
            xmax,
            frozen: infomask & INFOMASK_XMIN_FROZEN != 0,
        })

Бит лежит на диске, поле frozen: bool в памяти. Формат страницы не меняется: старые кортежи читаются с frozen: false. if ... { … } else { 0 } | if ... { … } else { 0 } работает, потому что if в Rust это выражение: два флага склеиваются побитовым ИЛИ без промежуточных переменных.

Видимость замороженной версии

Ветка уже стояла в is_visible из урока 8, сегодня она начинает срабатывать:

// src/txn/snapshot.rs (напоминание)

pub fn is_visible(tuple: &Tuple, snapshot: &Snapshot, me: Xid, status: &CommitStatus) -> bool {
    let inserter_visible =
        tuple.frozen || tuple.xmin == me || committed_as_of(tuple.xmin, snapshot, status);
    // ...
}

tuple.frozen стоит первым в ||, и для замороженной версии остальные проверки не вычисляются вовсе. Ни реестр статусов, ни снимок для неё не нужны. Удаление по-прежнему проверяется: замороженную версию можно удалить, как любую другую.

Горизонт удаляемости

// src/txn/mod.rs (в impl TransactionManager)

    /// Горизонт vacuum (RecentGlobalXmin): минимум snapshot.xmin по открытым, иначе
    /// следующий xid (всё зафиксированное удаляемо).
    pub fn vacuum_horizon(&self) -> Xid {
        self.active
            .values()
            .map(|txn| txn.snapshot.xmin)
            .fold(self.next_xid, Xid::min)
    }

fold(self.next_xid, Xid::min) одной строкой выражает оба случая: без открытых транзакций итератор пуст, и результат это начальное значение, следующий xid. С ними это минимум xmin их снимков. Xid::min передаётся как функция, без замыкания: u32::min уже имеет нужную сигнатуру fn(u32, u32) -> u32. Сейчас vacuum зовётся только без открытых транзакций, но горизонт написан для любого числа открытых транзакций, под упражнение про конкурентный vacuum.

Перепись страницы

Уплотнить страницу значит собрать живые кортежи заново у верхнего края, освободив дыры мёртвых. Но номера слотов живых версий трогать нельзя: на них смотрит индекс.

// src/page.rs (добавляем в impl Page)

    /// Переписать область данных из живых кортежей (vacuum). Массив слотов сохраняет
    /// длину: живой кортеж остаётся в своём слоте (RID стабилен), мёртвый становится
    /// DEAD_SLOT. Vacuum гарантирует, что `live` помещаются на страницу.
    pub fn vacuum_rewrite(&mut self, slot_count: u16, live: &[(u16, Vec<u8>)]) {
        self.set_lower((HEADER_SIZE + slot_count as usize * SLOT_SIZE) as u16);
        self.set_upper(PAGE_SIZE as u16);
        for slot in 0..slot_count {
            self.set_slot(slot, DEAD_SLOT);
        }
        for (slot, data) in live {
            let tuple_offset = self.place(data);
            self.set_slot(*slot, tuple_offset);
        }
    }

Три шага: массив слотов той же длины, upper снова у конца страницы, все слоты мертвы. Потом каждый живой кортеж кладётся под upper и получает свой прежний номер. Мёртвые слоты остаются DEAD_SLOT навсегда и не переиспользуются: RID стабилен, и индекс не начинает врать. Цена: массив слотов не сжимается, четыре байта на каждый мёртвый слот остаются занятыми.

live: &[(u16, Vec<u8>)] это срез пар «номер слота и готовые байты». Метод не знает ни про схему, ни про видимость: что живо и как сериализовано, решил вызывающий. Примитив страницы остаётся тупым и проверяемым отдельным тестом.

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

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

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