Раздел 10 · Databases

Своя СУБД на Go · урок 9: конкуренция, блокировки и дедлоки

senior~70 мин

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

Своя СУБД на Go · урок 9: конкуренция, блокировки и дедлоки

Кому это. В прошлом уроке у движка появились транзакции и версии кортежа. Удаление стало журналируемой пометкой Xmax, видимость считает txn.IsVisible по снимку, COMMIT форсит журнал, ROLLBACK откатывает по undo-log. Хэндлы db.Begin() уже открываются по несколько, но ничто не мешает двум из них переписать одну строку: последний писатель молча затрёт первого.

Зачем. Сегодня даём транзакциям честно конкурировать. Несколько хэндлов чередуют операторы, и мы проверим, что каждый видит свой snapshot isolation, а запись защитит пакет lock: эксклюзивный лок строки по строгому двухфазному протоколу, граф ожидания и детект дедлоков. Со стороны Go урок про карты-множества, обобщённую функцию и одно честное решение порта, которое многих удивит: на занятом локе горутина не засыпает. Почему, станет ясно, если заглянуть в урок 11, где движок встанет под sync.RWMutex.

Важно про объём. Фундамент готов с прошлого урока: версии, видимость, снимок, менеджер транзакций. Сегодня мы снимаем ограничение и достраиваем сбоку: новый пакет lock на 80 строк, ReadXmax в куче, две функции в операторах и отпускание локов в commit и abort. Тесты урока чередуют операторы нескольких транзакций в одной горутине: этого хватает, чтобы воспроизвести стабильный снимок, конфликт записи и дедлок. Настоящие горутины на соединение и sync.RWMutex вокруг движка придут в уроке 11.

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

wire/              протокол и TCP-сервер                   урок 11
engine/            каталог, binder, планировщик, Volcano   урок 6, +локи в DML сегодня
sql/               лексер, парсер, AST                     урок 5
vacuum/            сборка мёртвых версий                   урок 10
lock/              блокировки, граф ожидания               урок 9  ← мы здесь
txn/               xid, снимки, undo-log                   урок 8
dberr/             ошибки движка с кодом                   урок 6
wal/, recovery/    журнал и redo-проход                    урок 7
btree/             B+Tree индекс                           урок 4
heap/, tuple/      куча и кортеж                           урок 3
bufferpool/        кэш страниц, вытеснение clock           урок 2
page/, disk/       страница и файл                         урок 1

Пакет lock встаёт между транзакциями и кучей. Чтения он не трогает (они идут по снимку), но стоит на пути записи: прежде чем пометить строку удалённой или вставить новую версию, оператор берёт эксклюзивный лок строки.

Две стороны конкуренции

У безопасной конкуренции две стороны, и их важно не путать.

Первая это чтение. Под MVCC чтение вообще не берёт локов строк. Каждая транзакция работает над снимком, взятым при Begin, и видит версию строки, актуальную на тот момент. Параллельная транзакция может вставлять, менять, удалять и фиксировать, а наш читатель этого не заметит. Механика для этого уже написана, её осталось включить.

Вторая это запись. Тут одного MVCC мало: если две транзакции меняют одну строку, кто-то должен проиграть, иначе одно изменение потеряется. Решение в два слоя. Эксклюзивный лок строки сериализует писателей. А write-write конфликт, когда строку уже изменила транзакция, зафиксировавшаяся после моего снимка, ловится проверкой Xmax.

И есть третья сторона, которой у TS-движка не было вовсе: физическая. Логические транзакции живут над структурами в памяти: картами менеджера транзакций, кадрами пула, буфером журнала. Когда в уроке 11 к движку придут горутины, эти структуры начнут трогать одновременно. Логическую изоляцию дают снимки и локи строк, физическую целостность даст sync.RWMutex. Это разные уровни: логический заводим сегодня, физический в финале.

Снимок наконец наполняется

Начнём с приятного: чтобы заработала изоляция снимков, в txn менять нечего. Вспомни capture из прошлого урока:

// capture берёт снимок: активны все открытые кроме самой себя, Xmax это self.
func (m *Manager) capture(self uint32) Snapshot {
	snap := Snapshot{Xmin: self, Xmax: self, Active: make(map[uint32]bool, len(m.active))}
	for xid := range m.active {
		if xid != self {
			snap.Active[xid] = true
			snap.Xmin = min(snap.Xmin, xid)
		}
	}
	return snap
}

Он всегда собирал Active из всех открытых транзакций. Тесты прошлого урока держали по одной транзакции за раз, поэтому множество оставалось пустым. Стоит открыть две одновременно, и снимок второй честно запишет первую в Active. А CommittedAsOf это уже учитывает: !snap.Active[x] в его условии и есть изоляция. Даже если x уже зафиксировалась, но на момент моего снимка была активной, её изменения в мой снимок не пролезут. Чтение готово, осталось защитить запись.

Менеджер блокировок

Теперь сторона записи. Заведи пакет lock и файл lock/lock.go. Он на удивление компактный.

// Package lock это менеджер блокировок (урок 9): строгий 2PL. Транзакция берёт эксклюзивный
// лок строки при записи и держит до commit/abort. На конфликте строится граф ожидания:
// цикл это дедлок (запросивший жертва), иначе would-block.
//
// Как в эталоне, лок не блокирует горутину: конфликт без цикла валит только statement
// (dberr.LockWait), транзакция жива и её ребро ожидания остаётся в графе, поэтому взаимное
// ожидание при чередовании операторов ловится поиском цикла. Менеджер зовётся только под
// эксклюзивным локом движка, своей синхронизации ему не нужно.
package lock

import "ourdb/dberr"

// Manager держит владельцев ресурсов и граф ожидания.
type Manager struct {
	owners   map[string]uint32
	held     map[uint32]map[string]bool
	waitsFor map[uint32]map[uint32]bool
}

// New создаёт пустой менеджер.
func New() *Manager {
	return &Manager{owners: map[string]uint32{}, held: map[uint32]map[string]bool{}, waitsFor: map[uint32]map[uint32]bool{}}
}

// Acquire берёт эксклюзивный лок. Повторный захват владельцем это no-op.
func (m *Manager) Acquire(xid uint32, resource string) error {
	owner, taken := m.owners[resource]
	if !taken || owner == xid {
		m.owners[resource] = xid
		addTo(m.held, xid, resource)
		return nil
	}
	addTo(m.waitsFor, xid, owner)
	if m.hasCycle(xid) {
		delete(m.waitsFor[xid], owner)
		return dberr.New(dberr.Deadlock, "deadlock: транзакция %d откатывается (ждала %d)", xid, owner)
	}
	return dberr.New(dberr.LockWait, "ресурс %s занят транзакцией %d", resource, owner)
}

// Release освобождает все локи и рёбра ожидания транзакции (на commit/abort).
func (m *Manager) Release(xid uint32) {
	for resource := range m.held[xid] {
		if m.owners[resource] == xid {
			delete(m.owners, resource)
		}
	}
	delete(m.held, xid)
	delete(m.waitsFor, xid)
	for _, targets := range m.waitsFor {
		delete(targets, xid)
	}
}

// hasCycle ищет в глубину путь из start обратно в start.
func (m *Manager) hasCycle(start uint32) bool {
	visited := map[uint32]bool{}
	stack := []uint32{start}
	for len(stack) > 0 {
		node := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		for next := range m.waitsFor[node] {
			if next == start {
				return true
			}
			if !visited[next] {
				visited[next] = true
				stack = append(stack, next)
			}
		}
	}
	return false
}

func addTo[K comparable, V comparable](m map[K]map[V]bool, k K, v V) {
	if m[k] == nil {
		m[k] = map[V]bool{}
	}
	m[k][v] = true
}

Три структуры данных. owners отвечает на вопрос “кто держит ресурс”. held это обратный индекс “чем владеет транзакция”, чтобы Release отпустил всё разом. waitsFor это граф ожидания: ребро A -> B значит “A ждёт ресурс, занятый B”.

Acquire это вся логика. Свободный ресурс или свой же повторный захват это успех. Чужой ресурс это конфликт: добавляем ребро ожидания и ищем цикл. Цикл значит дедлок, запросившая транзакция становится жертвой: ребро убираем, чтобы граф не врал после её отката, и отдаём dberr.Deadlock (40P01). Конфликт без цикла это dberr.LockWait (55P03): ресурс занят, взаимного ожидания нет.

Посмотри на Go-сторону. Множества тут это map[T]bool: Go не завёл отдельного типа множества, и карта с булевым значением его заменяет. Чтение отсутствующего ключа даёт false, поэтому visited[next] не требует проверки наличия. delete(m.waitsFor[xid], owner) безопасен, даже если внутренней карты нет: delete из nil-карты в Go ничего не делает. А вот запись в nil-карту паникует, поэтому вставка идёт через addTo, который лениво заводит внутреннюю карту. Это обобщённая функция: [K comparable, V comparable] говорит, что ключи обеих карт должны поддерживать ==. Одна функция обслуживает и held (uint32 -> string), и waitsFor (uint32 -> uint32). Поиск в глубину идёт явным стеком на срезе, без рекурсии: stack[:len(stack)-1] это pop.

Коды конкуренции

Менеджер блокировок возвращает две ошибки, а оператор записи третью. Коды для них лежат в dberr/dberr.go рядом с Engine и Uniqueness из урока 6 (на этапах пакет дан готовым), но до сегодняшнего дня их никто не возвращал:

const (
	Engine        Code = "42000" // анализ, план, исполнение
	Uniqueness    Code = "23505" // дубликат PRIMARY KEY
	Serialization Code = "40001" // write-write конфликт под snapshot isolation
	Deadlock      Code = "40P01" // цикл в графе ожидания, запросивший это жертва
	LockWait      Code = "55P03" // лок занят без цикла: падает statement, транзакция жива
)

У TS-трека это три подкласса EngineError, и тест проверяет класс через instanceof. Здесь тест спрашивает dberr.CodeOf(err) == dberr.Deadlock. Коды взяты из SQLSTATE Postgres: в уроке 11 сессия протокола отдаст их клиенту как есть, и клиент, знакомый с Postgres, поймёт их без словаря.

Почему горутина не засыпает на локе

Тут остановимся. TS-движок однопоточный, блокировать там некого, поэтому LockWaitError просто валит текущий оператор. Логично ожидать, что Go-порт, где горутины есть, сделает по-взрослому: занятый лок усыпит горутину до COMMIT владельца, как это делает Postgres. Эталон этого сознательно не делает: Acquire возвращает 55P03 сразу, ровно как TS. Причины две, и вторая важнее.

Первая: тесты. Урок 9 TS-трека проверяет поведение чередованием операторов в одном потоке: A берёт строку, B получает “занято”, A фиксирует, B откатывается. С засыпающим локом этот тест повис бы навсегда: B ждёт A, а A не может сделать следующий шаг, потому что тест крутится в той же горутине и сам ждёт B. Порт держит те же тесты, что и TS, поэтому и поведение то же.

Вторая: мьютекс движка. Смотри комментарий пакета: менеджер зовётся только под эксклюзивным локом движка. Сегодня движок однопоточный, но в уроке 11 он встанет под sync.RWMutex, и любая мутация пойдёт под db.mu.Lock(). Представь: UPDATE берёт db.mu.Lock(), доходит до Acquire, а тот находит строку занятой. Если бы Acquire усыпил горутину, она спала бы, держа db.mu. Владелец строки, чтобы сделать COMMIT и отпустить её, сам должен взять db.mu.Lock(), а мьютекс занят спящим. Получился бы дедлок, который граф ожидания не видит: он не на строках, а на мьютексе Go. Весь движок встал бы.

Чтобы ждать честно, спящий обязан отпустить мьютекс движка на время сна и взять его обратно при пробуждении. В стандартной библиотеке ровно для этого есть sync.Cond: cond.Wait() атомарно отпускает связанный мьютекс, спит до Broadcast и снова его берёт. А проснувшись, оператор обязан перепроверить и лок, и Xmax: пока он спал, мир изменился. Это хорошее упражнение со звёздочкой, оно в конце урока. Базовая линия держит паритет с TS: 55P03 это “сейчас не вышло, попробуй позже или откатись”, а 40P01 это “так не выйдет никогда, откатываю тебя”.

Операторы берут локи

Контекст транзакции получает менеджер блокировок. В engine/operators.go:

type TxnContext struct {
	Xid      uint32
	Snapshot txn.Snapshot
	Status   *txn.CommitStatus
	Locks    *lock.Manager
}

func lockKey(relationID uint32, rid heap.Rid) string {
	return fmt.Sprintf("%d:%d:%d", relationID, rid.PageID, rid.Slot)
}

// lockAndCheckWrite берёт эксклюзивный лок строки и ловит write-write конфликт: xmax уже
// выставлен другой транзакцией, это first-updater-wins.
func lockAndCheckWrite(h *heap.File, ctx TxnContext, relationID uint32, rid heap.Rid) error {
	if err := ctx.Locks.Acquire(ctx.Xid, lockKey(relationID, rid)); err != nil {
		return err
	}
	xmax, err := h.ReadXmax(rid)
	if err != nil {
		return err
	}
	if xmax != 0 && xmax != ctx.Xid {
		return dberr.New(dberr.Serialization, "строка %s изменена другой транзакцией", lockKey(relationID, rid))
	}
	return nil
}

Лок берётся на конкретную строку. RID это страница плюс слот, но один и тот же RID в разных таблицах это разные строки, поэтому в ключ входит ещё relationID. Ключ строковый ради простоты и паритета с TS; структура struct{ rel, page uint32; slot uint16 } тоже подошла бы ключом карты (она comparable) и не выделяла бы память на Sprintf, это приятная оптимизация на потом.

Порядок важен: сначала Acquire. Если лок занят, падаем сразу, не дойдя до Xmax. Если взят, читаем Xmax из кучи. 0 значит строку никто не удалял. ctx.Xid значит удалил я сам в этой же транзакции. А чужой Xmax означает, что строку изменила транзакция, которой нет в моём снимке: потерянное обновление, и мы отдаём 40001.

Почему проверка корректна именно так? Удаление и обновление работают только над видимыми строками, скан их уже отфильтровал. Видимая строка ещё не удалена видимым мне образом. Если пометку поставила ещё открытая транзакция, она держит лок строки, и мы упали бы на Acquire раньше. Откатившаяся вернула бы Xmax в 0. Значит, до проверки доходит только пометка транзакции, которая зафиксировалась после моего снимка и отпустила лок. Это ровно first-updater-wins.

Дальше расставляем вызовы по операторам из engine/dml.go. Вставка берёт лок на новую строку сразу после записи в кучу: новый RID никто не держит, но лок не даст его тронуть до нашего COMMIT.

func (t target) insertVersion(ctx TxnContext, values []tuple.Value) error {
	rid, err := t.heap.Insert(tuple.Tuple{Values: values}, ctx.Xid)
	if err != nil {
		return err
	}
	if err := ctx.Locks.Acquire(ctx.Xid, lockKey(t.relationID, rid)); err != nil {
		return err
	}
	return t.maintainIndex(values, rid)
}

func newDelete(child Operator, t target, ctx TxnContext) Operator {
	return &dml{child: child, run: func() (int, error) {
		rows, err := drain(child)
		if err != nil {
			return 0, err
		}
		// конфликты до любой мутации, чтобы statement был атомарным
		for _, r := range rows {
			if err := lockAndCheckWrite(t.heap, ctx, t.relationID, *r.Rid); err != nil {
				return 0, err
			}
		}
		for _, r := range rows {
			if err := t.heap.MarkDeleted(*r.Rid, ctx.Xid); err != nil {
				return 0, err
			}
		}
		return len(rows), nil
	}}
}

Удаление проходит строки дважды: сначала ловит все конфликты, потом мутирует. Так оператор атомарен: либо помечаются все строки, либо ни одна. Взятые до ошибки локи остаются у транзакции: по строгому 2PL их отпустит только конец транзакции. UPDATE устроен так же: вычисляет новые значения, ловит конфликты, проверяет уникальность новых ключей (checkNewKeys) и только потом для каждой строки делает MarkDeleted плюс insertVersion.

target это маленькая структура-значение с кучей, relationID, индексом и колонкой: планировщик собирает её из каталога и передаёт операторам. Сканы (SeqScan, IndexScan) локов не берут: чтение идёт по снимку.

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

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

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