Раздел 10 · Databases

Своя СУБД на Haskell · урок 2: буферный пул

senior~60 мин

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

Своя СУБД на Haskell · урок 2: буферный пул

Кому это. Ты прошёл урок 1: у тебя есть Page поверх ForeignPtr и DiskManager, который двигает 8192 байта между файлом и памятью. Сегодня между движком и диском встанет кэш. Тот же урок есть в TS-треке, и алгоритм здесь дословно тот же. Отличается то, как Haskell держит изменяемое состояние: записи кадров неизменяемы и лежат в Seq за IORef, каждое изменение кадра это явная функция Frame -> Frame, а сами страницы остаются общими изменяемыми буферами.

Важно про код. Как и в прошлый раз, ты пишешь реализацию с нуля, а каждый листинг взят из эталона на Haskell. Буферный пул это первый слой, где появляется нетривиальная политика (вытеснение), и пройти её пальцами полезнее, чем прочитать. Структуры из containers (Seq, IntMap) разобраны в уроке про containers и vector, освежи его, если Seq.adjust' выглядит незнакомо.

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

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

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

Зачем вообще кэшировать страницы

Представь seq scan по таблице из урока 3: движок читает страницу 0, потом 1, потом 2. Без кэша каждое чтение это системный вызов и поход на устройство. Но нагрузка почти никогда не равномерна. У данных есть локальность: одни страницы (корень индекса, верхушка популярной таблицы) читаются постоянно, другие почти никогда. Если горячую страницу прочитать с диска один раз и дальше держать в памяти, тысячи следующих обращений к ней станут бесплатными.

Это и есть работа буферного пула: кэш фиксированного числа страниц. Когда движок просит страницу, пул сначала смотрит, нет ли её уже в памяти (это попадание), и только если нет, идёт на диск (промах). Память не бесконечна, поэтому кадров ограниченное число. Когда все заняты и нужно место под новую страницу, пул выбирает жертву и вытесняет её. Вся сложность в одном вопросе: какую страницу вытеснить, чтобы не выкинуть ту, что вот-вот понадобится снова.

Pin и dirty: два флага кадра

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

Pin это счётчик, а не замок. Закрепление говорит одно: “эту страницу сейчас используют, не вытесняй”. Несколько читателей могут держать pin на одну страницу одновременно, поэтому это число, а не флаг. Закончив, каждый делает unpin, и когда счётчик падает до нуля, страница снова кандидат на вытеснение. Не путай pin с блокировками транзакций из урока 9: pin живёт, пока идёт работа с буфером, и не имеет отношения к изоляции.

Второй флаг кадра это dirty. Кадр грязный, если страницу в памяти изменили и она разошлась с диском. Чистый кадр можно выбросить молча. Грязный перед вытеснением надо записать. Поэтому unpin принимает флаг: тот, кто менял страницу, сообщает об этом, отпуская её.

Clock: дешёвое приближение LRU

Идеальная политика вытеснения это выкинуть страницу, к которой дольше всего не обращались. Это LRU. Его проблема в цене: чтобы знать порядок, надо при каждом обращении двигать страницу в голову списка. Это изменение общей структуры на каждое попадание.

Clock приближает LRU почти бесплатно. Кадры расставлены по кругу, по ним ходит стрелка. У каждого кадра один ссылочный бит. Обращение к странице просто поднимает бит, списки двигать не надо. Когда нужна жертва, стрелка идёт по кругу:

  • кадр запинен, пропускаем, его трогать нельзя;
  • у кадра поднят ref-бит, он получает второй шанс: гасим бит и идём дальше;
  • первый кадр с погашенным битом и без pin это жертва.

За один полный проход стрелка гасит все поднятые биты, поэтому за два прохода жертва найдётся, если хоть один кадр не запинен. Страница, к которой обращались недавно, переживёт хотя бы один проход. Ровно эту схему, clock-sweep, использует buffer manager в Postgres.

Поиграй с этим вживую. Кнопки сверху запрашивают страницу (fetch и сразу unpin, типичный паттерн чтения). Следи за стрелкой ▼, ref-битами и счётчиками внизу. Запроси одну страницу дважды и посмотри, как поднятый бит спасает её. Запинь кадр и убедись, что стрелка его обходит. Пометь кадр грязным и поймай момент, когда его перед вытеснением пишут на диск.

Если повозился, ты увидел три морали урока. Повторное обращение поднимает ref-бит, и страница получает второй шанс. Запиненный кадр стрелка пропускает, сколько ни проси новые страницы. Грязную жертву перед вытеснением сбрасывают на диск, а чистую выкидывают молча. Теперь запрограммируем.

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

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

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