Своя СУБД на Rust · урок 1: страницы и свой файловый слой
Кому это. Ты пишешь на Rust: понимаешь владение и Drop, не боишься ссылок и срезов, обрабатываешь сбои через Result и ?. Теперь мы возьмём этот инструмент и построим им что-то весомое: маленькую честную реляционную СУБД снизу вверх. Это тот же воркшоп ourdb, что и в TS-треке, но переложенный на Rust. Если ты уже прошёл его на TypeScript или на Effect, этот урок покажет ту же механику под третьим углом: где в TS был ручной close(), а в Effect финалайзер scope, у нас будет Drop, который компилятор вставит сам.
Зачем именно Rust. Движок базы это байты, которые должны лечь на диск ровно так, как записано в контракте, и ресурсы, которые нельзя забыть отпустить. Rust закрывает обе стороны без сборщика мусора и без unsafe. Числа раскладываются по смещениям через to_le_bytes/from_le_bytes, выход за границу буфера ловится проверкой, а не порчей памяти. Закреплённая в кэше страница в уроке 2 станет значением-владельцем, и снять закрепление забыть нельзя: это сделает Drop. Сбои живут в Result, и подпись функции честно говорит, может ли она упасть. Нет пауз сборщика, нет скрытых аллокаций на горячем пути, а всё, что не сходится по типам, не соберётся. Этот урок закладывает первый камень: страницу и DiskManager, чей файл закрывается сам.
Важно про код. Ты пишешь свою реализацию с нуля, в своём репозитории. Каждый кусок кода в уроке приведён целиком или с явными пропусками // ... и разобран построчно. Читай, печатай руками, запускай cargo test. Печатать руками, а не копировать, тут не каприз: пальцы запоминают раскладку байтов лучше, чем глаза.
Что мы строим за одиннадцать уроков
ourdb это однонодовая дисковая реляционная СУБД в духе Postgres, урезанная до обучаемого ядра. На входе SQL, на выходе строки. Внутри полный путь запроса: разбор, планирование, исполнение поверх страничного хранилища с индексами, транзакциями и MVCC. Слои снизу вверх, справа номер урока и модуль крейта, который этот слой добавляет:
┌────────────────────────────────────────────────┐
│ Бинарный протокол + TCP-сервер │ урок 11 wire
├────────────────────────────────────────────────┤
│ Исполнитель (Volcano) + EXPLAIN │ урок 6 engine
│ Планировщик, binder, парсер, лексер │ уроки 5, 6 sql, engine
├────────────────────────────────────────────────┤
│ Vacuum │ урок 10 vacuum
│ Блокировки (2PL), граф ожидания │ урок 9 lock
│ MVCC, менеджер транзакций │ урок 8 txn
│ WAL (журнал, redo, recovery) │ урок 7 wal, recovery
├────────────────────────────────────────────────┤
│ B+Tree индекс │ урок 4 btree
│ Куча (slotted pages, seq scan) │ урок 3 tuple, heap
├────────────────────────────────────────────────┤
│ Буферный пул (кэш страниц, вытеснение) │ урок 2 buffer_pool
│ DiskManager (страницы поверх файла) │ урок 1 page, disk ← мы здесь
└────────────────────────────────────────────────┘
Мы стоим в самом низу. Без надёжного нижнего слоя весь движок повиснет в воздухе, поэтому начинаем с фундамента: как уложить байты на диск так, чтобы их потом можно было адресовать, читать и менять, и как сделать так, чтобы открытый файл закрылся сам.
Крейт: один на весь воркшоп
Весь движок живёт в одном библиотечном крейте ourdb. Внешних зависимостей нет совсем, только std: песочница курса собирает код офлайн, а заодно ты увидишь, что для базы данных стандартной библиотеки хватает. Модуль на урок, чтобы крейт рос шаг за шагом и каждый этап собирался сам по себе.
# Cargo.toml
[package]
name = "ourdb"
version = "0.1.0"
edition = "2021"
rust-version = "1.84"
publish = false
[dependencies]
Раскладка после первого урока:
ourdb/
├── Cargo.toml
├── src/
│ ├── lib.rs корень крейта: объявления модулей и реэкспорты
│ ├── bytes.rs чтение и запись чисел little-endian по смещению
│ ├── error.rs один enum Error на весь крейт
│ ├── page.rs страница: 8192 байта и типизированный доступ к ним
│ └── disk.rs DiskManager: файл как массив страниц
└── tests/
├── common/mod.rs временный каталог для тестов
└── lesson_01_pages.rs
Корень крейта на этом этапе короткий. С каждым уроком в нём прибавится строка pub mod ... и пара реэкспортов:
// src/lib.rs (этап 1)
//! ourdb: учебная реляционная СУБД снизу вверх.
mod bytes;
pub mod disk;
pub mod error;
pub mod page;
pub use disk::{DiskManager, PageId};
pub use error::{Error, Result};
pub use page::Page;
Заметь, что bytes объявлен без pub. Это внутренняя кухня крейта: помощники для байтов нужны страницам, кортежам и узлам дерева, но наружу их не показываем. Подробно о видимости модулей в уроке про модули и крейты.
Зачем базе своя файловая раскладка
Первый вопрос: у нас уже есть файловая система. Можно открыть файл, дописать в конец строку, прочитать назад. Зачем городить ещё один слой адресации поверх файла?
Причин четыре, и все про контроль.
- Фиксированный размер блока даёт предсказуемый ввод-вывод. Диск и ОС всё равно работают блоками. Если наш минимальный кусок данных это страница фиксированного размера, то чтение и запись это всегда ровно один блок по известному смещению. Никаких частичных чтений на полстроки.
- Адресация по номеру страницы стабильна. Кортеж адресуется парой RID вида (номер страницы, номер слота). Пока кортеж жив, его адрес не меняется, и на него можно ссылаться из индекса. Если бы мы дописывали строки в файл как попало, любой сдвиг ломал бы все ссылки.
- Кэширование становится возможным. В уроке 2 мы поставим между движком и диском буферный пул: страница один раз прочитана, дальше живёт в памяти. Кэшировать имеет смысл единицы фиксированного размера, а не строки произвольной длины.
- Журнал и восстановление опираются на страницы. WAL в уроке 7 будет писать «страница номер N изменилась так-то». Без понятия страницы нечего журналировать.
Поэтому почти каждая СУБД строит поверх сырого файла свою адресацию страницами. Это и есть та самая «своя ФС»: мы не доверяем файловой системе ОС вопрос раскладки данных, а берём его на себя. Наш самый нижний слой называется DiskManager, и его работа ровно одна: представить один файл как массив страниц фиксированного размера с операциями «прочитай страницу N», «запиши страницу N», «выдели новую страницу».
Страница: контракт байтов
Прежде чем писать код, договоримся о формате. Это важнее, чем кажется: реализации ourdb на TypeScript, Effect, Go, Rust и Haskell читают файлы друг друга, а значит формат страницы зафиксирован раз и одинаков для всех. Контракт лежит в SPEC.md, одном на все порты, а рядом корпус эталонных байтов, по которому каждая реализация сверяет себя. Числа на диске мы пишем little-endian. Про порядок байтов и почему он вообще бывает разным подробно в уроке про биты, байты и endianness.
Почему 8192 и почему фиксированный размер
Размер страницы PAGE_SIZE = 8192 байта, фиксированный, как у Postgres. Почему именно столько?
Это компромисс. Слишком маленькая страница (скажем, 512 байт) означает, что под крупную строку нужно много страниц, и накладные расходы на заголовки растут. Слишком большая (64 КБ) означает, что ради одной короткой строки мы тянем с диска и держим в кэше много лишнего. 8 КБ исторически хорошо ложатся на размер блока файловых систем и на страницы памяти, поэтому это разумное значение по умолчанию. Для нас важнее другое: размер фиксированный. Страница с номером N всегда лежит по смещению N * PAGE_SIZE. Зная номер, мы за одно умножение получаем место на диске.
Анатомия slotted page
Внутри страницы мы используем раскладку slotted page, ту же, что в Postgres. Идея в том, чтобы развести две вещи, которые тянут в разные стороны: указатели фиксированного размера и данные переменной длины.
Заголовок страницы лежит в первых 16 байтах:
смещение размер поле тип смысл
0 8 lsn u64 LSN последней записи WAL, изменившей страницу (урок 7)
8 2 lower u16 конец массива слотов (растёт вправо, к большим смещениям)
10 2 upper u16 начало области данных (растёт влево, к меньшим смещениям)
12 2 special u16 начало special-области (метаданные узла B+Tree, урок 4)
14 2 flags u16 тип и флаги страницы
16 ... slots[] массив слотов: на слот u16 со смещением кортежа
... свободное место между lower и upper
upper ... tuples[] кортежи растут от конца страницы к началу
Картинка целиком выглядит так. Слева заголовок, дальше массив слотов растёт вправо, кортежи растут влево от конца страницы, посередине свободное место:
0 16 lower upper 8192
│ header │ slot0 slot1 slot2 -> ← tuple2 tuple1 tuple0 │
│ 16 B │ растут вправо free растут влево │
space = upper - lower
Ключевая величина это свободное место, и оно ровно upper - lower. Вставка нового кортежа делает два движения навстречу: кладёт данные кортежа у upper (сдвигая upper влево, к меньшим смещениям) и добавляет слот у lower (сдвигая lower вправо). Когда lower догоняет upper, свободного места не осталось.
Зачем такая раздвоенность? Слот это маленький указатель фиксированного размера (2 байта). Кортеж это данные переменной длины. Если хранить их вперемешку, удаление кортежа в середине двигало бы всё, что за ним, и ломало бы RID соседей. А так слот всегда на своём месте по своему номеру, кортеж лежит где угодно в области данных, и удаление просто помечает слот, не трогая остальные. Физически место мёртвого кортежа вернёт vacuum в уроке 10.
Поиграй с этим вживую. Кнопка INSERT кладёт кортеж, клик по слоту помечает его мёртвым, VACUUM уплотняет страницу. Смотри за тремя числами внизу: lower, upper и свободным местом. Обрати внимание на главный сюрприз: удаление кортежа не возвращает свободное место сразу.
Если ты повозился с виджетом, ты заметил две вещи, которые и есть мораль урока про страницу. Первая: DELETE двигает не данные, а только статус слота, поэтому свободное место не растёт. Вторая: VACUUM возвращает место, двигая upper вверх, но lower оставляет на месте. Номера живых слотов сохраняются, а значит RID остаётся стабильным и индекс не протухает.
Байты по смещению: bytes.rs
В TS-треке числа из буфера читал DataView с флагом little-endian. В Rust отдельного «вида» нет и не нужно: у каждого целого типа есть пара методов from_le_bytes и to_le_bytes. Первый собирает число из массива байтов фиксированной длины, второй раскладывает число в такой массив. Никакого unsafe, никаких указателей: компилятор знает, что u16 это ровно [u8; 2], а u64 ровно [u8; 8].
Вынесем это в маленький внутренний модуль, чтобы страница, кортеж и узел дерева читали байты одинаково:
// src/bytes.rs
//! Little-endian чтение и запись чисел по смещению (числа на диске LE, см. SPEC.md).
//! `get_*`/`put_*` работают по фиксированным смещениям внутри страницы и паникуют на
//! выходе за границы (это баг движка); `slice` проверяет границы для недоверенных байтов
//! (кортеж с диска, payload WAL) и отдаёт `Error::Invalid`.
use crate::error::{invalid, Result};
pub(crate) fn get_u16(buf: &[u8], at: usize) -> u16 {
u16::from_le_bytes([buf[at], buf[at + 1]])
}
pub(crate) fn get_u32(buf: &[u8], at: usize) -> u32 {
u32::from_le_bytes(buf[at..at + 4].try_into().expect("4 bytes"))
}
pub(crate) fn get_u64(buf: &[u8], at: usize) -> u64 {
u64::from_le_bytes(buf[at..at + 8].try_into().expect("8 bytes"))
}
pub(crate) fn put_u16(buf: &mut [u8], at: usize, value: u16) {
buf[at..at + 2].copy_from_slice(&value.to_le_bytes());
}
pub(crate) fn put_u32(buf: &mut [u8], at: usize, value: u32) {
buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
}
pub(crate) fn put_u64(buf: &mut [u8], at: usize, value: u64) {
buf[at..at + 8].copy_from_slice(&value.to_le_bytes());
}
// get_i64/put_i64 устроены так же, они понадобятся для INT-колонок в уроке 3.
Разберём одну строку, остальные устроены так же: buf[at..at + 4].try_into().expect("4 bytes").
buf[at..at + 4]это срез&[u8]из четырёх байтов. Еслиat + 4выходит за длину буфера, индексация паникует..try_into()превращает срез в массив[u8; 4]. Для срезов произвольной длины это преобразование может не удаться, поэтому оно возвращаетResult. Здесь длина среза ровно 4 по построению, так чтоexpectникогда не сработает, но тип требует, чтобы мы это явно сказали.u32::from_le_bytesсобирает число, считая первый байт младшим.
put_* идут в обратную сторону: value.to_le_bytes() даёт массив, а copy_from_slice копирует его в нужный кусок буфера. Этот метод паникует, если длины не совпали, и это ещё одна страховка от ошибки в смещениях.
Паника или Result: кто виноват
В шапке модуля написано главное правило, и оно пройдёт через весь крейт. Ошибки бывают двух родов.
- Баг движка. Страница всегда ровно 8192 байта, смещения полей заголовка это константы. Если
get_u16(page, 8)вдруг вышел за границу, сломан наш код, а не данные. Продолжать работу бессмысленно, поэтому паника. Так же устроен индекс вVec:v[100]на векторе из трёх элементов паникует. - Плохие данные снаружи. Кортеж, прочитанный с диска, или запись журнала могли прийти побитыми. Это не баг, а нормальный сбой окружения, и вызывающий должен уметь на него ответить. Для таких байтов есть функция с проверкой, которая возвращает
Result:
// src/bytes.rs (продолжение)
/// Срез `buf[at..at + len]` с проверкой границ.
pub(crate) fn slice(buf: &[u8], at: usize, len: usize) -> Result<&[u8]> {
at.checked_add(len)
.and_then(|end| buf.get(at..end))
.ok_or_else(|| {
invalid(format!(
"out of bounds: {len} bytes at {at} of {}",
buf.len()
))
})
}
pub(crate) fn read_u16(buf: &[u8], at: usize) -> Result<u16> {
slice(buf, at, 2).map(|bytes| get_u16(bytes, 0))
}
pub(crate) fn read_u32(buf: &[u8], at: usize) -> Result<u32> {
slice(buf, at, 4).map(|bytes| get_u32(bytes, 0))
}
Тут две защиты. checked_add не даёт сложению переполниться: если в побитом кортеже длина поля оказалась гигантской, at + len вернул бы мусор, а checked_add честно вернёт None. А buf.get(at..end) это индексация без паники: вместо падения она отдаёт Option. Цепочка checked_add, and_then, ok_or_else превращает оба «не получилось» в одну ошибку Error::Invalid с понятным текстом. Пока эти функции не нужны: на первом этапе нет недоверенных байтов. Ими начнёт пользоваться разбор кортежа в уроке 3, но правило полезно увидеть сразу.
Ошибки: один enum на крейт
Раз появился Result, нужна ошибка. В Effect-треке на каждый слой был свой размеченный класс в канале E. В Rust мы идём другим путём: один enum Error на весь крейт, а вариант называет класс сбоя. Причина практическая. В уроке 11 сервер будет отвечать клиенту кодом ошибки в духе SQLSTATE, и выбрать код по одному enum можно простым match. Несколько независимых типов ошибок пришлось бы склеивать обёртками.
// src/error.rs
//! Ошибки ourdb. Один enum на весь крейт: варианты повторяют классы ошибок эталона.
use std::fmt;
use std::io;
#[derive(Debug)]
pub enum Error {
/// Ошибка ввода-вывода (диск, сокет).
Io(io::Error),
// ... Sql, Engine, Uniqueness и другие появятся вместе со своими слоями (уроки 5..9)
/// Нарушен формат байтов или контракт API.
Invalid(String),
}
pub type Result<T> = std::result::Result<T, Error>;
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Io(error) => write!(f, "{error}"),
Error::Invalid(message) => f.write_str(message),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(error) => Some(error),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Error::Io(error)
}
}
pub(crate) fn invalid(message: impl Into<String>) -> Error {
Error::Invalid(message.into())
}
Пройдёмся по частям.
pub type Result<T>это псевдоним: в подписях крейта мы пишемResult<Page>, а неstd::result::Result<Page, Error>. Так же поступаетstd::ioсо своимio::Result.impl From<io::Error> for Errorвключает магию оператора?. Когда внутри функции, возвращающей нашResult, ты пишешьfile.seek(...)?, аseekвернулio::Error, оператор?сам позовётFrom::fromи завернёт её вError::Io. Ни одного ручногоmap_errдля обычных сбоев диска.Displayиstd::error::Errorделают наш тип полноправной ошибкой: её можно напечатать, у неё естьsource(), ведущий к исходнойio::Error. Подробно эти трейты разобраны в уроке про обработку ошибок.invalid(...)это маленький конструктор для своих проверок.impl Into<String>принимает и&str, иString, поэтому писать можно иinvalid("…"), иinvalid(format!(…)).
Заметь, чего здесь нет: #[derive(PartialEq)]. Его не вывести, потому что io::Error не умеет сравниваться. Поэтому тесты проверяют ошибки не через assert_eq!, а через matches! с образцом, увидишь это ниже.
Тип Page: 8192 байта в коробке
Вот первое решение, которое в Rust надо принять осознанно: где живут байты страницы.
// src/page.rs
//! Страница (урок 1). Буфер PAGE_SIZE байт с типизированным доступом к заголовку
//! slotted page и слотам. Чистые операции над байтами, без ввода-вывода:
//! писать буфер на диск это дело DiskManager.
use crate::bytes::{get_u16, get_u64, put_u16, put_u64};
use crate::error::{invalid, Result};
/// Размер страницы в байтах. Фиксированный, как у Postgres.
pub const PAGE_SIZE: usize = 8192;
/// Размер заголовка slotted page до массива слотов.
pub const HEADER_SIZE: usize = 16;
/// Размер одного слота: u16 со смещением кортежа внутри страницы.
pub const SLOT_SIZE: usize = 2;
/// Сентинел мёртвого слота: живой кортеж всегда лежит не ниже HEADER_SIZE.
pub const DEAD_SLOT: u16 = 0;
/// Смещения полей заголовка страницы.
pub mod offset {
pub const LSN: usize = 0;
pub const LOWER: usize = 8;
pub const UPPER: usize = 10;
pub const SPECIAL: usize = 12;
pub const FLAGS: usize = 14;
}
#[derive(Clone, PartialEq, Eq)]
pub struct Page {
data: Box<[u8; PAGE_SIZE]>,
}
Константы и смещения лежат прямо в модуле страницы, а смещения заголовка собраны во вложенный модуль offset, поэтому в коде они читаются как offset::LOWER. Когда у тебя пять реализаций на разных языках, единственный способ их синхронизировать это держать раскладку в одном месте на каждую и сверять с SPEC.md.
Заметь сентинел DEAD_SLOT = 0. Слот хранит смещение кортежа внутри страницы. Живой кортеж не может лежать в первых 16 байтах, там заголовок, значит его смещение всегда не меньше HEADER_SIZE. Поэтому значение 0 в слоте мы используем как метку «слот мёртв», не вводя отдельный бит.
Почему Box, а не просто массив
Поле data имеет тип Box<[u8; PAGE_SIZE]>: массив ровно на 8192 байта, но в куче, а не внутри самой структуры. Почему не data: [u8; PAGE_SIZE]?
Подумай, куда страница будет ездить. DiskManager::read_page вернёт её из функции. Буферный пул в уроке 2 положит её в кадр. Тест передаст её в write_page. В Rust каждое такое перемещение (move) это побайтовое копирование значения. Если страница это 8 КБ прямо в структуре, каждый return page двигает 8 КБ. Если это Box, двигается один указатель, 8 байт, а сами данные остаются на месте в куче. Плюс стек: десяток страниц в локальных переменных уже съели бы 80 КБ стека потока. Про то, как устроен Box и почему он владеет своими данными, подробно в уроке про умные указатели.
При этом тип Box<[u8; PAGE_SIZE]>, а не Box<[u8]> и не Vec<u8>. Длина зашита в тип. Страницу неправильного размера просто нельзя сконструировать, и компилятор знает, что self.data[8191] в границах. Это то же «никаких страниц не того размера», которого TS-трек добивался приватным конструктором и проверкой в Page.from, только здесь его держит система типов.
Атрибут #[derive(Clone, PartialEq, Eq)] даёт копию страницы через clone() (глубокую, вместе с байтами в куче) и сравнение двух страниц байт в байт. Пригодится в тестах и в буферном пуле.
Конструкторы: empty и from_bytes
impl Default for Page {
fn default() -> Self {
Page::empty()
}
}
impl Page {
/// Свежая страница: lsn 0, lower 16, upper и special 8192, flags 0, остальное нули.
pub fn empty() -> Page {
let data: Box<[u8; PAGE_SIZE]> = vec![0u8; PAGE_SIZE]
.into_boxed_slice()
.try_into()
.expect("PAGE_SIZE bytes");
let mut page = Page { data };
page.set_lower(HEADER_SIZE as u16);
page.set_upper(PAGE_SIZE as u16);
page.set_special(PAGE_SIZE as u16);
page
}
/// Скопировать уже прочитанные байты. Длина обязана быть PAGE_SIZE.
pub fn from_bytes(bytes: &[u8]) -> Result<Page> {
let data: Box<[u8; PAGE_SIZE]> =
bytes.to_vec().into_boxed_slice().try_into().map_err(|_| {
invalid(format!(
"page buffer must be {PAGE_SIZE} bytes, got {}",
bytes.len()
))
})?;
Ok(Page { data })
}
/// Сырые байты страницы: DiskManager пишет на диск именно их.
pub fn bytes(&self) -> &[u8; PAGE_SIZE] {
&self.data
}
pub fn bytes_mut(&mut self) -> &mut [u8; PAGE_SIZE] {
&mut self.data
}
// ... аксессоры заголовка и слотов дальше
}
Цепочка в empty выглядит громоздко, но у неё есть причина. Очевидный вариант Box::new([0u8; PAGE_SIZE]) сначала собирает массив на стеке и только потом копирует его в кучу. Оптимизатор обычно это убирает, но не обещает, а в отладочной сборке копия будет точно. Путь через vec! выделяет память сразу в куче и сразу нулями. Дальше into_boxed_slice() превращает вектор в Box<[u8]> (срез без запаса ёмкости), а try_into() уточняет тип до Box<[u8; PAGE_SIZE]>, проверив длину. Здесь длина верна по построению, поэтому expect.
В from_bytes то же самое преобразование уже может не удаться: байты пришли снаружи, их могло оказаться 100 или 9000. Поэтому map_err превращает неудачу в нашу Error::Invalid, а ? возвращает её вызывающему. Сравни с TS: там Page.from(buffer) бросал RangeError, и подпись функции об этом молчала. Здесь подпись -> Result<Page> говорит об этом прямо.
Обрати внимание на сигнатуру bytes(&self) -> &[u8; PAGE_SIZE]. Наружу отдаётся заимствование, а не копия: читать байты может кто угодно, пока жива страница. А bytes_mut(&mut self) даёт изменяемый доступ, и пока он жив, никто другой страницу не тронет. Это правило заимствования, и в уроке 2 на нём вырастет весь буферный пул.
impl Default делает пустую страницу значением по умолчанию: Page::default() и ..Default::default() работают так же, как Page::empty().
Отладочный вывод без восьми тысяч байтов
Ещё одна мелочь, которая сэкономит тебе нервы. Если бы мы написали #[derive(Debug)], то println!("{page:?}") или упавший assert_eq! на двух страницах вывалили бы в терминал 8192 числа. Поэтому Debug написан руками и показывает только заголовок:
impl std::fmt::Debug for Page {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Page")
.field("lsn", &self.lsn())
.field("lower", &self.lower())
.field("upper", &self.upper())
.field("special", &self.special())
.field("flags", &self.flags())
.finish()
}
}
debug_struct это строитель из std::fmt, он печатает Page { lsn: 0, lower: 16, upper: 8192, ... } и сам поддерживает красивый многострочный вывод через {:#?}.
Заголовок: геттеры и сеттеры
Каждое поле заголовка это пара методов поверх bytes.rs. Геттер берёт &self, сеттер &mut self:
pub fn lsn(&self) -> u64 {
get_u64(&self.data[..], offset::LSN)
}
pub fn set_lsn(&mut self, value: u64) {
put_u64(&mut self.data[..], offset::LSN, value);
}
pub fn lower(&self) -> u16 {
get_u16(&self.data[..], offset::LOWER)
}
pub fn set_lower(&mut self, value: u16) {
put_u16(&mut self.data[..], offset::LOWER, value);
}
pub fn upper(&self) -> u16 {
get_u16(&self.data[..], offset::UPPER)
}
pub fn set_upper(&mut self, value: u16) {
put_u16(&mut self.data[..], offset::UPPER, value);
}
pub fn special(&self) -> u16 {
get_u16(&self.data[..], offset::SPECIAL)
}
pub fn set_special(&mut self, value: u16) {
put_u16(&mut self.data[..], offset::SPECIAL, value);
}
pub fn flags(&self) -> u16 {
get_u16(&self.data[..], offset::FLAGS)
}
pub fn set_flags(&mut self, value: u16) {
put_u16(&mut self.data[..], offset::FLAGS, value);
}
&self.data[..] превращает Box<[u8; 8192]> в обычный срез &[u8], который и принимают помощники из bytes.rs. Срез это пара «указатель плюс длина», копирования нет.
Сравни с TS. Там LSN приходилось держать в bigint, потому что u64 не помещается в number без потери точности. В Rust u64 это обычный родной тип, и lsn() возвращает его без церемоний. А поля u16 так и остаются u16: тип в подписи совпадает с типом в контракте байтов, и значение 70000 в lower не записать даже по ошибке, компилятор не даст.
Зачем заголовок вообще нужен, нельзя ли хранить lower и upper полями структуры Page? Нельзя, и это принципиально. Эти числа должны лежать в байтах страницы, потому что именно эти байты уйдут на диск и вернутся при следующем чтении. Поле структуры живёт только в памяти. Истина это буфер, а методы лишь типизированный вид на него.
Есть соблазн сделать «по-сишному»: описать заголовок как #[repr(C)] struct Header { lsn: u64, lower: u16, ... } и привести к нему указатель на байты. Это требует unsafe, зависит от выравнивания и от порядка байтов машины, на big-endian процессоре файл перестанет читаться. Наши from_le_bytes дают тот же результат на любой платформе и стоят те же несколько инструкций: на little-endian процессоре компилятор сводит u16::from_le_bytes к одной загрузке. Про repr и раскладку структур в памяти есть отдельный урок, а про цену unsafe ещё один.
Слоты и свободное место
Число слотов выводится из lower: массив слотов начинается с HEADER_SIZE, каждый слот занимает SLOT_SIZE байт.
/// Число слотов в массиве слотов (выводится из lower).
pub fn slot_count(&self) -> u16 {
((self.lower() as usize).saturating_sub(HEADER_SIZE) / SLOT_SIZE) as u16
}
/// Свободное место между концом слотов и началом данных.
pub fn free_space(&self) -> usize {
(self.upper() as usize).saturating_sub(self.lower() as usize)
}
/// Смещение кортежа, на которое указывает слот.
pub fn get_slot(&self, slot: u16) -> u16 {
get_u16(&self.data[..], slot_offset(slot))
}
pub fn set_slot(&mut self, slot: u16, tuple_offset: u16) {
put_u16(&mut self.data[..], slot_offset(slot), tuple_offset);
}
/// Живой ли слот: существует и указывает на кортеж, а не на DEAD_SLOT.
pub fn is_slot_live(&self, slot: u16) -> bool {
slot < self.slot_count() && self.get_slot(slot) != DEAD_SLOT
}
И свободная функция за пределами impl:
fn slot_offset(slot: u16) -> usize {
HEADER_SIZE + slot as usize * SLOT_SIZE
}
Два места тут стоит проговорить.
saturating_sub вместо минуса. На честной странице lower не меньше 16, а upper не меньше lower. Но страница могла прийти с диска побитой. В отладочной сборке Rust проверяет арифметику на переполнение, и 5 - 16 на беззнаковых числах уронил бы процесс, а в релизной тихо дал бы огромное число. saturating_sub упирается в ноль: на битой странице мы увидим «0 слотов» и «0 свободных байтов», а не панику и не 4 миллиарда. Про то, как устроены беззнаковые числа и что такое переполнение, в уроке про дополнительный код.
Явные as usize. Rust не приводит u16 к usize сам, даже когда это безопасно. Каждое as в коде это место, где мы сознательно меняем ширину числа. Приведение u16 в usize расширяющее и ничего не теряет. Обратное ... as u16 в конце slot_count сужающее, но результат не больше 4088, так что тоже безопасно.
slot_count и free_space не хранят ничего своего: они каждый раз пересчитываются из заголовка. Так не бывает рассинхрона между «что мы думаем» и «что в байтах».
Вставка кортежа в страницу
Главный метод. Он кладёт сырые байты кортежа в страницу: данные у upper, слот у lower. О формате этих байтов страница ничего не знает, для неё кортеж это непрозрачный срез &[u8]. Сериализацию кортежа мы напишем в уроке 3.
/// Положить сырые байты кортежа у upper и добавить слот у lower. None, если места
/// меньше, чем нужно под данные плюс слот. О формате байтов страница не знает.
pub fn insert_tuple(&mut self, data: &[u8]) -> Option<u16> {
if data.len() + SLOT_SIZE > self.free_space() {
return None;
}
let tuple_offset = self.place(data);
let slot = self.slot_count();
self.set_slot(slot, tuple_offset);
self.set_lower(self.lower() + SLOT_SIZE as u16);
Some(slot)
}
/// Пометить слот мёртвым. Место не уплотняется, это работа vacuum (урок 10).
pub fn delete_slot(&mut self, slot: u16) {
self.set_slot(slot, DEAD_SLOT);
}
/// Скопировать байты под upper, сдвинуть upper, вернуть смещение. Место проверено.
fn place(&mut self, data: &[u8]) -> u16 {
let tuple_offset = self.upper() as usize - data.len();
self.data[tuple_offset..tuple_offset + data.len()].copy_from_slice(data);
self.set_upper(tuple_offset as u16);
tuple_offset as u16
}
Разберём insert_tuple по шагам, это смысловой центр всего урока.
- Считаем, сколько места нужно: байты кортежа плюс один новый слот. Если это больше свободного места, страница полна, возвращаем
None. Вызывающий код (слой кучи в уроке 3) поймёт, что надо взять другую страницу. - Приватный
placeсчитает смещениеupper - data.len(), копирует туда байты черезcopy_from_sliceи сдвигаетupperвлево. Вычитание здесь обычное, безsaturating: место уже проверено на шаге 1, и если вычитание переполнится, это баг, о котором лучше узнать паникой. - Номер нового слота это текущий
slot_count(). Пишем в этот слот смещение кортежа. - Двигаем
lowerвправо на один слот и возвращаемSome(slot).
Строка self.set_lower(self.lower() + SLOT_SIZE as u16) выглядит как нарушение правил заимствования: метод берёт &mut self, а в аргументе мы читаем self.lower() через &self. Компилятор это пропускает благодаря двухфазному заимствованию: аргументы вычисляются до того, как изменяемая ссылка вступает в силу. Это тот же механизм, что разрешает писать v.push(v.len()).
Заметь тип результата Option<u16>, а не Result. «Места не хватило» это не сбой, а ожидаемый ответ, на который у вызывающего есть план. Тот же выбор сделали и TS-трек (number | null), и Effect-трек (пустое значение вместо ошибки в канале E). В Rust Option к тому же заставляет вызывающего разобрать оба случая: просто взять номер слота из Option<u16> без match, if let или ? нельзя.
delete_slot читается тривиально: пишем DEAD_SLOT в слот. Данные остаются в области данных, upper не двигается, свободное место не растёт. Это ровно то, что ты видел в виджете. Почему так? Физическое уплотнение дорого, а ещё в уроке 8 появится MVCC: «удалённый» кортеж может быть ещё виден старой транзакции, и трогать его рано.
В эталонном крейте у Page есть ещё несколько методов: вставка в конкретный слот для восстановления после сбоя, переписывание страницы для vacuum, чтение и запись xmax кортежа на месте. Они появятся в уроках 7, 8 и 10, каждый в момент, когда станет понятно, зачем он нужен.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.