Своя СУБД на Rust · урок 11: бинарный протокол и сервер
Кому это. За десять уроков мы собрали целый движок на Rust: страницы, буферный пул с PageGuard, кучу, B+Tree, парсер SQL, планировщик и исполнитель, журнал с CRC-32, транзакции и MVCC, блокировки с поиском дедлока, vacuum. Но говорить с движком умеют только тесты в том же процессе.
Зачем. Это финал воркшопа: выставляем движок в сеть. Придумаем своё кадрирование, реализуем simple query protocol в духе Postgres, напишем сессию, сервер и клиента. В конце клиент откроет сокет, пошлёт SELECT, получит типизированные строки, а BEGIN и COMMIT заработают по сети. Байтовый контракт общий для всех языков воркшопа, поэтому наш клиент поговорит с сервером на TS или Go, и наоборот.
Зачем именно Rust. Здесь собираются вместе все решения, которые мы откладывали. Сервер синхронный, на std::net, без асинхронного рантайма: курс собирает движок без крейтов. Поток на accept и поток на соединение, а Database лежит в Arc<Mutex<Database>>: движок однопоточный, и мьютекс выполняет запросы по одному. Тут окупается FlushHooks: Send из урока 7: без него Mutex<Database> нельзя было бы отдать в thread::spawn. Ошибки движка это Result, и на границе протокола они превращаются в ErrorResponse с SQLSTATE-кодом через Error::sqlstate(). Тесты слушают 127.0.0.1:0, и ОС сама выдаёт свободный порт.
Важно про объём. Урок аддитивный: новый модуль src/wire/ из пяти файлов, движок не меняется. Кадрирование и кодеки сообщений чистые, без ввода-вывода. Сессия превращает SQL в сообщения и тестируется без сети. Сервер и клиент это тонкий слой поверх сокетов.
Где мы в движке
┌────────────────────────────────────────────────┐
│ Бинарный протокол + 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
└────────────────────────────────────────────────┘
Идея протокола
Клиент и сервер обмениваются сообщениями. Каждое сообщение это один байт типа и payload. Клиент шлёт Query (SQL-строка) или Terminate (до свидания). Сервер на каждый Query отвечает серией: для SELECT это RowDescription, сколько угодно DataRow, CommandComplete с тегом вроде SELECT 2 и ReadyForQuery со статусом транзакции. Ошибка это ErrorResponse с кодом и текстом, а за ним всё равно ReadyForQuery. Клиент читает ответ до ReadyForQuery и только потом шлёт следующий запрос.
Типы сообщений это ASCII-буквы, как в Postgres: Q, X, T, D, C, E, Z. Числа на проводе big-endian, в сетевом порядке, в отличие от little-endian на диске. Два порядка байт в одном движке это обычное дело: формат файла выбирают под процессор, формат провода под традицию сетевых протоколов.
Кадрирование
// src/wire/framing.rs
//! Кадр: u8 тип, u32 длина big-endian (включает 4 байта длины, не включает байт типа),
//! payload длиной length - 4.
use crate::error::{invalid, Result};
/// Размер заголовка кадра: байт типа плюс u32 длины.
const FRAME_HEADER_SIZE: usize = 5;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Frame {
pub ty: u8,
pub payload: Vec<u8>,
}
/// Собрать кадр: байт типа, длина (4 плюс payload), payload.
pub fn encode_frame(ty: u8, payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(FRAME_HEADER_SIZE + payload.len());
frame.push(ty);
frame.extend_from_slice(&(4 + payload.len() as u32).to_be_bytes());
frame.extend_from_slice(payload);
frame
}
#[derive(Debug, Default)]
pub struct FrameReader {
buffer: Vec<u8>,
}
impl FrameReader {
pub fn new() -> FrameReader {
FrameReader::default()
}
/// Добавить чанк и вернуть все кадры, собравшиеся целиком. Длина меньше 4 это
/// мусор на входе: такой кадр не может существовать.
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<Frame>> {
self.buffer.extend_from_slice(chunk);
let mut frames = Vec::new();
let mut start = 0;
while self.buffer.len() - start >= FRAME_HEADER_SIZE {
let length_bytes = self.buffer[start + 1..start + FRAME_HEADER_SIZE]
.try_into()
.expect("4 bytes");
let length = u32::from_be_bytes(length_bytes) as usize;
if length < 4 {
return Err(invalid(format!("frame length {length} is below 4")));
}
let total = 1 + length;
if self.buffer.len() - start < total {
break; // кадр ещё не дочитан
}
frames.push(Frame {
ty: self.buffer[start],
payload: self.buffer[start + FRAME_HEADER_SIZE..start + total].to_vec(),
});
start += total;
}
self.buffer.drain(..start);
Ok(frames)
}
}
Длина в кадре включает свои 4 байта, но не байт типа: так устроен протокол Postgres, и наш кадр байт в байт совпадает с его форматом. Поэтому длина меньше 4 невозможна, и FrameReader отвергает её как мусор.
FrameReader это маленький конечный автомат над буфером. push дописывает чанк, режет из начала буфера все целые кадры и оставляет хвост до следующего чанка. Один read из сокета может принести полкадра, три кадра или два с половиной, и push справится с любым вариантом. Разберём Rust-детали.
self.buffer[start + 1..start + 5].try_into()превращает срез&[u8]в массив[u8; 4]дляu32::from_be_bytes. Преобразование может не удаться, если длина среза не 4. Здесь она 4 по построению, поэтому.expect("4 bytes")фиксирует инвариант, а не обрабатывает ошибку.- Курсор
startсдвигается по кадрам, а буфер чистится один раз в конце черезdrain(..start). Удаление из началаVecпосле каждого кадра сдвигало бы хвост снова и снова; одинdrainдвигает его единожды. - Длина приходит из сети и проверяется до использования.
length < 4это ошибка, а огромная длина просто оставит кадр недочитанным. Ограничение сверху на размер кадра стоит добавить в настоящем сервере, иначе клиент заставит нас копить буфер бесконечно; это часть упражнения про handshake.
Сообщения
// src/wire/messages.rs
//! Simple query protocol в духе Postgres, числа big-endian. DataRow на проводе это
//! текст: int десятичной строкой, bool как t/f, text как есть.
/// Байты типа сообщений (ASCII).
pub mod msg {
pub const QUERY: u8 = b'Q';
pub const TERMINATE: u8 = b'X';
pub const ROW_DESCRIPTION: u8 = b'T';
pub const DATA_ROW: u8 = b'D';
pub const COMMAND_COMPLETE: u8 = b'C';
pub const ERROR_RESPONSE: u8 = b'E';
pub const READY_FOR_QUERY: u8 = b'Z';
}
/// Статус транзакции в ReadyForQuery.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TxnStatus {
Idle,
InTransaction,
Failed,
}
impl TxnStatus {
pub fn byte(self) -> u8 {
match self {
TxnStatus::Idle => b'I',
TxnStatus::InTransaction => b'T',
TxnStatus::Failed => b'E',
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClientMessage {
Query(String),
Terminate,
}
impl ClientMessage {
/// Query это SQL плюс завершающий NUL, Terminate пуст.
pub fn encode_payload(&self) -> Vec<u8> {
match self {
ClientMessage::Query(sql) => {
let mut payload = Vec::with_capacity(sql.len() + 1);
payload.extend_from_slice(sql.as_bytes());
payload.push(0);
payload
}
ClientMessage::Terminate => Vec::new(),
}
}
pub fn decode(ty: u8, payload: &[u8]) -> Result<ClientMessage> {
match ty {
msg::QUERY => {
let sql = payload.strip_suffix(&[0]).unwrap_or(payload);
Ok(ClientMessage::Query(
String::from_utf8_lossy(sql).into_owned(),
))
}
msg::TERMINATE => Ok(ClientMessage::Terminate),
other => Err(invalid(format!(
"неизвестный тип клиентского сообщения 0x{other:x}"
))),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServerMessage {
RowDescription(Vec<Field>),
/// Ячейки текстом, None это NULL (длина -1).
DataRow(Vec<Option<String>>),
CommandComplete(String),
ErrorResponse {
code: String,
message: String,
},
ReadyForQuery(TxnStatus),
}
Сообщения это два enum, клиентский и серверный. Каждый вариант несёт ровно свои данные, и match по сообщению не даст забыть вид ответа. b'Q' это байтовый литерал: u8 со значением ASCII-кода буквы. Модуль msg повторяет приём с кодами записей журнала: на проводе голый байт, и незнакомый тип должен стать ошибкой разбора, а не паникой.
strip_suffix(&[0]) снимает завершающий NUL, если он есть, а unwrap_or(payload) берёт payload как есть, если его нет. String::from_utf8_lossy заменяет битые последовательности символом замены вместо ошибки: SQL с битым UTF-8 всё равно не разберётся парсером, и он вернёт понятную синтаксическую ошибку. 0x{other:x} это шестнадцатеричный вывод прямо в строке формата.
Кодирование серверных сообщений собирает payload по варианту:
// src/wire/messages.rs (продолжение, в impl ServerMessage)
pub fn encode_payload(&self) -> Vec<u8> {
let mut out = Vec::new();
match self {
ServerMessage::RowDescription(fields) => {
out.extend_from_slice(&(fields.len() as u16).to_be_bytes());
for field in fields {
out.extend_from_slice(&(field.name.len() as u16).to_be_bytes());
out.extend_from_slice(field.name.as_bytes());
out.push(type_code(field.ty));
}
}
ServerMessage::DataRow(cells) => {
out.extend_from_slice(&(cells.len() as u16).to_be_bytes());
for cell in cells {
match cell {
None => out.extend_from_slice(&(-1i32).to_be_bytes()),
Some(text) => {
out.extend_from_slice(&(text.len() as i32).to_be_bytes());
out.extend_from_slice(text.as_bytes());
}
}
}
}
ServerMessage::CommandComplete(tag) => out.extend_from_slice(tag.as_bytes()),
ServerMessage::ErrorResponse { code, message } => {
out.extend_from_slice(&(code.len() as u16).to_be_bytes());
out.extend_from_slice(code.as_bytes());
out.extend_from_slice(message.as_bytes());
}
ServerMessage::ReadyForQuery(status) => out.push(status.byte()),
}
out
}
DataRow несёт ячейку как Option<String>, а на проводе длину i32: -1 значит NULL, как в Postgres. Знаковый тип длины здесь ради этого сентинела. При разборе обратная операция тоже красивая: usize::try_from(len) для отрицательной длины даёт Err, и ветка Err(_) => None превращает её в NULL без отдельного сравнения с -1.
Значения на проводе текстом: int десятичной строкой, bool как t и f, text как есть. Клиент разбирает их обратно по типу из RowDescription:
// src/wire/messages.rs (продолжение)
/// Текстовое представление значения для DataRow (None это NULL).
pub fn encode_value_text(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::Int(int) => Some(int.to_string()),
Value::Bool(flag) => Some(if *flag { "t" } else { "f" }.to_owned()),
Value::Text(text) => Some(text.clone()),
}
}
/// Разобрать текстовое значение колонки по её типу.
pub fn decode_value_text(ty: ColumnType, text: &str) -> Result<Value> {
Ok(match ty {
ColumnType::Int => Value::Int(
text.parse()
.map_err(|_| invalid(format!("не число в int-колонке: {text}")))?,
),
ColumnType::Bool => Value::Bool(text == "t"),
ColumnType::Text => Value::Text(text.to_owned()),
})
}
text.parse() выводит целевой тип из Value::Int(i64) и разбирает строку в i64. Число за пределами i64 на проводе станет ошибкой разбора, а не тихим переполнением. Здесь видна одна из сознательных развилок порта с TS-эталоном. В TS значение INT живёт в bigint без верхней границы, и v + 1 у максимального числа даёт честно большее число, а выход за 64 бита всплывает только при сериализации кортежа. В Rust Value::Int это i64, и вычислитель урока 6 считает через checked_add и его родню: переполнение становится Error::Engine("integer overflow") уже в выражении. По сети клиент получит его как ErrorResponse с кодом 42000.
decode серверного сообщения читает payload через маленький курсор Reader с методами take, u16 и text. Каждый проверяет границы через bytes::slice и возвращает Result: payload пришёл из сети, и короткое сообщение от неисправного сервера должно стать ошибкой, а не паникой клиента.
Ошибки становятся кодами
Сессия на каждую ошибку движка отвечает ErrorResponse. Код выбирает Error::sqlstate() из урока 9:
Вариант Error | SQLSTATE | Имя в Postgres |
|---|---|---|
Uniqueness | 23505 | unique_violation |
Serialization | 40001 | serialization_failure |
Deadlock | 40P01 | deadlock_detected |
LockWait | 55P03 | lock_not_available |
Sql | 42601 | syntax_error |
Engine | 42000 | syntax_error_or_access_rule_violation |
Io, Invalid | XX000 | internal_error |
Сессия добавляет ещё один код, 25P02 (in_failed_sql_transaction): команда внутри упавшей транзакции. Клиент сам порождает 08006 (connection_failure), когда сервер закрыл сокет.
Особенно полезен 55P03 по сети. В уроке 9 мы решили, что менеджер блокировок не ждёт. Теперь у этого решения есть клиентская сторона: второе соединение, которое тронуло строку под чужим локом, получает 55P03 сразу, а не висит. Клиент вправе повторить оператор позже.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.