Своя СУБД на Rust · урок 8: транзакции, откат и видимость
Кому это. В прошлом уроке у движка появился журнал. INSERT и CREATE переживают падение, старт проигрывает журнал. Но дыра осталась: ROLLBACK движок не знает, а DELETE стирает слот на странице и после падения может воскреснуть.
Зачем именно Rust. Сегодня появятся BEGIN, COMMIT и ROLLBACK, кортеж научится версиям через xmin/xmax, и видимость строки начнёт решать движок. Это MVCC, на которой стоит Postgres. На Rust этот урок интересен тем, что borrow checker вмешается в проектирование трижды. Исполнителю нужно одновременно писать в журнал транзакции и читать статусы фиксации, и оба лежат в одном TransactionManager: понадобится метод, который делит структуру на две непересекающиеся ссылки. Откат должен трогать страницы, которых менеджер транзакций не видит: он примет замыкание impl FnMut(&UndoEntry) -> Result<()>. А граница no-steal нужна хукам внутри пулов, до которых &self менеджера не дотянуться: она уедет в Arc<AtomicU64>.
Важно про объём. Это самый плотный урок воркшопа, но почти всё опирается на готовое: журнал из прошлого урока, исполнитель из шестого. Менеджер транзакций сразу умеет держать несколько открытых транзакций, но снаружи сегодня видна одна, сессионная (BEGIN в SQL), плюс autocommit. Хэндлы для нескольких транзакций и блокировки появятся в уроке 9. Снимок почти всегда тривиален, а форма у него уже правильная.
Где мы в движке
┌────────────────────────────────────────────────┐
│ Бинарный протокол + 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
└────────────────────────────────────────────────┘
Идея версий
В уроке 3 мы заложили в заголовок кортежа два поля, xmin и xmax, и до сих пор писали туда константы. Сегодня они заработают. xmin это номер транзакции, создавшей версию. xmax это номер транзакции, удалившей её, или 0, если версия жива. DELETE больше не стирает слот: он пишет свой xid в xmax, и слот остаётся живым. UPDATE это xmax у старой версии плюс вставка новой. Будет ли строка видна, решает не страница, а вопрос «какая транзакция её создала, какая удалила и зафиксировались ли они к моменту моего снимка».
Отсюда три следствия. Удаление становится записью журнала SET_XMAX, которую можно проиграть. Откат становится простым: снять свои пометки и сделать свои вставки мёртвыми. А восстановление учится игнорировать незафиксированное: если COMMIT транзакции в журнал не попал, её записи redo пропускает.
Версии в странице: set_xmax
// src/page.rs (добавляем в impl Page)
/// Смещение xmax внутри заголовка кортежа (см. tuple.rs).
const TUPLE_XMAX: usize = 4;
/// Записать xmax кортежа в слоте на месте (MVCC-удаление, урок 8).
pub fn set_xmax(&mut self, slot: u16, xmax: u32) {
let at = self.get_slot(slot) as usize + TUPLE_XMAX;
put_u32(&mut self.data[..], at, xmax);
}
pub fn get_xmax(&self, slot: u16) -> u32 {
get_u32(&self.data[..], self.get_slot(slot) as usize + TUPLE_XMAX)
}
/// Живой ли слот: существует и указывает на кортеж, а не на DEAD_SLOT.
pub fn is_slot_live(&self, slot: u16) -> bool {
slot < self.slot_count() && self.get_slot(slot) != DEAD_SLOT
}
Четыре байта на месте, без разбора и пересборки кортежа: xmax лежит по смещению 4 от начала его заголовка. Куча оборачивает это в MVCC-удаление:
// src/heap.rs
/// MVCC-удаление (урок 8): xmax = xid в кортеже на месте, слот остаётся живым.
pub fn mark_deleted(
&self,
rid: Rid,
xid: Xid,
journal: Option<&mut dyn Journal>,
) -> Result<()> {
let mut guard = self.pool.fetch_page(rid.page_id)?;
// на мёртвом слоте смещение 0, и xmax лёг бы в lsn заголовка страницы
if !guard.read().is_slot_live(rid.slot) {
return Err(invalid(format!(
"markDeleted: слот {}:{} уже мёртв",
rid.page_id, rid.slot
)));
}
let mut page = guard.write();
page.set_xmax(rid.slot, xid);
if let Some(journal) = journal {
let lsn = journal.log_set_xmax(xid, self.relation_id, rid.page_id, rid.slot, xid)?;
page.set_lsn(lsn);
}
Ok(())
}
Комментарий в начале стоит прочитать дважды. У мёртвого слота смещение 0, и set_xmax записал бы четыре байта по адресу 0 + 4, то есть прямо в поле lsn заголовка страницы. Ни паники, ни ошибки, просто тихо испорченная страница. Проверка is_slot_live превращает эту ловушку в Err. Обрати внимание и на latch: guard.read() живёт только до конца условия if, поэтому следующий guard.write() не столкнётся с ним. Latch в нашем пуле это try_lock, который паникует при повторном захвате, и такой порядок «прочитал, отпустил, взял на запись» придётся соблюдать везде.
Видимость: снимок и правило
Видимость отвечает на вопрос: видит ли транзакция me версию кортежа под своим снимком. Нужны две структуры: реестр статусов транзакций и сам снимок.
// src/txn/snapshot.rs
use std::collections::{HashMap, HashSet};
use crate::tuple::{Tuple, Xid};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TxnState {
InProgress,
Committed,
Aborted,
}
/// Реестр статусов транзакций. Незнакомый xid считается откатанным; ниже floor (водяной
/// знак последнего checkpoint) всё зафиксировано без явной записи, аналог обрезания CLOG.
#[derive(Clone, Debug, Default)]
pub struct CommitStatus {
states: HashMap<Xid, TxnState>,
floor: Xid,
}
impl CommitStatus {
pub fn new() -> CommitStatus {
CommitStatus::default()
}
/// Явная запись приоритетна; всё ниже floor это committed.
pub fn get(&self, xid: Xid) -> TxnState {
match self.states.get(&xid) {
Some(state) => *state,
None if xid != 0 && xid < self.floor => TxnState::Committed,
None => TxnState::Aborted,
}
}
pub fn set(&mut self, xid: Xid, state: TxnState) {
self.states.insert(xid, state);
}
pub fn raise_floor_to(&mut self, watermark: Xid) {
self.floor = self.floor.max(watermark);
}
/// Перенять floor и явные статусы другого реестра (после recovery).
pub fn absorb(&mut self, other: CommitStatus) {
self.raise_floor_to(other.floor);
self.states.extend(other.states);
}
}
CommitStatus это карта xid -> состояние плюс водяной знак floor. Три ветки match в get читаются как правила. Явная запись главнее всего. Ниже водяного знака зафиксировано всё, кроме сентинела 0. Остальное, включая xid, о котором мы никогда не слышали, считается откатанным: если транзакция не успела записать COMMIT, она не зафиксирована. Защитное условие None if ... в match это ровно та форма, в которой правило проще всего прочитать.
// src/txn/snapshot.rs (продолжение)
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Snapshot {
/// Самый старый активный xid (горизонт vacuum).
pub xmin: Xid,
/// Верхняя граница, эксклюзивно: xid >= xmax ещё не начинались.
pub xmax: Xid,
/// Активные транзакции на момент снимка, кроме самой себя.
pub active: HashSet<Xid>,
}
/// Зафиксирована ли транзакция x к моменту снимка.
pub fn committed_as_of(x: Xid, snapshot: &Snapshot, status: &CommitStatus) -> bool {
x != 0
&& status.get(x) == TxnState::Committed
&& x < snapshot.xmax
&& !snapshot.active.contains(&x)
}
/// Видна ли версия транзакции `me` под её снимком. Свои записи видны сразу.
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);
if !inserter_visible {
return false;
}
if tuple.xmax == 0 {
return true;
}
!(tuple.xmax == me || committed_as_of(tuple.xmax, snapshot, status))
}
Снимок это три поля. committed_as_of проверяет четыре условия: не сентинел, зафиксирована, началась раньше снимка и не была активной в момент снимка. Последние два и есть суть изоляции: транзакция, которая зафиксировалась после моего снимка, для меня не зафиксирована.
is_visible читается в два шага. Создатель версии должен быть виден: либо версия заморожена (это поле появится в уроке 10), либо создал её я сам, либо создатель зафиксирован к моему снимку. Удаление не должно быть видно: xmax пуст, либо его поставил кто-то, чей коммит я ещё не вижу. Свои записи и свои удаления видны сразу, иначе транзакция не смогла бы прочитать то, что только что вставила.
Функции свободные и чистые: ни &self, ни ввода-вывода. Им передают ссылки на снимок и реестр, и тесты видимости пишутся без базы, на руками собранных кортежах.
Поиграй с правилом: виджет даёт выставить xmin, xmax, статусы и снимок и показывает, какая ветка is_visible решила судьбу версии.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.