Раздел 10 · Databases

Своя СУБД на Go · урок 3: куча и записи

senior~130 мин

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

Своя СУБД на Go · урок 3: куча и записи

Кому это. В уроке 1 ты сделал страницу как массив [8192]byte и disk.Manager поверх *os.File. В уроке 2 поставил над диском буферный пул под sync.Mutex: FetchPage закрепляет страницу, UnpinPage отпускает её, вытеснение идёт по clock. Но движок пока хранит только безымянные байты. Страница не знает, что в ней лежит строка с колонками id, name, active. Этот урок вводит таблицу и запись.

Зачем именно Go. Сериализация строки это работа руками над байтами, и Go даёт для неё ровно нужный набор инструментов без лишних слоёв. encoding/binary пишет и читает числа little-endian по смещению, append растит срез без ручного учёта длины, а явный error в каждой подписи заставляет решить, что делать с обрезанным кортежем. В TS-треке значение int пришлось держать в bigint, потому что number теряет точность за 2^53. В Go это просто int64, родной тип процессора. Зато у Go свои ловушки: значение колонки хранится в any, и нетипизированная константа 42 ляжет туда как int, а не int64. Мы разберём, где именно Go бьёт по рукам и почему это хорошо.

Важно про код. Ты пишешь свою реализацию с нуля, в своём модуле ourdb (Go 1.23, только стандартная библиотека). Листинги взяты из эталона examples/our-db/go; где я убрал куски будущих уроков, это сказано прямо. Печатай руками, запускай go test ./.... Главная новая идея урока это сериализация: превращение типизированной строки в плоский срез байтов и обратно, байт в байт по контракту. Через неё проходит вся работа с данными.

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

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

Куча ляжет ровно поверх буферного пула. Когда ей нужна страница, она зовёт pool.FetchPage(id), работает с байтами и обязательно зовёт pool.UnpinPage(id, dirty). На диск напрямую куча не ходит вообще, за неё это делает пул. Так слои собираются один над другим, и зависимость видна прямо в импортах: пакет heap импортирует bufferpool, page и tuple, но не disk.

Три новые сущности

Куча держится на трёх типах, и порядок тут важен. Заодно заметь, как они разложены по пакетам.

  • tuple.Schema описывает форму строки: имена и типы колонок по порядку. Чистые данные, без ввода-вывода.
  • tuple.Tuple это одна строка в памяти (срез значений) плюс заголовок версии и умение сериализовать себя в байты по схеме и разобраться обратно. Чистая арифметика над срезом байтов.
  • heap.File это таблица: набор страниц поверх пула. Вставка ищет место, скан обходит всё, удаление помечает слот. Тут есть и зависимость (пул), и ввод-вывод, и сбои диска.

Первые два живут в пакете tuple, третий в пакете heap. Почему не в одном? Пакет tuple не знает ни о страницах, ни о пуле, и это ценно: в уроке 6 binder и вычислитель будут работать с tuple.Value и tuple.Schema, не таща за собой весь слой хранения. В Go пакет это единица компиляции и граница видимости, а циклические импорты запрещены. Если положить схему рядом с кучей, движок верхнего уровня импортировал бы кучу ради одного типа колонки. Мелкий пакет снизу, который ни от чего не зависит, это обычный приём Go-проектов; про раскладку модуля по пакетам подробнее в уроке про идиомы и стандартную библиотеку.

Идём снизу вверх: схема, потом кортеж, потом куча.

Schema: форма строки

Минимальный набор типов из контракта: int (8 байт, знаковый), text (UTF-8 переменной длины), bool (1 байт). Этого хватает на весь воркшоп, новые типы вынесены в упражнения. Схема это упорядоченный список колонок.

// tuple/schema.go
package tuple

import "fmt"

// ColumnType это тип колонки из контракта: int (8 байт LE, знаковый), text (UTF-8), bool.
type ColumnType string

const (
	TypeInt  ColumnType = "int"
	TypeText ColumnType = "text"
	TypeBool ColumnType = "bool"
)

// Column это имя и тип колонки.
type Column struct {
	Name string     `json:"name"`
	Type ColumnType `json:"type"`
}

// Schema это колонки таблицы по порядку.
type Schema []Column

// ColumnIndex ищет колонку по имени.
func (s Schema) ColumnIndex(name string) (int, error) {
	for i, c := range s {
		if c.Name == name {
			return i, nil
		}
	}
	return -1, fmt.Errorf("unknown column %q", name)
}

Здесь три Go-приёма, и каждый стоит проговорить.

ColumnType это именованная строка, а не iota. Можно было бы завести type ColumnType uint8 и константы через iota, как перечисления в уроке про структуры и iota. Но строка печатается сама (fmt.Println(c.Type) даст int, а не 0), читается в сообщениях об ошибках и ложится в JSON без маршалера. В уроке 6 каталог сохранит схему таблицы на диск, и строковый тип избавит от таблицы перевода. Цена вопроса мала: сравнение двух коротких строк почти не дороже сравнения байтов.

Schema это именованный срез с методом. В TS-треке схема была классом с полем columns. В Go проще: type Schema []Column это срез, у которого есть свой метод. Схему можно обходить range, брать len(schema) и индексировать schema[i] как обычный срез, и при этом у неё есть ColumnIndex. Литерал при этом получается коротким, потому что Go позволяет опустить тип элемента внутри составного литерала:

var users = tuple.Schema{{Name: "id", Type: tuple.TypeInt}, {Name: "name", Type: tuple.TypeText}, {Name: "active", Type: tuple.TypeBool}}

Метод объявлен на значении (func (s Schema)), а не на указателе. Срез и так ссылается на общий массив, копируется только заголовок из трёх слов, поэтому указатель тут ничего не сэкономит. Как устроен заголовок среза и почему его копия дешёвая, разобрано в уроке про внутреннее устройство типов.

Теги json:"name" уже сейчас. Сериализация кортежей идёт руками через encoding/binary, JSON тут ни при чём. Теги нужны каталогу из урока 6, который запишет схему таблицы в служебный файл. Завести их сразу дешевле, чем потом искать все литералы.

Порядок колонок это часть контракта, а не деталь. Сериализация пишет значения в порядке колонок, разбор читает в том же порядке. Поменяешь местами две колонки в схеме, и старые байты прочитаются неверно. ColumnIndex понадобится binder-у в уроке 6, когда он будет переводить имя колонки из SQL в её позицию. Заметь, что на неизвестном имени метод возвращает ошибку, а не паникует: имя колонки придёт из пользовательского SQL, а на ввод пользователя в Go отвечают значением error.

Контракт байтов кортежа

Прежде чем писать Tuple, посмотри на формат, в который он сериализуется. Это контракт из спецификации, общий для всех реализаций воркшопа, поэтому байты Go-трека обязаны совпадать с TS-треком и Effect-треком до бита:

смещение  размер  поле
0         4       xmin       u32   xid транзакции, создавшей версию
4         4       xmax       u32   xid транзакции, удалившей версию (0 если живая)
8         2       infomask   u16   биты-флаги (пока один: HAS_NULL)
10        2       nattrs     u16   число колонок
12        ...     null_bitmap      по биту на колонку
...       ...     data             значения ненулевых колонок по порядку

Заголовок это 12 байт. Два поля в нём, xmin и xmax, несут метаданные MVCC. Поле xmin это номер транзакции, вставившей строку, xmax это номер удалившей (0, пока строка жива). Сейчас, до транзакций, они значат мало: мы заполняем xmin тем xid, что передан вставке, а xmax нулём. Но место под них резервируем уже здесь, чтобы в уроке 8 не ломать формат и не переписывать каждый кортеж на диске.

Значения колонок в области данных кодируются так:

int    8 байт, int64 little-endian (знаковый, дополнительный код)
bool   1 байт, 0 или 1
text   u32 little-endian с длиной в байтах, затем байты UTF-8

Про числа в Go говорить почти нечего, и это приятно. int из контракта это int64, родной тип для 64-битного процессора. Никаких bigint и суффиксов n, как в TS-треке: значение -9_000_000_000_000_000_000 помещается в int64 и проходит через байты без потерь.

NULL это бит, а не значение

Ключевая тонкость формата это null bitmap. NULL это не особое значение в данных, а поднятый бит в карте. Если колонка NULL, в области данных она не занимает ничего. Это и компактнее (разреженная строка с десятком NULL почти пустая), и честнее: NULL это отсутствие значения, а не ноль и не пустая строка. В Go отсутствие выразим через nil.

Чтобы это уложилось, повозись с раскладкой вживую. Переключай колонки в NULL и обратно, правь имя. Следи за тремя вещами: область данных (NULL-колонка исчезает из неё целиком), бит в null bitmap, флаг HAS_NULL в infomask. Заметь, что заголовок всегда 12 байт, а bitmap всегда 1 байт (на 3 колонки хватает одного байта), меняется только хвост с данными.

Если повозился с виджетом, ты уже увидел три факта, на которых стоит сериализация. Первый: заголовок и bitmap фиксированной длины, плавает только область данных. Второй: NULL-колонка не пишет ни байта данных, её выдаёт только бит в bitmap. Третий: text растёт ровно на длину UTF-8, и кириллица это 2 байта на символ, поэтому name из русских букв вдвое длиннее, чем кажется по числу символов. В Go это видно сразу: len("батя") равно 8, а не 4, потому что len у строки считает байты. Про байты, руны и символы подробно в уроке про строки. Теперь запрограммируем.

Tuple: значения плюс заголовок

Начнём с констант и самого типа. Пакет tuple открывается так.

// tuple/tuple.go (сокращено: флаг заморозки и метод Freeze появятся в уроке 10)
// Package tuple это логическая строка плюс заголовок версии MVCC (урок 3) и схема таблицы.
// Сериализация по контракту (SPEC.md, раздел "Кортеж"):
//
//	xmin u32 LE | xmax u32 LE | infomask u16 LE | nattrs u16 LE | null_bitmap | data
//
// xmin/xmax заполняются уже здесь, но значат что-то только с урока 8.
package tuple

import (
	"encoding/binary"
	"fmt"
)

const (
	// HeaderSize это размер заголовка кортежа до null bitmap.
	HeaderSize = 12
	// InfomaskHasNull значит, что null bitmap значим.
	InfomaskHasNull = 0x0001
)

var le = binary.LittleEndian

// Value это значение колонки: int64, string, bool или nil (NULL).
type Value = any

// Tuple это версия строки: значения плюс заголовок MVCC.
type Tuple struct {
	Values []Value
	Xmin   uint32
	Xmax   uint32
}

infomask это набор битовых флагов про кортеж. Пока флаг один, InfomaskHasNull. В уроке 10 здесь появится второй бит (заморозка версии для vacuum), поэтому держим это битовой маской uint16, а не булевым полем.

Строка var le = binary.LittleEndian знакома по уроку 1: короткое имя для переменной пакета encoding/binary убирает шум из каждой строки с числами.

Value: псевдоним any

Самая интересная строка тут type Value = any. Знак = делает её псевдонимом, а не новым типом. tuple.Value и any это один и тот же тип, []tuple.Value это буквально []any. Зачем тогда имя? Ради документации: в подписи Values []Value сразу понятно, что лежит в срезе, а комментарий рядом перечисляет допустимые варианты.

Почему any, а не что-то строже? В TS-треке значение было объединением bigint | string | boolean | null, и компилятор проверял его. В Go объединений типов для значений нет: ограничение int64 | string | bool бывает только у параметра дженерика, а положить разнотипные значения в один срез через него нельзя. Остаются два пути:

  1. Интерфейс с неэкспортируемым методом и свои типы-обёртки IntValue, TextValue, BoolValue. Закрытое множество, но каждый литерал в тестах и в движке обрастает обёрткой, а NULL требует ещё одного типа.
  2. any плюс проверка типа там, где значение превращается в байты.

Эталон выбирает второе. Значения приходят в кортеж из двух мест: из binder-а урока 6, который уже проверил типы по схеме, и из Deserialize, который сам их создал. Проверка на границе с байтами ловит оставшиеся ошибки. Как устроен any внутри (пара «тип, указатель на данные») и что делает приведение v.(int64), разобрано в уроке про интерфейсы.

У any есть одна ловушка, и в тестах ты в неё обязательно наступишь. Нетипизированная целая константа, положенная в any, получает тип по умолчанию, а это int, не int64:

tuple.Tuple{Values: []tuple.Value{42}}             // 42 станет int, Serialize вернёт ошибку
tuple.Tuple{Values: []tuple.Value{int64(42)}}      // так правильно

Первая строка скомпилируется, а Serialize вернёт int column expects int64, got int. Поэтому во всех тестах урока целые значения записаны как int64(1). Строки и булевы этой проблемы не знают: у нетипизированной строки тип по умолчанию string, у true тип bool.

Поля открыты, конструктора нет. В TS-треке у Tuple был конструктор и метод withHeader, который возвращал новый кортеж. В Go кортеж это обычная структура с экспортируемыми полями, и её создают литералом: tuple.Tuple{Values: values}. Неуказанные Xmin и Xmax получают нулевое значение, то есть 0, а это ровно «живая версия без транзакции». Нулевое значение, которое уже осмысленно, это одна из любимых идиом Go, и тут она работает без единой строки кода.

А как же иммутабельность? Методы Serialize ниже объявлены на значении (func (t Tuple)), поэтому получают копию структуры. Куча проставит xmin в своей копии, и кортеж вызывающего не изменится. Срез Values при этом общий (копируется только заголовок среза), но ни одна функция урока в него не пишет.

Serialize: строка в байты

// Serialize пишет кортеж в байты по схеме. (сокращено: бит заморозки появится в уроке 10)
func (t Tuple) Serialize(schema Schema) ([]byte, error) {
	n := len(schema)
	if len(t.Values) != n {
		return nil, fmt.Errorf("tuple has %d values, schema expects %d", len(t.Values), n)
	}
	bitmapBytes := (n + 7) / 8
	out := make([]byte, HeaderSize+bitmapBytes, HeaderSize+bitmapBytes+8*n)
	var infomask uint16
	for i, c := range schema {
		v := t.Values[i]
		if v == nil {
			infomask |= InfomaskHasNull
			out[HeaderSize+i/8] |= 1 << (i % 8)
			continue
		}
		var err error
		if out, err = appendValue(out, c.Type, v); err != nil {
			return nil, err
		}
	}
	le.PutUint32(out[0:], t.Xmin)
	le.PutUint32(out[4:], t.Xmax)
	le.PutUint16(out[8:], infomask)
	le.PutUint16(out[10:], uint16(n))
	return out, nil
}

Разберём по шагам, это смысловой центр урока.

  1. Проверка. Число значений обязано совпадать с числом колонок схемы. Несовпадение возвращаем ошибкой: лучше отказаться тут, чем записать кривой кортеж на диск.
  2. Один срез на весь кортеж. make([]byte, длина, ёмкость) сразу выделяет заголовок и bitmap нулями (длина HeaderSize+bitmapBytes) и резервирует место под данные (ёмкость плюс 8*n). Дальше appendValue дописывает значения в конец через append, и пока данные влезают в запас, новой памяти не выделяется. Для строки из одних int и bool оценка точная, длинный text заставит append один раз вырасти. Сравни с TS-треком: там было три буфера (header, bitmap, чанки данных) и Buffer.concat в конце, то есть лишние аллокации и копия. Здесь срез один.
  3. Проход по колонкам. range schema даёт индекс и колонку. Если значение nil, поднимаем InfomaskHasNull и зажигаем бит колонки: out[HeaderSize+i/8] |= 1 << (i % 8). Байт bitmap по индексу i/8, бит по позиции i%8 внутри байта. В область данных ничего не кладём. Иначе кодируем значение по типу колонки.
  4. Заголовок в конце. Его поля пишутся последними, через PutUint32 и PutUint16 прямо в начало того же среза. Почему в конце? Потому что infomask известен только после прохода по колонкам: был ли хоть один NULL. Длина заголовка фиксирована, поэтому место под него мы зарезервировали заранее, и писать его можно когда угодно.

Обрати внимание на строку if out, err = appendValue(out, c.Type, v); err != nil. Тут =, а не :=, и это важно. С := внутри if появилась бы новая переменная out, видимая только в теле условия, и внешний out остался бы без дописанных данных. Код скомпилировался бы и молча терял значения. Поэтому err объявлен строкой выше, а присваивание обычное. Это классическая ловушка затенения в Go, и её стоит запомнить: append возвращает новый заголовок среза, и его обязательно надо сохранить в ту же переменную.

А le.PutUint32(out[0:], ...) ничего не выделяет и не возвращает: он пишет 4 байта в начало переданного среза, который делит память с out.

appendValue: одно значение по типу

func appendValue(out []byte, typ ColumnType, v Value) ([]byte, error) {
	switch typ {
	case TypeInt:
		x, ok := v.(int64)
		if !ok {
			return nil, fmt.Errorf("int column expects int64, got %T", v)
		}
		return le.AppendUint64(out, uint64(x)), nil
	case TypeBool:
		x, ok := v.(bool)
		if !ok {
			return nil, fmt.Errorf("bool column expects bool, got %T", v)
		}
		if x {
			return append(out, 1), nil
		}
		return append(out, 0), nil
	case TypeText:
		x, ok := v.(string)
		if !ok {
			return nil, fmt.Errorf("text column expects string, got %T", v)
		}
		return append(le.AppendUint32(out, uint32(len(x))), x...), nil
	}
	return nil, fmt.Errorf("unknown column type %q", typ)
}

Три типа, три кодека, и в каждом проверка типа в форме «запятая ok».

x, ok := v.(int64). Это приведение интерфейса к конкретному типу с флагом. Если в v лежит int64, ok равно true, и x это само число. Если там что-то другое (тот самый int из нетипизированной константы, строка, float64), ok равно false, и мы возвращаем ошибку с %T, который печатает фактический тип. Без ok форма v.(int64) паниковала бы на несовпадении, а нам паника тут не нужна: несовпадение типа значения и колонки это ошибка данных, которую вызывающий должен увидеть как error.

le.AppendUint64(out, uint64(x)). AppendUint64 дописывает 8 байт числа в конец среза и возвращает новый срез, ровно как append. Эта семья функций (AppendUint16, AppendUint32, AppendUint64) появилась в encoding/binary в Go 1.19 и избавляет от ручного «расширить на 8 байт, потом PutUint64 в хвост». Приведение uint64(x) для отрицательного int64 не меняет ни одного бита: Go хранит знаковые целые в дополнительном коде, и конверсия между int64 и uint64 просто по-другому читает те же 64 бита. При разборе обратное int64(...) вернёт исходное число, включая знак.

bool это один байт. append(out, 1) или append(out, 0). Тернарного оператора в Go нет, поэтому два return.

text это длина плюс байты. le.AppendUint32(out, uint32(len(x))) дописывает длину строки в байтах, и сразу следом append(..., x...) дописывает сами байты. Запись x... для строки работает потому, что у append для []byte есть особый случай: вторым аргументом можно передать строку с многоточием, и байты скопируются без промежуточного []byte(x). Длину храним в байтах, а не в символах: разбору важно знать, сколько байт прочитать, а один символ это не один байт (кириллица занимает два, эмодзи четыре). len(x) в Go ровно байты и отдаёт.

Последняя строка, unknown column type, ловит тип колонки вне трёх известных. Компилятор Go не проверяет полноту switch по строковым константам, поэтому ветка по умолчанию остаётся на нас.

Deserialize: байты в строку

Разбор это зеркало. Дано: срез байтов, смещение начала кортежа в нём и схема. Надо собрать Tuple.

// Deserialize разбирает кортеж из buf начиная со смещения off. (сокращено: флаг заморозки появится в уроке 10)
func Deserialize(buf []byte, off int, schema Schema) (Tuple, error) {
	if off < 0 || off+HeaderSize > len(buf) {
		return Tuple{}, fmt.Errorf("tuple header at %d out of bounds", off)
	}
	t := Tuple{Xmin: le.Uint32(buf[off:]), Xmax: le.Uint32(buf[off+4:])}
	infomask := le.Uint16(buf[off+8:])
	nattrs := int(le.Uint16(buf[off+10:]))
	if nattrs != len(schema) {
		return Tuple{}, fmt.Errorf("tuple has %d attrs, schema expects %d", nattrs, len(schema))
	}
	hasNull := infomask&InfomaskHasNull != 0
	bitmap := off + HeaderSize
	cur := bitmap + (nattrs+7)/8
	if cur > len(buf) {
		return Tuple{}, fmt.Errorf("tuple null bitmap at %d out of bounds", bitmap)
	}
	t.Values = make([]Value, nattrs)
	for i, c := range schema {
		if hasNull && buf[bitmap+i/8]&(1<<(i%8)) != 0 {
			continue
		}
		v, next, err := decodeValue(c.Type, buf, cur)
		if err != nil {
			return Tuple{}, err
		}
		t.Values[i], cur = v, next
	}
	return t, nil
}

Шаги те же, что у сериализации, в обратную сторону.

  1. Заголовок. Сначала проверяем, что 12 байт заголовка вообще помещаются в срез. Потом читаем xmin, xmax, infomask, nattrs по смещениям от off. Сверяем nattrs со схемой: если кортеж записан под другую схему, лучше вернуть ошибку, чем выдать мусор.
  2. Курсор. Данные начинаются сразу за заголовком и bitmap. cur это где мы сейчас читаем, он ползёт вперёд по мере чтения колонок. Перед первым чтением проверяем, что и bitmap помещается в срез.
  3. Значения. make([]Value, nattrs) заводит срез нужной длины, все элементы сразу nil. Поэтому для NULL-колонки достаточно continue: нужное значение уже лежит на месте. Для остальных зовём decodeValue, который возвращает значение и смещение сразу за ним, и одним множественным присваиванием кладём значение и двигаем курсор.

Сравни с TS-треком. Там deserialize не проверял границ вообще: buffer.readUInt32LE за концом буфера бросал RangeError, и это было достаточно. В Go выход за границы среза это паника index out of range, которая роняет горутину, а без recover и всю программу. Кортеж на диске может быть битым (оборванная запись, чужой файл, ошибка в твоём коде прошлой недели), и падать на нём всем сервером нельзя. Поэтому Deserialize проверяет границы перед каждым чтением и превращает битые байты в обычный error. Тест TestTruncatedTupleIsError ниже обрезает кортеж на два байта и требует именно ошибку, а не панику.

Заметь, как важна проверка NULL первой. Если бы мы попытались прочитать значение NULL-колонки из области данных, мы бы прочитали байты следующей колонки и сбили курсор на всё остальное. Bitmap говорит, какие колонки в данных вообще присутствуют. Проверка hasNull && впереди экономит чтение байта bitmap на строках без NULL, но и без неё результат был бы тем же: у такого кортежа bitmap состоит из нулей.

decodeValue: одно значение, затем смещение

func decodeValue(typ ColumnType, buf []byte, off int) (Value, int, error) {
	need := 4 // длина text
	switch typ {
	case TypeInt:
		need = 8
	case TypeBool:
		need = 1
	case TypeText:
	default:
		return nil, 0, fmt.Errorf("unknown column type %q", typ)
	}
	if off+need > len(buf) {
		return nil, 0, fmt.Errorf("tuple %s value at %d out of bounds", typ, off)
	}
	switch typ {
	case TypeInt:
		return int64(le.Uint64(buf[off:])), off + 8, nil
	case TypeBool:
		return buf[off] != 0, off + 1, nil
	}
	start := off + 4
	end := start + int(le.Uint32(buf[off:]))
	if end > len(buf) || end < start {
		return nil, 0, fmt.Errorf("tuple text value at %d out of bounds", off)
	}
	return string(buf[start:end]), end, nil
}

Два switch подряд выглядят странно, но у каждого своя работа. Первый отвечает на вопрос «сколько байт нужно, чтобы начать читать» и отсекает неизвестный тип. Потом одна общая проверка границ. Второй switch уже читает, зная, что байты на месте. Так проверка границ написана один раз, а не три. Пустой case TypeText: в первом switch не ошибка: в Go нет сквозного провала в следующую ветку, пустая ветка просто ничего не делает, и need остаётся равным 4 под длину строки.

Для text проверок две. Первая общая: помещаются ли 4 байта длины. Вторая после чтения длины: помещаются ли сами байты строки. end < start страхует от переполнения: на 32-битной платформе int занимает 32 бита, и длина больше 2^31 из битого файла превратилась бы в отрицательное число.

string(buf[start:end]) копирует байты. Это свойство Go, на котором держится весь скан ниже. Строка в Go неизменяема, поэтому конверсия []byte в string выделяет новую память и копирует туда байты. Результат больше не смотрит в буфер страницы. int64 и bool и так копируются по значению. Значит, кортеж, который вернул Deserialize, полностью отвязан от страницы: её можно отпустить в пул, вытеснить, перезаписать, а кортеж останется прежним. Запомни это, через пару разделов мы на этом сэкономим pin.

Ошибки слоя: error, а не паника

В TS-треке ошибки этого слоя бросались исключениями (RangeError, TypeError), в Effect-треке слишком большой кортеж стал размеченной HeapError в канале E. В Go всё проще и строже: каждая функция, которая может отказать, возвращает error последним значением, и вызывающий обязан на него посмотреть.

Где тогда паника? Ты видел её в уроке 2: лишний UnpinPage паникует, потому что это ошибка программиста, такая же, как unlock незахваченного мьютекса. Её не «обрабатывают», её исправляют в коде. Всё, что зависит от данных, возвращается как error:

  • значение не того типа для колонки (int column expects int64, got int);
  • число значений не совпадает со схемой;
  • битые байты при разборе (out of bounds);
  • кортеж больше страницы (ниже, в куче);
  • любой сбой диска, который поднимется из пула.

Все эти ошибки собраны через fmt.Errorf с понятным текстом. Эталон не заводит для них сторожевых значений вроде var ErrTupleTooLarge = errors.New(...), и тест на слишком большой кортеж проверяет текст через strings.Contains. Для учебного движка этого хватает: различать эти ошибки программно пока некому. Если в твоём движке появится вызывающий, которому надо отличить «слишком большой кортеж» от сбоя диска, идиома Go известна: сторожевая ошибка плюс обёртка %w и проверка errors.Is. Как устроены обёртки, errors.Is и errors.As, разобрано в уроке про ошибки; в уроке 6 движок заведёт тип ошибки с кодом и будет читать его через errors.As.

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

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

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