Своя СУБД на 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 бывает только у параметра дженерика, а положить разнотипные значения в один срез через него нельзя. Остаются два пути:
- Интерфейс с неэкспортируемым методом и свои типы-обёртки
IntValue,TextValue,BoolValue. Закрытое множество, но каждый литерал в тестах и в движке обрастает обёрткой, а NULL требует ещё одного типа. 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
}
Разберём по шагам, это смысловой центр урока.
- Проверка. Число значений обязано совпадать с числом колонок схемы. Несовпадение возвращаем ошибкой: лучше отказаться тут, чем записать кривой кортеж на диск.
- Один срез на весь кортеж.
make([]byte, длина, ёмкость)сразу выделяет заголовок и bitmap нулями (длинаHeaderSize+bitmapBytes) и резервирует место под данные (ёмкость плюс8*n). ДальшеappendValueдописывает значения в конец черезappend, и пока данные влезают в запас, новой памяти не выделяется. Для строки из однихintиboolоценка точная, длинныйtextзаставитappendодин раз вырасти. Сравни с TS-треком: там было три буфера (header,bitmap, чанки данных) иBuffer.concatв конце, то есть лишние аллокации и копия. Здесь срез один. - Проход по колонкам.
range schemaдаёт индекс и колонку. Если значениеnil, поднимаемInfomaskHasNullи зажигаем бит колонки:out[HeaderSize+i/8] |= 1 << (i % 8). Байт bitmap по индексуi/8, бит по позицииi%8внутри байта. В область данных ничего не кладём. Иначе кодируем значение по типу колонки. - Заголовок в конце. Его поля пишутся последними, через
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
}
Шаги те же, что у сериализации, в обратную сторону.
- Заголовок. Сначала проверяем, что 12 байт заголовка вообще помещаются в срез. Потом читаем
xmin,xmax,infomask,nattrsпо смещениям отoff. Сверяемnattrsсо схемой: если кортеж записан под другую схему, лучше вернуть ошибку, чем выдать мусор. - Курсор. Данные начинаются сразу за заголовком и bitmap.
curэто где мы сейчас читаем, он ползёт вперёд по мере чтения колонок. Перед первым чтением проверяем, что и bitmap помещается в срез. - Значения.
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.