Внутренности PostgreSQL: кэш системного каталога

11 сентября 2023

Системный каталог, или просто каталог — это таблицы, в которых PostgreSQL хранит информацию обо всех остальных объектах, хранящихся в базе данных. К ним относятся таблицы, функции, триггеры, и т.д. Обращение к системному каталогу происходит часто, поэтому для него предусмотрен кэш. Давайте же разберемся, как этот кэш устроен.

Реализация кэша находится в следующих файлах:

  • syscache.{c,h} — реализация основных функций кэша. Функции оперируют с Datum’ами и HeapTuple’ами;
  • catcache.{c,h} — низкоуровневые функции, используемые syscache.c. Выделение и освобождение памяти реализовано здесь;
  • lsyscache.{c,h} — высокоуровневая обертка над syscache.c. Содержит функции «получить имя отношения по его Oid» и подобного рода;
  • inval.{c,h} — логика инвалидации кэша. Подробности см ниже по тексту;
  • sinvaladt.{c,h} — реализация кольцевого буфера в разделяемой памяти, через который рассылаются сообщения об инвалидации кэшей;
  • sinval.{c,h} — интерфейс коммуникации через sinvaladt.c. Содержит функции вроде «отправить / получить SharedInvalidationMessage»;

Коротко о главном:

  • Кэш является локальным для каждого процесса. Он живет в отдельном контексте памяти CacheMemoryContext;
  • Ключом являются Datum’ы, до четырех штук. Значением является кортеж. Если встречаются TOAST-аттрибуты, они распаковываются. См реализацию CatalogCacheCreateEntry();
  • Кэш является read-through. Чтение всегда осуществляется из кэша. Если данных не нашлось, кэш идет в таблицу. Поиск в таблице всегда осуществляется по индексу. См реализацию SearchCatCacheMiss();
  • Если и в таблице ничего не нашлось, SearchCatCacheMiss() записывает в кэш отрицательную запись, negative entry. В следующий раз при виде этой записи кэш не пойдет в таблицу;
  • Ограничений на размер кэша нет. Если palloc() не бросает исключений, продолжаем работу. См реализацию RehashCatCache();
  • Записи в кэше имеют счетчики ссылок. Запись освобождается при обнулении счетчика. См функцию ReleaseCatCache();
  • Процессы обмениваются сообщениями об инвалидации через разделяемую память. Делать это нужно далеко не на каждое изменение. Например, если исполняемая транзакция откатилась, то ничего инвалидировать не нужно;

В качестве примера обращения к кэшу рассмотрим такую функцию:

/* отрывок из lsyscache.c */

char *
get_rel_name(Oid relid)
{
    HeapTuple   tp;

    tp = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
    if (HeapTupleIsValid(tp))
    {
        Form_pg_class reltup = (Form_pg_class) GETSTRUCT(tp);
        char       *result;

        result = pstrdup(NameStr(reltup->relname));
        ReleaseSysCache(tp);
        return result;
    }
    else
        return NULL;
}

Функция определяет имя отношения по его Oid. Для этого она обращается к таблице pg_class через кэш. Если ничего не найдено, возвращается NULL. Иначе из кортежа достается relname, и при помощи pstrdup() создается копия строки в текущем контексте памяти. Затем вызовом ReleaseSysCache() мы освобождаем полученную запись из кэша. То есть, уменьшаем ее счетчик ссылок. После чего возвращаем копию строки.

Основные функции, имеющие отношение к инвалидации, перечислены в inval.h. Инвалидация устроена довольно хитро.

Рассмотрим сценарий, когда СУБД исполняет команду и изменяет или удаляет кортеж. После чего вновь идет обращение к этому кортежу, в рамках исполнения все той же команды. СУБД не может видеть обновленные данные, ведь команда еще не завершилась. По этой причине inval.c запоминает изменения, однако применяет их к кэшу лишь при переходе к следующей команде. О переходе нужно сообщить вызовом CommandEndInvalidationMessages().

Это касается изменений в локальном кэше процесса. Другие процессы имеют свои копии кэша с другими значениями. Когда процесс изменяет системный каталог, он должен как-то просигнализировать об этом остальным процессам. Но сделать это можно лишь в случае, если транзакция завершилась успешно. Соответствующий код находится в AtEOXact_Inval().

Изменения передаются через сообщения с типом SharedInvalidationMessage. Они отправляются при помощи SendSharedInvalidMessages(), которая в свою очередь является лишь оберткой над SIInsertDataEntries(). Сообщения складываются в кольцевой буфер в разделяемой памяти. Его размер — MAXNUMMESSAGES, или 4096 сообщений.

Другие процессы забирают сообщения при помощи AcceptInvalidationMessages(). Например, при начале новой транзакции стек вызовов будет следующим:

StartTransaction() - xact.c
    AtStart_Cache() - xact.c
        AcceptInvalidationMessages() - inval.c
            ReceiveSharedInvalidMessages() - sinval.c
                SIGetDataEntries() - sinvaladt.c

Код в sinvaladt.с написан таким образом, что он знает, какие процессы какие сообщения уже видели. Эта информация также хранится в разделяемой памяти, в массиве из структур с типом ProcState.

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

Ответ содержится в комментариях к файлу sinvaladt.c и особенно в реализации SICleanupQueue(). Бэкендам, которые отстали настолько, что из-за них нельзя добавить новое сообщение в буфер, в ProcState проставляется флаг resetState. После чего старые сообщения перезаписываются. Когда отставший бэкенд придет за новыми сообщениями, SIGetDataEntries() увидит resetState и вернет отрицательное число. Это сигнализирует бэкенду о том, что он должен сбросить свой кэш целиком.

Однако полный сброс кэша является крайней мерой, и ее хотелось бы избегать. Поэтому существует еще один механизм. Когда SICleanupQueue() видит бэкенд, отстающий на MAXNUMMESSAGES / 2 сообщений, функция отправляет ему прерывание PROCSIG_CATCHUP_INTERRUPT. Реализовано это проставлением соответствующего флага в состоянии бэкенда, которое находится в разделяемой памяти, с последующей посылкой процессу сигнала SIGUSR1.

Обработчик сигнала регистрируется в StartupProcessMain(). При его вызове проверяются флаги в разделяемой памяти и вызывается соответствующий обработчик прерывания. В данном случае это HandleCatchupInterrupt(). Функция присваивает true глобальной переменной catchupInterruptPending и выставляет защелку MyLatch. Таким образом, если процесс спит на WaitLatch(), он будет разбужен. Большего в обработчике сигнала мы сделать не можем. Причины объяснены в докладе Thomas Munro под называнием IPC in PostgreSQL. Если коротко, то это чревато нетривиальными ошибками.

Проверка catchupInterruptPending осуществляется в ProcessClientReadInterrupt(). Вызовы функции стратежно расставлены по коду в тех местах, где происходит чтение новых данных от пользователя. При получении сигнала процесс может находиться в системном вызове. В этом случае системный вызов возвращает управление с соответствующим кодом возврата.

Пример стека вызовов:

PostgresMain() - postgres.c
    ReadCommand() - postgres.c
        SocketBackend() - postgres.c
            pq_getbyte() - pqcomm.c
                pq_recvbuf() - pqcomm.c
                    secure_read() - be-secure.c
                        ProcessClientReadInterrupt() - postgres.c

Если catchupInterruptPending = true, функция вызывает ProcessCatchupInterrupt(), который вызывает уже знакомую нам AcceptInvalidationMessages(). Оттуда управление передается в ReceiveSharedInvalidMessages(), где происходит инвалидация кэша и сбрасывается catchupInterruptPending. Затем вызывается SICleanupQueue(), чтобы освободить ставшие теперь ненужными сообщения в кольцевом буфере.

Заметьте, что именно SICleanupQueue() был инициатором посылки SIGUSR1, из-за которого все началось. При повторном вызове функции может быть обнаружен следующий сильно отставший бэкенд, которому также следует послать сигнал. Выходит, что отставшие бэкенды передают друг другу эстафету.

Как видите, кэш системного каталога оказался не такой уж простой подсистемой. Тем не менее, в нем можно разобраться и описать с точки зрения архитектуры в рамках одного поста. Читателям, заинтересованных в более низкоуровневых деталях реализации, рекомендуется ознакомиться непосредственно с кодом.

Метки: , , .


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