Внутренности PostgreSQL: карта видимости

27 марта 2023

Ранее в постах серии «Внутренности PostgreSQL» несколько раз упоминалось нечто под названием visibility map, или карта видимости (случай один, случай два и далее по ссылкам). Однако не сообщалось, что это за штука такая и почему она важна. Пришло время заполнить данный пробел.

Карта видимости (VM) представляет собой большую битовую маску. Для каждой страницы в куче она хранит два бита. Первый бит назвается all-visible, а второй бит — all-frozen. Для индексов VM не строится. VM хранится отдельно от кучи. Например, если таблица имеет relfilenode равный 16393, то VM будет лежать рядом, в файле с именем 16393_vm. В терминологии PostgreSQL говорят, что VM хранится «в отдельном форке».

Страницы карты видимости представляют собой обычные страницы с заголовком PageHeaderData. Работа с ними осуществляется через разделяемые буферы, как и с обычными страницами. Разница лишь в том, что в VM за заголовком лежит битовая маска, а не кортежи с массивом ItemIdData.

Когда установлен бит all-visible, это значит, что все кортежи соответствующей страницы видны всем транзакциям. В частности, на странице нет мертвых кортежей, а значит VACUUM не обязательно в нее заходить. Установленный бит all-frozen означает, что страница содержит только замороженные кортежи. VACUUM не нужно заходить в эту страницу даже для предотвращения XID wraparound. Разумеется, бит all-frozen может быть установлен, только если установлен бит all-visible.

Бит all-visible имеет второе применение. Если во время index only scan мы видим, что кортеж находится на странице с битом all-visible, то его видимость для текущей транзакции можно не проверять. Иначе СУБД вынуждена обратиться к куче, чтобы получить состояние кортежа и проверить его видимость. Да, когда в плане запроса вы видите Index Only Scan, это еще не значит, что запрос будет обращаться только к индексу. Как бы контринтуитивно это не казалось.

Когда бит установлен, мы точно знаем, что такое-то условие истинно. Иначе мы не знаем, истинно оно или нет. В исходниках это называется «консервативной» структурой.

При установке бита all-visible в WAL пишется запись XLOG_HEAP2_VISIBLE, а при установке бита all-frozen — запись XLOG_HEAP2_FREEZE_PAGE. За эти записи отвечает resource manager кучи Heap2. Сброс битов VM отдельно не логируется. Вызывающая сторона должна убедиться, что при восстановлении после сбоя WAL будет содержать записи, проигрывание которых приведет карту видимости в непротиворечивое состояние.

В случае установки бита обновляется LSN соответствующей страницы VM. Это гарантирует, что страница VM не будет вытеснена раньше WAL-записи об изменениях на странице. Но при сбросе бита LSN страницы не обновляется. Худшее, что может случиться — страница VM с обнуленным битом попадет на диск раньше WAL-записи, которая привела к обновлению VM. Это не приведет к нарушению корректности VM в силу ее «консервативности». Обнулять биты VM всегда безопасно.

Реализация VM находится в visibilitymap.c / visibilitymap.h. Доступен ряд функций: visibilitymap_clear(), visibilitymap_pin(), visibilitymap_set() и другие. Их реализация довольно простая.

Вот, например, visibilitymap_clear():

/*
 * visibilitymap_clear() - сбросить заданные биты в VM.
 *
 * Перед вызовом нужно сделать visibilitymap_pin().
 * Эта функция не делает дискового I/O.
 * Возвращает true, если мы что-то поменяли в VM, иначе - false.
 */

bool
visibilitymap_clear(
    Relation rel,        /* используется только для отладки */
    BlockNumber heapBlk, /* номер страницы в куче */
    Buffer vmbuf,        /* страница VM */
    uint8 flags          /* какие биты сбрасываем */
) {
    BlockNumber mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk);
    int         mapByte = HEAPBLK_TO_MAPBYTE(heapBlk);
    int         mapOffset = HEAPBLK_TO_OFFSET(heapBlk);
    uint8       mask = flags << mapOffset;
    char       *map;
    bool        cleared = false;

    /* ... тут идет ряд проверок, пропущено ... */

    /* захватываем страницу VM */
    LockBuffer(vmbuf, BUFFER_LOCK_EXCLUSIVE);

    /* получаем содержимое страницы */
    map = PageGetContents(BufferGetPage(vmbuf));

    /* сбрасываем указанные биты, если они установлены */
    if (map[mapByte] & mask) {
        map[mapByte] &= ~mask;

        /* страница теперь грязная */
        MarkBufferDirty(vmbuf);
        cleared = true;
    }

    /* отпускаем страницу VM */
    LockBuffer(vmbuf, BUFFER_LOCK_UNLOCK);
    return cleared;
}

Чтение из VM типично выглядит как-то так:

Oid         relid = /* ... */;
int64       blkno = /* ... */
int32       mapbits;
Relation    rel;
Buffer      vmbuffer = InvalidBuffer;
bool        all_visible, all_frozen;

rel = relation_open(relid, AccessShareLock);
mapbits = (int32) visibilitymap_get_status(rel, blkno, &vmbuffer);
if (vmbuffer != InvalidBuffer)
    ReleaseBuffer(vmbuffer);

all_visible = (mapbits & VISIBILITYMAP_ALL_VISIBLE) != 0);
all_frozen = (mapbits & VISIBILITYMAP_ALL_FROZEN) != 0);

relation_close(rel, AccessShareLock);

Заметьте, что visibilitymap_get_status() сама найдет правильную страницу и закрепит ее, если последним аргументом передать указатель на InvalidBuffer. Страницу нужно не забыть открепить при помощи ReleaseBuffer(). Также заметьте, что функция не берет никаких блокировок на содержимое страницы. Ответственность за предотвращение возможных состояний гонки лежит на вызывающей стороне.

Полный пример вы найдете в pg_visibility.c из расширения pg_visibility, а пример посложнее — в verify_heapam.c из расширения amcheck.

Типичная запись в VM происходит так:

Buffer      vmbuffer = /* ... */;

/* Читаем страницу VM и закрепляем ее в shared buffers */
visibilitymap_pin(reln, blkno, &vmbuffer);

/* Выставляем биты VM; здесь дискового I/O не происходит */
visibilitymap_set(reln, blkno, InvalidBuffer, lsn, vmbuffer,
                  xlrec->snapshotConflictHorizon, xlrec->flags);

/* Отпускаем закрепленный буфер */
ReleaseBuffer(vmbuffer);

Но есть нюанс, и даже несколько. Чтобы писать в VM, необходимо держать лок на соответствующей странице хипа. Кроме того, перед выставлением битов VM у страницы хипа в pd_flags нужно проставить PD_ALL_VISIBLE. Заметьте, что visibilitymap_set() берет и затем отпускает все необходимые блокировки.

Полный пример записи в VM можно посмотреть в heap_xlog_visible() из файла heapam.c. Эта функция воспроизводит запись XLOG_HEAP2_VISIBLE из WAL.

Расширение pg_visibility позволяет DBA заглянуть внутрь VM и даже заставить СУБД перестроить ее. Заинтересованным читателям предлагается ознакомиться с этим расширением самостоятельно.

Дополнение: Внутренности PostgreSQL: карта свободного пространства

Метки: , , .


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