Внутренности PostgreSQL: разделяемые буферы

21 ноября 2022

Ранее мы установили (часть один, часть два) что PostgreSQL хранит все данные в страницах, размер которых по умолчанию равен 8 Кб. Однако напрямую читать и писать страницы с/на диск было бы дороговато. Поэтому используется кэш в разделяемой памяти. Он называется разделяемые буферы, или shared buffers. Попробуем разобраться, как именно устроен этот кэш.

Интересующие нас исходники находятся в src/backend/storage/buffer:

  • README — высокоуровневое описание работы разделяемых буферов;
  • buf_init.c — код инициализации буферов;
  • buf_table.c — логика отображения BufferTag на индекс буфера;
  • bufmgr.c — основной код;
  • freelist.c — логика поиска следующего свободного буфера;
  • localbuf.c — код, отвечающий за локальные буферы;

Вместе этот код иногда называют менеджер буферов, или buffer manager. Учтите, что это не какой-то выделенный процесс СУБД.

Локальные буферы (localbuf.c) используются для временных таблиц. Поскольку временные таблицы не видны за пределами своей сессии, их буферы хранятся в обычных MemoryContext’ах, а не в разделяемой памяти.

Часть разделяемой памяти может быть выделена под кольцевой буфер, или buffer ring. Допустим, пользователь решает последовательно читать данные из большой таблицы. В наивной реализации вроде LRU это вымывало бы весь кэш. Притом, что страницы наверняка будут использованы лишь один раз. Поэтому для подобных операций выделяется небольшая часть кэша, кольцевой буфер. Когда место в нем заканчивается, операция переиспользует страницы своего кольцевого буфера. Это предотвращает вымывание кэша.

Код работы с кольцевыми буферами находится во freelist.c. Объект доступа к кольцевому буферу в коде называется BufferAccessStrategy. (Нет, это не какой-то интерфейс для работы с любым буфером, кольцевым или обычным. Да, название могло бы быть лучше.)

Рассмотрим функцию InitBufferPool() из buf_init.c:

/* ... */

BufferDescriptors = (BufferDescPadded *)
    ShmemInitStruct("Buffer Descriptors",
                    NBuffers * sizeof(BufferDescPadded),
                    &foundDescs);

BufferBlocks = (char *)
    ShmemInitStruct("Buffer Blocks",
                    NBuffers * (Size) BLCKSZ, &foundBufs);
/* ... */

Работа с разделяемой памятью к этому моменту нам уже знакома. Здесь она осуществляется практически так же, как это делается в коде расширений.

BufferBlocks хранит непосредственно содержимое закэшированных страниц. BufferDescriptors — это массив «дескрипторов». В коде дескрипторы также называются заголовками буферов (buffer header). По сути, это мета-информация о соответствующих страницах. Переменная NBuffers хранит количество разделяемых буферов. Это число задается параметром конфигурации shared_buffers.

Что представляет собой дескриптор буфера? Ответ находится в buf_internals.h:

/* Идентификатор страницы */
typedef struct buftag
{
    /* Идентификатор отношения */
    RelFileNode rnode;

    /* Идентификатор "форка": main, free space map, visibility map */
    ForkNumber  forkNum;

    /* Номер страницы в отношении */
    BlockNumber blockNum;
} BufferTag;

typedef struct BufferDesc
{
    /* Идентификатор страницы */
    BufferTag   tag;

    /* Индекс буфера в BufferBlocks, начиная с нуля */
    int         buf_id;

    /* Состояние буфера, о нем ниже */
    pg_atomic_uint32 state;

    /* PGPROC.pgprocno ожидающего бэкенда */
    int         wait_backend_pgprocno;

    /* Индекс следующего свободного буфера */
    int         freeNext;

    /* Лок на содержимое буфера */
    LWLock      content_lock;
} BufferDesc;

/* Как BufferDesc, только с выравниванием */
typedef union BufferDescPadded
{
    BufferDesc  bufferdesc;
    char        pad[BUFFERDESC_PAD_TO_SIZE];
} BufferDescPadded;

Особый интерес представляет state. Это 32-х битная атомарная переменная. Она позволяет получать и изменять информацию о буфере, не блокируя его или его дескриптор. Биты поделены таким образом:

  • 18 бит выделено под счетчик ссылок (refcount);
  • 4 бита — количество использований (usage count);
  • Остальные 10 бит отведены под флаги;

Когда процесс начинает работать с буфером, он увеличивает refcount на один. По окончании работы счетчик уменьшается на один. Если значение refcount больше нуля, говорят, что буфер закреплен, или pinned. Закрепленный буфер не может быть вытеснен.

Помимо глобальных счетчиков ссылок есть еще и локальные счетчики процесса. В коде это называется PrivateRefCount. Код может быть написан таким образом, что один и тот же буфер закрепляется им несколько раз. Это может привести к переполнению refcount. Поэтому, когда процесс видит, что ранее он уже закреплял буфер, он делает инкремент локального refcount, а не глобального. Подробности можно найти в реализации PinBuffer() и UnpinBuffer().

Дескриптор можно заблокировать, проставив ему флаг BM_LOCKED. Заметьте, что блокировка дескриптора и блокировка буфера — это не одно и то же. Если буфер содержит изменения, которые не были записаны на диск, в его дескрипторе проставляется флаг BM_DIRTY. Грязный и незакрепленный буфер может быть переиспользован, но его содержимое нужно записать на диск. Если буфер чистый, то его можно использовать сразу. Предусмотрены и другие флаги, но все их мы рассматривать не будем.

Важно! Как правило, грязная страница не может быть вытеснена на диск раньше WAL-записи о последних изменениях на этой странице. Исключением являются нежурналируемые таблицы.

Наконец, usage count говорит о том, как часто буфер использовался в прошлом. Для вытеснения буферов PostgreSQL использует алгоритм clock sweep. Идея заключается в следующем. Каждый раз, когда кто-то закрепляет буфер, usage count увеличивается на единицу, но максимум до BM_MAX_USAGE_COUNT, значение которого равно 5. Когда СУБД нужен новый буфер, сначала она проверяет список незанятых буферов (free list). Если в списке есть буфер, используется он. Иначе СУБД начинаете перебирать буферы по кругу, уменьшая usage count каждого незакрепленного буфера на единицу. Если usage count оказался равен нулю, то буфер может быть переиспользован. Качественно алгоритм похож на LRU, только с весами.

Содержимое грязных страниц может быть записано на диск фоновым процессом под названием background writer, или bgwriter. В упрощенном виде его реализация выглядит так:

int BgWriterDelay = 200; /* 200 мс */

void
BackgroundWriterMain(void)
{
    for (;;)
    {
        /* ... */

        /* Пишем грязные страницы. Этот вызов уходит в bufmgr.c */
        can_hibernate = BgBufferSync(&wb_context);

        /* ... */

        rc = WaitLatch(MyLatch, /* флаги */, BgWriterDelay, /* ... */);

        /* ... */
    }
}

Здесь мы видим новый примитив — защелку, или latch. Защелка может быть установлена при помощи SetLatch() и сброшена при помощи ResetLatch(). Еще есть WaitLatch(). Он ждет на сброшенной защелке до тех пор, пока ее кто-нибудь не установит, либо до истечения таймаута. Другими словами, защелки позволяют одному процессу просигнализировать второму о каком-то событии. Полную реализацию можно посмотреть в latch.c и latch.h. Здесь на защелках мы подробно задерживаться не будем.

Кто же может сигнализировать bgwriter, и почему бы ему просто не спать на pg_usleep? Это такая оптимизация. Если bgwriter видит, что он длительное время не делал ничего полезного, процесс засыпает на больший интервал времени, «уходит в гибернацию». Если в это время произойдет вызов StrategyGetBuffer(), он просигнализирует bgwriter, и выведет его из гибернации. Этим экономятся циклы CPU.

Реализация BgBufferSync() немного запутанная, но не сказать, что полна сюрпризов. Заинтересованным читателям предлагается ознакомиться с ней самостоятельно, в качестве упражнения. Попробуйте найти конкретное место в коде, где у записанной на диск страницы снимается флаг BM_DIRTY.

Это все, что я хотел рассказать в данном посте. Примите во внимание, что полная картина несколько сложнее. Если вы будете модифицировать код разделяемых буферов, рекомендую перечитать README, поскольку правила работы с ними нетривиальны. Особого внимания заслуживает порядок взятия блокировок. Следует также отметить, что помимо bgwriter грязные страницы может писать еще один процесс, checkpointer. Однако тема контрольных точек и восстановления системы после сбоев уж точно выходит за рамки статьи.

Дополнение: В продолжение темы см посты Внутренности PostgreSQL: журнал предзаписи (WAL), Внутренности PostgreSQL: XID wraparound и Внутренности PostgreSQL: кэш системного каталога.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.