Внутренности PostgreSQL: ProcArray и CLOG

21 сентября 2022

Благодаря статье Внутренности PostgreSQL: страницы и кортежи мы узнали, что каждый кортеж в PostgreSQL хранит t_xmin и t_xmax — XIDы транзакций создавшей и удалившей кортеж соответственно. Зная XID текущей транзакции, ее уровень изоляции, а также состояние транзакций t_xmin и t_xmax, СУБД способна определить, виден ли кортеж в текущей транзакции или нет. Узнать состояние транзакции по ее XID можно при помощи ProcArray и CLOG.

Действующие лица

ProcArray, строго говоря, не имеет прямого отношения к транзакциям. Это структура, хранящая состояние процессов СУБД. Однако зная, что заданный процесс в настоящее время исполняет транзакцию с таким-то XID, можно определить, что транзакция еще не завершилась.

Commit Log, или CLOG — это структура, непосредственно хранящая состояние транзакций. Если транзакция не исполняется, то она могла завершиться либо успешно, либо неуспешно. Какой из этих вариантов имел место быть нельзя определить по ProcArray, но можно по CLOG. Вопреки названию, физически CLOG представляет собой не журнал, а большой битовый массив.

Спрашивается, а зачем так сложно? Ведь можно было просто использовать для всего CLOG. Дело в том, что ProcArray всегда хранится в памяти. Обращение к нему является дешевой операцией. CLOG хранится преимущественно в памяти, но может вытесняться и на диск. Соответственно, обращение к нему считается дороже.

Рассмотрим ProcArray и CLOG по отдельности.

ProcArray

Реализация ProcArray находится в файлах procarray.c, procarray.h и proc.h.

Каждому процессу СУБД соответствует структура PGPROC. В ней много полей. Вот некоторые из наиболее интересных:

/*
 * Семафор процесса. Процесс спит на этом семафоре, когда пытается
 * взять LWLock, уже взятый другим процессом с LW_EXCLUSIVE.
 */

PGSemaphore sem;

/* XID исполняемой транзакции */
TransactionId xid;

/*
 * Наименьший XID исполняемой транзакции на момент
 * начала исполнения нашей транзакции.
 */

TransactionId xmin;

/* Идентификатор процесса */
int pid;

/*
 * Индекс этой структуры в массиве ProcGlobal->allProcs (см далее).
 * Это значение проставляется один раз в InitProcGlobal().
 */

int pgprocno;

/* Идентификатор бэкенда, если присвоен */
BackendId backendId;

/* Идентификатор базы данных, с которой работает бэкенд */
Oid databaseId;

/* Идентификатор роли, под которой работает бэкенд */
Oid roleId;

/* Идентификатор схемы, где бэкенд хранит временные таблицы */
Oid tempNamespaceId;

/* Истина, если это фоновый процесс (background worker) */
bool isBackgroundWorker;

/*
 * Информация о том, что в данный момент ожидает процесс.
 * Используется pg_stat_get_activity() и вьюхой pg_stat_activity.
 */

uint32 wait_event_info;

Массив из PGPROC хранится в ProcGlobal:

typedef struct PROC_HDR
{
    /* Массив из структур PGPROC */
    PGPROC    *allProcs;

    /* Длина массива allProcs */
    uint32    allProcCount;

    /* ... */
} PROC_HDR;

PROC_HDR *ProcGlobal;

Структура PROC_HDR хранит и так называемые отраженные (mirrored) поля:

/*
 * Массив отражающий PGPROC.xid для каждого
 * PGPROC из ProcArray (о нем ниже)
 */

TransactionId *xids;

/* Массив отражающий PGPROC.subxidStatus */
XidCacheStatus *subxidStates;

/* Массив отражающий PGPROC.statusFlags */
uint8 *statusFlags;

Это такая оптимизация. Код, которому нужно обойти все xids из ProcArray, может сделать это по ProcGlobal->xids. Кэш-линии процессора (cache lines) при этом используются более эффективно, чем при итерации по ProcGlobal->allProcs. С другой стороны, если требуется обратиться к нескольким полям из конкретного PGPROC, выгоднее делать это через ProcGlobal->allProcs. Подробности можно найти в комментариях к структуре PROC_HDR.

Может показаться, что ProcGlobal->allProcs — это и есть ProcArray. На самом деле, это лишь пул заранее аллоцированных PGPROC. Они переиспользуются СУБД в процессе работы.

Настоящий же ProcArray находится в файле procarray.c:

typedef struct ProcArrayStruct
{
    /* ... множество полей, которые в данный момент нам
           не очень интересны ... */


    /* индексы массива allProcs[] */
    int pgprocnos[FLEXIBLE_ARRAY_MEMBER];
} ProcArrayStruct;

/* ... */

static ProcArrayStruct *procArray;
static PGPROC *allProcs; /* копия ProcGlobal->allProcs */

Одна из причин разделения на ProcGlobal и ProcArray заключалась в получении более читаемого кода. Плюс к этому, массив procArray->pgprocnos сортируется по адресам PGPROC (точнее говоря, по pgprocno, что эквивалентно). Этим повышается производительность при последовательном сканировании состояний процессов. Заметьте, что procArray является внутренней переменной procarray.c, тогда как ProcGlobal используется по всему коду.

Fun fact! Вспомогательные процессы, такие, как bgwriter, имеют соответствующую им структуру PGPROC, но она не добавляется в ProcArray.

В procarray.c реализовано много полезных функций. Например, можно найти PGPROC по идентификатору процесса:

extern PGPROC *BackendPidGetProc(int pid);

Но в данный момент нам наиболее интересно то, что можно проверить состояние транзакции по ее XID:

extern bool TransactionIdIsInProgress(TransactionId xid);
extern bool TransactionIdIsActive(TransactionId xid);

TransactionIdIsActive() совсем простая. Она буквально итегируется по ProcArray в поисках транзакции с заданным XID. TransactionIdIsInProgress() учитывает подтранзакции, подготовленные транзакции (prepared transactions), а также транзакции исполняемые на мастере, когда функция вызывается на реплике.

Вопрос о том, как ProcArray работает с подтранзакциями и всяким таким в этой статье мы рассматривать не будем. Заинтересованным читателям предлагается разобраться в этом по коду, в качестве упражнения.

Если транзакция не исполняется, ProcArray не может сообщить, завершилась ли она успешно или неуспешно. На это может ответить только CLOG.

CLOG

Реализация CLOG находится в clog.c и clog.h. Как уже отмечалось, это битовая массив, хранящий состояние транзакций. Под каждую транзакцию используется два бита.

Возможные состояния транзакции следующие:

#define TRANSACTION_STATUS_IN_PROGRESS          0x00
#define TRANSACTION_STATUS_COMMITTED            0x01
#define TRANSACTION_STATUS_ABORTED              0x02
#define TRANSACTION_STATUS_SUB_COMMITTED        0x03

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

CLOG реализован поверх контейнера под названием SLRU. В рамках статьи мы не будем углубляться в датели его реализации. В двух словах, это LRU-кэш с интерфейсом уровня SimpleLruReadPage() / SimpleLruWritePage(). Правда, интерфейс у SLRU немного необычный. Например, SimpleLruReadPage() возвращает номер слота. Это индекс нескольких массивов, с которым нужно работать напрямую, например:

slotno = SimpleLruReadPage(MySlruCtl, pageno, /* more args */ );

MySlruCtl->shared->page_dirty[slotno] = true;
MySlruCtl->shared->page_status[slotno] = SLRU_PAGE_VALID;
strcpy(MySlruCtl->shared->page_buffer[slotno], "some data");

Единицей кэширования является страница размером BLCKSZ. SLRU держит часто используемые страницы в памяти. Те, что давно не использовались, вытесняются на диск. Страницы хранятся в файлах, называемых сегментами. По умолчанию PostgreSQL использует под CLOG кэш в 128 страниц, что позволяет хранить состояние около 4 млн транзакций. Если пользователь указал малое значение shared_buffers, будет использовано меньше страниц. Сегменты CLOG можно найти на диске в каталоге с именем «pg_xact».

Детали реализации SLRU вы найдете в slru.c и slru.h. Примеры его использования можно посмотреть в clog.c, либо в этом паче. SLRU используется и для многих задач, не только для CLOG. В этом легко убедиться, поискав по коду вызовы SimpleLruInit().

TransactionIdGetStatus() из clog.c возвращает состояние транзакции. Но напрямую с этой функцией обычно не работают. Файлы transam.c и transam.h содержат разные макросы и функции для работы с XID’ами. Наиболее важными для нас сейчас являются функции:

extern bool TransactionIdDidCommit(TransactionId transactionId);
extern bool TransactionIdDidAbort(TransactionId transactionId);

Обе являются обертками над TransactionIdGetStatus().

Тонкости работы с ProcArray и CLOG

Рассмотрим следующий код:

if(TransactionIdDidCommit(xid) || TransactionIdIsInProgress(xid)) {
    /* do something */
}

Выглядит неплохо. Но в действительности может произойти следующее:

  1. Транзакция xid исполняется;
  2. Вызов TransactionIdDidCommit() возвращает false;
  3. Параллельно транзакция commit’ится;
  4. TransactionIdIsInProgress() возвращает false;
  5. (false || false) дает нам false, хотя по идее должно было быть true;

То есть, имеем гонку (race condition). В зависимости от того, что за код у нас в if-блоке, это может быть или не быть проблемой.

Если поменять вызовы местами:

if(TransactionIdIsInProgress(xid) || TransactionIdDidCommit(xid)) {
    /* do something */
}

… это устранит проблему. Такая последовательность нередко встречается в коде самого PostgreSQL. Код работает благодаря тому факту, что когда транзакция коммитится или абортится, в первую очередь всегда изменяется CLOG, и только затем ProcArray. Однако из этого также следует, что в граничных случаях обе функции могут вернуть true.

Есть еще один крайне важный момент. Когда СУБД аварийно завершает работу и затем снова запускается, информация о зааборченных транзакциях в CLOG не обновляется. Для таких транзакций TransactionIdDidAbort() вернет false. Таким образом, эта функция может быть использована разве что для каких-то оптимизаций. Если она возвращает true, то транзакция точно была зааборчена. Когда же возвращается false, то в общем случае ничего не ясно.

В отличие от TransactionIdDidAbort(), на функцию TransactionIdDidCommit() можно полагаться всегда.

Заключение

Конкретная логика проверки кортежа на видимость находится в heapam_visibility.c. В том же коде вы увидите, как PostgreSQL проставляет hint bits. Например, если мы проверили, что t_xmin была закоммичена, кортежу проставляется флаг HEAP_XMIN_COMMITTED. Это позволяет сократить число обращений к CLOG.

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

Дополнение: В продолжение темы см посты Внутренности PostgreSQL: разделяемые буферы, Внутренности PostgreSQL: журнал предзаписи (WAL), и далее по ссылкам.

Метки: , , .


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