Удаление брошенных файлов
- Введение
- В чем суть проблемы
- Корни проблемы родом из PostgreSQL
- Воспроизведение в PostgreSQL
- Почему возникает данная проблема
- Возможные подходы PostgreSQL
- Подход VMWare/Broadcom
- В чем суть нашей реализации в Greengage
- Восстановление согласованности после сбоя
- Как отслеживать файлы, которые могут оказаться брошенными
- Не WAL-логом единым
- В паре шагов от отступления или prepared-транзакции и восстановление после сбоя
- Вместо заключения
Введение
Часть сложных задач, с которыми сталкивается команда разработки Greengage/Arenadata DB, появляется в результате анализа инцидентов, которые изначально разбирает команда поддержки нашего продукта. Даже если сперва задача кажется багом, исправление которого не отнимет много времени, мы всегда стараемся подойти к решению задачи наиболее общим образом, решая более широкий круг проблем.
Некорректное поведение ядра СУБД в этом случае попадало в категорию ошибок, которые исправлялись легко и не тянули за собой массу нюансов, и мы решили эту задачу относительно быстро…
Я бы хотел начать эту статью именно так, но (спойлер!) все было с точностью до наоборот. Пару раз мы были в двух шагах от того, чтобы отказаться от намеченного плана, пойти по пути наименьшего сопротивления и решать задачу схожим образом, как это в свое время сделал апстрим проекта Greenplum.
В чем суть проблемы
Проблему можно проиллюстрировать на примере, который привела в тикете команда поддержки.
Во время эксплуатации СУБД Greenplum возможно появление orphaned files (брошенные или потерянные файлы) на сегментах.
Соответствующий кейс воспроизведения:
-
На мастере открыть транзакцию, создать таблицу и вставить в нее данные:
testdb=# BEGIN; BEGIN testdb=# CREATE TABLE t1(id bigserial, s char(4000)) WITH (appendonly = true) DISTRIBUTED BY (ID); CREATE TABLE testdb=# INSERT INTO t1(s) SELECT v::text FROM generate_series(1,100000) v; INSERT 0 100000
-
На сегмент-сервере в
base
-каталоге можно увидеть файлы данных новой таблицы:$ ls /data1/primary/gpseg1/base/541094/ | sort -n | tail
-
Эмулировать kernel panic на сегмент-сервере:
$ echo c | tee /proc/sysrq-trigger
-
При попытке зафиксировать транзакцию возникает ошибка:
testdb=# COMMIT; ERROR: gang was lost due to cluster reconfiguration (cdbgang_async.c:94)
-
Восстановить работу Linux-сервера.
-
Запустить
gprecoverseg
. -
Запустить
gprecoverseg -r
.
Результат выполнения сценария:
-
После выполнения
gprecoverseg
консистентность сервера базы данных восстановлена, таблицаt1
отсутствует в системном каталоге. -
Файлы данных таблицы
t1
остались на сегмент-сервере. Эти файлы не соответствуют таблице в каталоге и не выводятся поисковым запросом:testdb=# SELECT relname, relfilenode FROM pg_class WHERE relfilenode = $1;
Образование этих файлов связано с событиями failover на кластере.
В версиях Arenadata DB 6.x невозможно полностью исключить появление подобных файлов, и потому в реальности пользователи могут встретиться с ними и с последствиями: чрезмерной утилизацией каталогов данных (основного дата-каталога и/или tablespaces). В практике технической поддержки было уже несколько подобных случаев, когда совместно с пользователями проводился поиск и удаление orphaned files на кластерах Arenadata DB.
Из данного описания видно, что в случае аварийного прерывания процесса бэкенда, который сопоставлен с транзакцией, создавшей новую таблицу, на файловой системе могут оставаться файлы данных таблиц. Мы условились называть такие файлы "брошенными".
Такие файлы после аварийного завершения процесса не будут ассоциированы ни с одной из имеющихся в базе данных таблиц. В зависимости от профиля нагрузки и частоты возникновения аварийных ситуаций, со временем таких файлов может становиться все больше, полезное место расходуется впустую, выявление этих файлов отнимает время, а удаление сопряжено с рисками удалить что-то нужное.
Корни проблемы родом из PostgreSQL
Проблема не является специфичной для Greengage/Greenplum. Интересующиеся могут почитать, например, это долгое обсуждение. Как мне представляется, PostgreSQL относится к возможности оставлять "мусор" от прервавшихся операций без особых переживаний. В частности такой вывод можно сделать, прочитав комментарии исходного кода функции перемещения таблиц из одного табличного пространства в другое при выполнении выражения ALTER DATABASE SET TABLESPACE
:
/*
* ALTER DATABASE SET TABLESPACE
*/
static void
movedb(const char *dbname, const char *tblspcname)
{
...
/*
* Force synchronous commit, thus minimizing the window between
* copying the database files and committal of the transaction. If we
* crash before committing, we'll leave an orphaned set of files on
* disk, which is not fatal but not good either.
*/
...
}
Возможно, для OLTP-СУБД, где состояние базы с точки зрения набора таблиц может быть довольно статичным, эта проблема не является столь критичной, но для наших крупных пользователей Greengage/Greenplum она играет новыми красками. Несколько сегментов на одном сервере, массовые вставки в рамках ETL-процессов в случае аварийного завершения процессов могут оставлять много брошенных файлов. К таким последствиям могут приводить отказы оборудования, ошибки при эксплуатации (например, нехватка свободного места на дисках), программные ошибки — всё, что может привести к аварийному завершению процессов.
Ядро СУБД должно уметь обрабатывать такие ситуации при восстановлении.
Воспроизведение в PostgreSQL
Проблема остается актуальной и в свежей версии ядра PostgreSQL. Воспроизведем один из сценариев в PostgreSQL 17.
postgres=# SELECT version();
version
-------------------------------------------------------------------------------------------------------
PostgreSQL 17.5 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0, 64-bit
(1 row)
postgres=# BEGIN;
BEGIN
postgres=*# CREATE TABLE t1(col1 INT); INSERT INTO t1 SELECT generate_series(1,1000000);
CREATE TABLE
INSERT 0 1000000
postgres=*# SELECT pg_relation_filepath('t1');
pg_relation_filepath
----------------------
base/5/32778
(1 row)
postgres=*# \! stat /data1/postgres/base/5/32778
File: /data1/postgres/base/5/32778
Size: 36249600 Blocks: 70800 IO Block: 4096 regular file
Device: 10302h/66306d Inode: 15860721 Links: 1
Access: (0600/-rw-------) Uid: ( 1000/ andrey) Gid: ( 1000/ andrey)
Access: 2025-05-29 11:14:04.671474274 +0300
Modify: 2025-05-29 11:17:29.490127947 +0300
Change: 2025-05-29 11:17:29.490127947 +0300
Birth: 2025-05-29 11:14:04.671474274 +0300
postgres=*# \! kill -9 $(head -1 /data1/postgres/postmaster.pid)
$ pg_ctl -D /data1/postgres/ -l /usr/local/pgsql/postgres.log start
$ psql postgres
postgres=# \dt
Did not find any relations.
postgres=# \! stat /data1/postgres/base/5/32778
File: /data1/postgres/base/5/32778
Size: 36249600 Blocks: 70808 IO Block: 4096 regular file
Device: 10302h/66306d Inode: 15860721 Links: 1
Access: (0600/-rw-------) Uid: ( 1000/ andrey) Gid: ( 1000/ andrey)
Access: 2025-05-29 11:14:04.671474274 +0300
Modify: 2025-05-29 11:18:18.195104666 +0300
Change: 2025-05-29 11:18:18.195104666 +0300
Birth: 2025-05-29 11:14:04.671474274 +0300
Почему возникает данная проблема
Для понимания причин возникновения брошенных файлов нужно рассмотреть процесс создания таблицы с точки зрения записей, сохраняемых в WAL-логе. Как известно, в PostgreSQL реализован REDO-лог. Обработка выражения CREATE TABLE heap(col1 INT)
приведет к сохранению в WAL-логе следующих записей на мастере (далее все примеры будут разбираться уже в рамках Greengage/Greenplum):
..., tx: 720, lsn: 0/0C000078, ..., desc: file create: base/12812/16384 (1) ..., tx: 720, lsn: 0/0C0000B0, ..., desc: insert: rel 1663/12812/12546; tid 2/82 ... ..., tx: 720, lsn: 0/0C03D5A0, ..., desc: distributed commit ... gid = 1746706495-0000000005, gxid = 5 (2) ..., tx: 720, lsn: 0/0C03D618, ..., desc: distributed forget gid = 1746706495-0000000005, gxid = 5 (3)
и на сегменте:
..., tx: 715, lsn: 0/0C000078, ..., desc: file create: base/12812/16384 (1) ..., tx: 715, lsn: 0/0C0000B0, ..., desc: insert: rel 1663/12812/12546; tid 2/82 ... ..., tx: 715, lsn: 0/0C03D438, ..., desc: prepare (2) ..., tx: 0, lsn: 0/0C03D850, ..., desc: commit prepared 715: ... gid = 1746706495-0000000005 gxid = 5 (3)
Часть WAL-лога я скрыл из соображений краткости (также удалил из вывода множество insert
-записей).
Для рассматриваемого нами сценария особый интерес представляют WAL-записи:
-
file create
в строке, отмеченной цифрой 1 (оба лога); -
distributed commit
иforget
в строках 2 и 3 (лог мастера); -
prepare
иcommit prepared
в строках 2 и 3 (лог сегмента).
Записи file create
соответствует событие создания файла данных таблицы, строкам 2 и 3 соответствуют фазы commit request (vote)
и commit (completion)
в терминах 2PC-протокола с точки зрения сегмента. На мастере точку успешной распределенной транзакции ставит запись forget
на строке 3, которая следует за распределенным коммитом (distributed commit
) на строке 2.
Для того чтобы на файловой системе остался брошенный файл, достаточно "уронить" процесс бэкенда, который является участником транзакции (распределенной или локальной).
Для иллюстрации наличия брошенного файла воспользуемся расширением arenadata_toolkit
и его представлением arenadata_toolkit.__db_files_current_unmapped
. Данное представление отображает список файлов из base
-каталога текущей базы данных (где установлено данное расширение), которые не соответствуют ни одной из таблиц из системного каталога pg_class
(поле relfilenode
). Однако, для того, чтобы получить этот список для незавершенной транзакции, представление должно быть запрошено из транзакции, отличной от той, которая создает эти файлы. В противном случае транзакция получит снимок (snapshot) pg_class
, в котором новые файлы будут ассоциированы с созданной таблицей и формально не будут считаться брошенными.
Поэтому в примере ниже фигурируют два процесса, которые я обозначил как P1 и P2:
P1:
postgres=# BEGIN;
BEGIN
postgres=# CREATE TABLE orphaned(col1 INT) WITH (appendoptimized=false) DISTRIBUTED BY (col1);
CREATE TABLE
P2:
postgres=# CREATE EXTENSION arenadata_toolkit;
CREATE EXTENSION
postgres=# SELECT content, file FROM arenadata_toolkit.__db_files_current_unmapped;
content | file
---------+--------------------------------------------------------------------------------------------
0 | /data1/primary/gpseg0/base/12812/16393
(1 row)
postgres=# SELECT pid, query FROM gp_dist_random('pg_stat_activity') WHERE query LIKE 'CREATE TABLE orphaned%';
pid | query
-------+---------------------------------------------------------------------------------
17532 | CREATE TABLE orphaned(col1 INT) WITH (appendoptimized=false) DISTRIBUTED BY (col1);
(1 row)
postgres=# \! kill -9 17532 (1)
P1:
postgres=# COMMIT; (2)
ERROR: Error on receive from seg0 127.0.0.1:6002 pid=17532: server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
postgres=# \dt (3)
No relations found. (4)
P2:
postgres=# SELECT content, file FROM arenadata_toolkit.__db_files_current_unmapped;
content | file
---------+--------------------------------------------------------------------------------------------
0 | /data1/primary/gpseg0/base/12812/16384 (5)
(1 row)
Как мы видим, после прерывания транзакции и неуспешной попытки выполнить COMMIT
(строка 2) из-за аварийного завершения процесса pid=17532
на сегменте seg0
(строка 1) на файловой системе остался брошенный файл таблицы (строка 5), информации о которой нет в системном каталоге pg_class
(строки 3 и 4).
Частично реализацию удаления файлов таблиц при завершении транзакций операциями COMMIT
или ABORT
мы затрагивали в статье Отслеживание изменений размеров таблиц Arenadata DB. Ключевой момент в удалении файлов таблиц заключается в том, что штатно это происходит при выполнении COMMIT
транзакции, если пользователь запрашивает удаление таблицы (например, в явном виде через DROP TABLE
), в случае ABORT
это откат транзакции, которая создала таблицу, например, через CREATE TABLE
.
Если такая WAL-запись (commit prepared
или abort
) будет отсутствовать, то после проигрывания WAL-лога файл таблицы так и останется на файловой системе. В общем случае базовыми штатными средствами СУБД такой файл не удалить, а удаление вручную будет сопряжено с рисками удалить что-то нужное. Тривиальный пример упомянут выше: транзакция в процессе выполнения, ее эффект еще не виден другим транзакциям, и удаление таких файлов приведет к полной потере данных таблицы.
Возможные подходы PostgreSQL
В сообществе PostgreSQL проблема известна и неоднократно обсуждалась. Например, в рамках этого обсуждения поднимался вопрос возможной реализации UNDO-логов, которые бы в теории позволили разрешить эту проблему. Однако, к большому сожалению, на данный момент обсуждение затормозилось, и вопрос трудоемкости добавления UNDO-логов в ядро PostgreSQL остается открытым. Есть надежда, что сообщество когда-нибудь вернется к этой теме.
Подход VMWare/Broadcom
Greenplum, когда еще был открытым проектом, пошел своим путем и реализовал расширение gp_check_functions
. Это расширение предоставляет в распоряжение пользователя несколько функций и представлений, которые позволяют вывести список брошенных файлов, переместить брошенные файлы в заданную пользователем папку или решить обратную задачу отображения отсутствующих файлов. Такие файлы ожидаются в base
-каталоге сервера согласно данным в pg_class
, но по какой-то причине отсутствуют. Самым главным недостатком этого подхода мне представляется перекладывание на плечи пользователя задачи удаления "мусора" за самой СУБД.
Также нами была обнаружена связанная с переименованием файлов техническая проблема этого расширения, которое реализовано на базе Linux API функции rename. У этой функции есть нюанс — переименование работает только в рамках одной файловой системы. В этом случае функция возвращает ошибку EXDEV
:
EXDEV oldpath and newpath are not on the same mounted filesystem. (Linux permits a filesystem to be mounted at multiple points, but rename() does not work across different mount points, even if the same filesystem is mounted on both.)
Таким образом, если табличные пространства находятся в разных файловых системах (а скорее всего это так в подавляющем числе случаев production-кластеров, где используются табличные пространства с точками монтирования дисковых полок и т.п.), то при указании пользователем такого каталога для переноса файлов этот вызов завершится ошибкой. Если в списке брошенных файлов будут файлы из разных табличных пространств, то часть файлов может переименоваться, часть — нет, так как функция упадет где-то посередине.
Так как мы поддерживаем и это расширение, то планируем исправить эту проблему, расширив контракт новыми функциями. К сожалению, возможная проблема удаления текущей версии в случае совмещения разных табличных пространств на базе разных файловых систем останется актуальной для старого контракта. Механизма разделения переименования (переноса между каталогами) брошенных файлов в разрезе табличных пространств пока нет, но мы работаем над этим.
В то же время, если быть объективным, то расширение gp_check_functions
на начальном этапе дополняет предлагаемый и реализованный нами подход. Это станет понятно из дальнейшего описания предлагаемого нами решения. Сейчас можно сказать, что наше решение не позволит удалить уже имеющиеся в base
-каталоге брошенные файлы, но он позволит не "плодить" их в дальнейшем.
Также несомненным плюсом этого расширения является возможность поиска отсутствующих файлов, что в общем-то является тревожным звоночком — каталог ожидает присутствия на диске файлов данных таблиц, которых нет на диске? Скорее всего, это очень плохая ситуация.
В чем суть нашей реализации в Greengage
Предлагаемая нами идея заключается в следующих трех простых тезисах:
-
В случае фиксации или отката транзакции, создавшей таблицу, ядро СУБД само решит, как ему быть с файлами данных таблицы. В случае
abort
произойдет удаление файлов, в случаеcommit prepared
файлы остаются на месте.Таким образом, текущее штатное поведение не меняется.
-
Пока транзакция находится в статусе
TRANSACTION_STATUS_IN_PROGRESS
, считается, что транзакция может прерваться нештатно и оставить после себя брошенные файлы данных таблиц. Само ядро могло бы отслеживать такие файлы в качестве кандидатов на проверку, были ли они брошены транзакцией, которая не завершиласьcommit prepared
илиabort
. Эти файлы впоследствии рассматриваются как кандидаты на удаление.Это главное из условий, предотвращающее удаление файлов, которые должны быть обработаны в соответствии с предыдущим пунктом. Транзакции в любых других статусах не рассматриваются для удаления брошенных файлов, так как считаются обработанными штатным образом.
-
Принять решение об удалении файла можно на этапе выполнения восстановления (startup recovery). Этот пункт требует особого рассмотрения, об этом мы поговорим далее. В этом заключается основное изменение текущего поведения ядра СУБД (помимо самой реализации отслеживания файлов, которые будут рассматриваться как брошенные).
Восстановление согласованности после сбоя
Одним из важнейших элементов обеспечения согласованности данных в базе является процесс восстановления сервера после сбоя (хотя этот же процесс является и точкой входа при старте после корректной остановки). Входная точка этого процесса — функция StartupXLOG. Без малого две тысячи строк исходного кода этой функции отвечают за следующие основные действия:
-
чтение файла pg_control (данные из этого файла потребуются в частности для следующих двух шагов получения состояния сервера на момент остановки, позиции контрольной точки (checkpoint) и временной линии (timeline) восстановления);
-
понимание в каком состоянии была база на момент ее остановки (была ли это штатная остановка сервера или аварийное завершение);
-
понимание режима восстановления (старт после корректной остановки, восстановление после сбоя, point-in-time восстановление, запуск в режиме standby и т.д.);
-
чтение контрольной точки (записи
checkpoint
) и выявление соответствующей ейredo
-точки — позиции в WAL-логе, с которой нужно начинать применять WAL-записи; -
чтение файла backup_label при его наличии (в этом случае позиция контрольной точки берется из этого файла, а не из pg_control);
-
применение WAL-записей (если их необходимо "проиграть" начиная от
redo
-точки); -
переключение временных линий при необходимости;
-
остановку применения WAL-записей в случае достижения целевой точки или проигрывания всех имеющихся записей (конец WAL-лога);
-
промоута зеркала в случае такой необходимости;
-
специальной обработки
prepared
-транзакций в рамках 2PC-протокола (об этом мы поговорим далее); -
и, по окончании, старт сервера в согласованном состоянии, если этого состояния удалось достичь.
Для standby (в том числе до его возможного промоута) этот список действий несколько короче, но общей картины это не меняет.
Схематично и упрощенно этот процесс можно представить следующим образом.
Если рассматривать возможные места в коде, где можно было бы встроить удаление брошенных файлов, то на ум приходит такой вариант.
Это выглядит следующим образом (момент удаления брошенных файлов обозначен красным прямоугольником, на рисунке представлена часть исходной схемы).
Именно с этого мы и начали. В целом все работало так как нужно, но первый выявленный недостаток этой схемы показал ее неполноту. Например, что происходит, когда зеркало после аварийного завершения восстанавливается утилитой gprecoverseg
и стартует в прежней роли?
P1:
postgres=# BEGIN; CREATE TABLE heap(col1 INT) DISTRIBUTED BY (col1);
BEGIN
CREATE TABLE
P2:
postgres=# SELECT content, file FROM arenadata_toolkit.__db_files_current_unmapped;
content | file
---------+--------------------------------------------------------------------------------------------
0 | /data1/primiary/gpseg0/base/12812/16392
(1 row)
postgres=# \! kill -9 407529 408718 (1)
P1:
postgres=# COMMIT;
ERROR: Error on receive from seg0 127.0.0.1:6002 pid=407834: server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
postgres=# \dt
No relations found.
P2:
postgres=# SELECT content, file FROM arenadata_toolkit.__db_files_current_unmapped;
content | file
---------+------
(0 rows)
postgres=# \! ls /data1/primary/gpseg0/base/12812/16392
ls: cannot access '/data/primary/gpseg0/base/12812/16392': No such file or directory (2)
postgres=# \! ls /data1/mirror/gpseg0/base/12812/16392
/data1/mirror/gpseg0/base/12812/16392
postgres=# SELECT content, file FROM arenadata_toolkit.__db_files_current_unmapped;
dbid | content | role | mode | status
------+---------+------+------+--------
1 | -1 | p | n | u
4 | -1 | m | s | u
2 | 0 | p | n | u
3 | 0 | m | n | d
(4 rows)
postgres=# \! gprecoverseg
postgres=# SELECT content, file FROM arenadata_toolkit.__db_files_current_unmapped;
dbid | content | role | mode | status
------+---------+------+------+--------
1 | -1 | p | n | u
4 | -1 | m | s | u
2 | 0 | p | s | u
3 | 0 | m | s | u
(4 rows)
postgres=# \! ls /data1/mirror1/gpseg0/base/12812/16392 (3)
/data1/mirror1/gpseg0/base/12812/16392
Видно, что primary-сегмент (строка с номером 2), в отличие от своего standby (строка 3) после падения процесса бэкенда удалил файлы таблицы, которая была создана аварийно завершившейся транзакцией.
И действительно, согласно алгоритму функции StartupXLOG
:
-
Определяется, что standby восстанавливается после падения (для штатного завершения зеркала это состояние было бы
DB_SHUTDOWNED_IN_RECOVERY
). -
Контрольная точка для случая восстановления
gprecoverseg
считывается из файла pg_control и указанной тамredo
-точки. -
Запускается проигрывание записей из WAL-лога, но так как это standby, то по завершении этого процесса ("маркером" окончания этого процесса является запись
XLOG_CHECKPOINT_SHUTDOWN
) standby так и остается "крутиться" в цикле ожидания и применения WAL-записей.
Удаления брошенных файлов на standby не происходит, так как первый реализованный вариант предполагал удаление перед завершением процесса standby и возвратом управления postmaster для дальнейшей инициализации/промоута (либо полной остановки). В случае применения записей с primary, в котором постоянно находится standby, такого не происходит.
Можно задать вопрос, а как же произошло удаление файлов на primary и по какой причине заново отработал процесс startup вместе с функцией StartupXLOG
, хотя судя по комментарию эта функция вроде бы отрабатывает только один раз?
Все дело в аварийном завершении процесса бэкенда: SIGKILL
посылается (строка 1) двум процессам — postmaster standby и бэкенду процесса транзакции на primary-сегменте.
В логе primary видно, что это приводит к завершению всех процессов (строка 1) и повторной инициализации через startup-процесс. Что в свою очередь ведет к той самой StartupXLOG
, но уже в статусе not properly shut down
(строка 2), и согласно нашей реализации удаления брошенных файлов, приводит к удалению брошенных файлов транзакции 718 (строки 3 и 4):
"LOG","00000","server process (PID 408718) was terminated by signal 9" "LOG","00000","terminating any other active server processes",,,,,,,0,,"postmaster.c",3812, (1) "LOG","00000","all server processes terminated; reinitializing",,,,,,,0,,"postmaster.c",4369, "LOG","00000","database system was interrupted; last known up at 2025-05-19 18:15:12 MSK",,,,,,,0,,"xlog.c",6628, "LOG","00000","database system was not properly shut down; automatic recovery in progress",,,,,,,0,,"xlog.c",7079, (2) "LOG","00000","redo starts at 0/C1A5350",,,,,,,0,,"xlog.c",7345, "LOG","00000","checkpoint starting: end-of-recovery immediate",,,,,,,0,,"xlog.c",8911, "LOG","00000","Prepare to drop node (1663: 12812: 16392) for xid: 718",,,,,,,0,,"storage_pending_deletes_redo.c",279, (3) "LOG","00000","Pending delete rels were dropped (count: 1; xid: 718).",,,,,,,0,,"storage_pending_deletes_redo.c",331, (4) "LOG","00000","database system is ready",,,,,,,0,,"xlog.c",8171,
Более сложный, но схожий сценарий, когда бывший primary, "разжалованный" после своего падения в зеркало, стартует после его восстановления утилитой gprecoverseg
.
И в том, и в другом случае результат одинаков — брошенные файлы остаются в base
-каталоге. И остаются они до момента промоута standby либо его штатной остановки.
И то, и другое нас не устраивало — и мы усложнили схему до следующего варианта.
Как видно, отличие в добавленном блоке удаления брошенных файлов в цикле обработки WAL-записей — при обработке записей XLOG_CHECKPOINT_SHUTDOWN
или XLOG_END_OF_RECOVERY
. Сам факт появления этих записей в WAL-логе означает, что на этот момент в системе нет никаких активных транзакций, а потому все незафиксированные транзакции такими и останутся. Теперь в процессе выполнения gprecoverseg
при получении соответствующих WAL-записей происходит проверка и удаление брошенных файлов.
По итогу в этой части принятия решения об удалении файлов мы пришли к выводу, что эта схема покрывает все сценарии. По крайней мере, на текущий момент мы не выявили доказательства обратного.
Остальные нюансы, описанные далее, дополняют и уточняют эту схему. Точки принятия решения об удалении остаются такими же.
Как отслеживать файлы, которые могут оказаться брошенными
В предыдущем разделе мы рассмотрели, в какие места в коде функции StartupXLOG
мы встроили удаление брошенных файлов. Далее нас ожидало множество сюрпризов, но для дальнейшего повествования нужно рассказать про реализацию сохранения списка идентификаторов файлов (relfilenode), который понадобится при удалении брошенных файлов. Тут также вскрылась масса нюансов.
Как упоминалось выше, созданию файла данных таблицы в WAL-логе соответствует запись file create
(тут я привел полный вывод pg_xlog_dump
):
rmgr: Storage len (rec/tot): 20/ 52, tx: 720, lsn: 0/0C000078, prev 0/0C000050, bkp: 0000, desc: file create: base/12812/16384
Ключевой элемент процесса создания файла данных таблицы — функция RelationCreateStorage. Помимо запроса менеджера хранилища (smgr
), отвечающего за физическое создание файлов таблицы, она создает новый экземпляр структуры PendingRelDelete:
typedef struct PendingRelDelete
{
RelFileNodePendingDelete relnode; /* relation that may need to be deleted */
bool atCommit; /* T=delete at commit; F=delete at abort */
int nestLevel; /* xact nesting level of request */
struct PendingRelDelete *next; /* linked-list link */
} PendingRelDelete;
Экземпляры этих структур описывают файлы, которые должны быть удалены при фиксации или откате транзакции.
Они хранятся в виде связанного списка в рамках каждого из бэкендов в локальной памяти процесса (то есть он не виден никому, кроме текущего бэкенда!). Список представлен статическим указателем на головной элемент списка pendingDeletes.
Созданный экземпляр RelationCreateStorage
добавляется в связанный список (используется next
-указатель на следующий элемент). Поле atCommit
указывает на действие, которое нужно совершить с файлом в случае фиксации или отката транзакции. Если файл нужно удалить при фиксации, то atCommit
будет установлен в true
, если при откате транзакции — в false
. Сам файл идентифицируется по экземпляру структуры RelFileNodePendingDelete:
typedef struct RelFileNode
{
Oid spcNode; /* tablespace */
Oid dbNode; /* database */
Oid relNode; /* relation */
} RelFileNode;
typedef struct RelFileNodePendingDelete
{
RelFileNode node;
bool isTempRelation;
char relstorage;
} RelFileNodePendingDelete;
relNode
представляет собой идентификатор, который содержится в поле relfilenode
таблицы pg_class
.
Когда бэкенд доходит до этапа фиксации или отката транзакции, то одной из выполняемых операций является удаление файлов, которые хранятся в списке pendingDeletes
. За это отвечает функция smgrDoPendingDeletes. В недрах этой функции содержится вызов физического удаления (операция unlink файлов, связанных с таблицей).
Так работает штатное удаление файлов. Как видно, этот процесс полностью полагается на список pendingDeletes
.
Для нашей задачи важно отслеживать то, какая именно транзакция создала файл с конкретным идентификатором relfilenode
. Почему это важно, мы увидим далее. Мы решили хранить идентификаторы (RelFileNodePendingDelete
и TransactionId
) также в виде связанного списка, так как при наличии указателя на конкретный узел эта структура данных позволяет удалять элемент из списка за O(1).
При этом мы хотели обойтись без изменений существующей функциональности и приняли решение реализовать отслеживание брошенных файлов на базе имеющейся схемы с локальным списком pendingDeletes
.
typedef struct PendingRelDelete
{
... /* all existing fields */
dsa_pointer shmemPtr; /* ptr to shared pending delete list node */
} PendingRelDelete;
Расширив структуру PendingRelDelete
новым полем-указателем на узел списка (поле shmemPtr
) брошенных файлов, мы решили сразу несколько задач:
-
Концептуально связали
pendingDeletes
со списком файлов, которые при аварийном завершении будут рассматриваться как брошенные. -
Упростили удаление из списка брошенных файлов, так как у нас уже есть указатель на нужный узел списка (
shmemPtr
). Таким образом, фиксация или откат транзакции, что ведет к удалению элементов из отслеживаемого локального спискаpendingDeletes
, сразу же удаляет и узлы из списка потенциально брошенных файлов.
Не WAL-логом единым
Реализовав базовую схему удаления и описанный выше механизм сохранения списка, воодушевленные промежуточным успехом, мы провели первые тесты и увидели следующую проблему: если транзакция не успевает завершиться до очередной контрольной точки (checkpoint
), при этом create
-запись оказывается на временной линии "слева" от redo
-точки, то удаления брошенных файлов при сбое не происходит.
Очевидно, что:
-
восстановление стартует начиная с
redo
-точки; -
запись
file create
не проигрывается при восстановлении (она находится раньшеredo
-точки); -
в список потенциально брошенных файлов идентификаторы не добавляются;
-
как итог, процесс восстановления не имеет понятия, что какие-то файлы не будут привязаны к таблице и их нужно удалить.
Следовательно, чтобы отслеживание файлов могло пройти "рубикон" контрольной точки и redo
-записи, должна быть решена задача сохранения этого списка.
Первым кандидатом для сохранения списка была сама checkpoint
-запись, но мы отказались от этой идеи из соображений сохранения совместимости в таких критичных блоках кода, касающихся содержания записи контрольной точки и ее обработки. В результате мы остановились на добавлении WAL-записи нового типа XLOG_PENDING_DELETE
(orphaned relfilenodes to delete
в выводе pg_xlog_dump
).
Эта запись представляет собой "контейнер" идентификаторов файлов для удаления (RelFileNodePendingDelete relnode
) в их привязке к транзакции (поле xid
), которая создала этот файл (экземпляры структуры PendingRelXactDeleteArray
в поле array
):
typedef struct PendingRelXactDelete
{
RelFileNodePendingDelete relnode;
TransactionId xid;
} PendingRelXactDelete;
typedef struct PendingRelXactDeleteArray
{
Size count;
PendingRelXactDelete array[FLEXIBLE_ARRAY_MEMBER];
} PendingRelXactDeleteArray;
Сразу возникает два вопроса: кем и когда эта запись должна добавляться в WAL-лог?
Так как за процесс создания контрольной точки отвечает процесс checkpointer
, то выглядело логичным встроиться в функцию создания online
контрольной точки. Ответ на вопрос "кем" мы получили — процессом checkpointer
.
С ответом на вопрос "когда" возникли некоторые сложности. Список идентификаторов файлов, которые могут стать брошенными из-за прерывания транзакции, понадобится как можно "ближе" к redo
-точке, чтобы в случае запуска процесса восстановления сразу же получить список и далее актуализировать статус записей в нем. Актуализация, очевидно, понадобится для того, чтобы ядро приняло решение по удаляемым файлам самостоятельно, так как после redo
-точки возможна вставка записей commit prepared
или abort
. Таким образом, актуализация подразумевает удаление записей для тех транзакций, которые были обработаны штатно.
Гарантировать, что новая WAL-запись будет первой, то есть redo
-точка сразу бы указывала на эту запись, без блокировки записи в WAL-лог нельзя. Блокировать запись бэкендов без критичной необходимости мы не хотели. Это, как будет видно дальше, создает определенные сложности, но вариант с блокировкой мы сочли плохим решением.
Из этого следует, что ответ на вопрос "когда?" подразумевает либо некоторую синхронизацию получения списка с одновременной работой бэкендов на запись в этот список, либо последующую обработку ситуации, что информация в списке брошенных файлов на момент чтения может уже быть неактуальной. Это требует отдельного пояснения.
Процессы бэкендов, обрабатывающие транзакции, живут своей жизнью. В контексте создания и удаления файлов таблиц это означает работу со списком pendingDeletes
в своей локальной области памяти. Для того, чтобы список идентификаторов каждого из бэкендов был доступным процессу checkpointer
, нужно сделать этот список разделяемым. Для решения задачи создания структур общего доступа в памяти, в архитектуре PostgreSQL применяется разделяемая область памяти.
Однако, если делать этот список единым для всех бэкендов, то, очевидно, потребуется использование синхронизирующих примитивов (в нашем случае LWLock
-реализации). Вся работа со списком (удаление или добавление узлов) должна быть защищена эксклюзивной блокировкой. У такого подхода есть один существенный недостаток: все транзакции, которые будут создавать таблицы или удалять существующие, будут конкурировать за эту блокировку. Такой вариант решения был отвергнут, и мы реализовали возможность использования каждым бэкендом своего списка. Процессу checkpointer
нужен доступ только на чтение, процессам бэкендов — на запись. Без блокировки не обойтись и в этом случае, так как пока checkpointer
идет по списку, менять его содержимое бэкенду нельзя. Тем не менее это будет локальная блокировка в рамках пары "бэкенд-checkpointer", а не "все бэкенды-checkpointer".
ПРИМЕЧАНИЕ
Мы также рассматривали, прототипировали и тестировали на производительность вариант с lock-free списком. Схожая структура данных описана в статье Lock-Free Linked Lists Using Compare-and-Swap и вариант ее улучшенной реализации — в статье A more Pragmatic Implementation of the Lock-free, Ordered, Linked List. По итогам нагрузочного тестирования мы пришли к выводу, что такая структура данных в нашем случае избыточна. Эта структура прекрасно себя показывала в условиях моделирования конкурентного доступа значимого количества бэкендов и большого количества элементов в списке (от сотен тысяч и больше). В предполагаемых условиях работы такой нагрузки на эту структуру не ожидается. Поэтому мы остановились на компромиссном варианте с блокировкой на список в рамках бэкенда. Однако полученные результаты исследования lock-free списка интересны сами по себе и, возможно, реализациям на базе таких списков найдется применение в других, более нагруженных частях ядра СУБД. В одной из следующих статей мы рассчитываем поделиться этими результатами. |
Важным моментом является понимание следующей особенности реализации:
-
список идентификаторов файлов, который сохраняется в
XLOG_PENDING_DELETE
-записи, подготавливается процессомcheckpointer
; -
в это время в WAL-лог остальные бэкенды могут добавлять свои записи
commit prepared
илиabort
; -
следовательно, полагаться на порядок появления записей в WAL-логе нельзя.
Например, возможна ситуация, что для таблицы будет выполнено выражение commit prepared
, эта запись будет добавлена в WAL-лог, далее checkpointer
вставит в WAL-лог запись в соответствии со своим знанием списка брошенных файлов. Действительно, пока checkpointer
обходил списки активных бэкендов, транзакция могла зафиксироваться или откатиться. Таким образом, при проигрывании WAL-записей в процессе восстановления нужно каким-то образом проверять, не была ли зафиксирована транзакция, создавшая файл. Если статус транзакции TRANSACTION_STATUS_IN_PROGRESS
, то файл не должен удаляться.
Это решение позволило нам не завязываться на порядок записи в WAL-лог. Любая синхронизация повлекла бы за собой необходимость сериализации записи в WAL-лог, что неизбежно создало бы конкуренцию между бэкендами.
Отсюда и следует необходимость сохранять пару xid:relfilenode
, про которую упоминалось ранее — статус транзакции можно узнать по ее идентификатору. Кроме того, нужно помнить, что у транзакции могут быть вложенные транзакции со своими идентификаторами (для которых допустим и свой откат в виде ROLLBACK TO SAVEPOINT
). При фиксации транзакции COMMIT
выполняется в рамках транзакции верхнего уровня. Отдельных событий фиксации вложенных транзакций нет. В одной из первых версий патча при удалении мы нарвались на ситуацию, когда после фиксации транзакции и последующего восстановления после сбоя СУБД с радостью удалила файлы таблиц, которые были созданы и зафиксированы в рамках вложенных транзакций. Эта проблема решалась удалением из списка всего "дерева" идентификаторов вложенных транзакций.
В паре шагов от отступления или prepared-транзакции и восстановление после сбоя
Когда мы провели массу тестов, продумали разные варианты сценариев и последовательностей событий/WAL-записей, мой коллега, Василий Иванов (aka Stolb27), нашел сценарий ошибочного удаления нужных файлов, который по началу поставил в тупик. После стольких тестов, обработки пограничных условий (статус транзакции, горизонт видимости транзакции) и опять нетривиальный случай? В этот момент я чуть было не начал обдумывать варианты с PostgreSQL-расширением или еще чем-то внешним (по отношению к ядру).
Сценарий с точки зрения WAL-лога мастера такой:
..., tx: 732, lsn: 0/0C19B1B8, ..., desc: file create: base/12812/57345 (1) ..., tx: 0, lsn: 0/0C1C4298, ..., desc: orphaned relfilenodes to delete: 1 (2) ..., tx: 0, lsn: 0/0C1C42D8, ..., desc: checkpoint: redo 0/C1C4298; ...; online, ... (3) ..., tx: 732, lsn: 0/0C1C4358, ..., desc: distributed commit ... gid = 1747898739-0000000005, gxid = 5 (4)
WAL-лог сегмента:
..., tx: 727, lsn: 0/0C19C158, ..., desc: file create: base/12812/49154 (1) ..., tx: 727, lsn: 0/0C1C5000, ..., desc: prepare (2) ..., tx: 0, lsn: 0/0C1C5418, ..., desc: orphaned relfilenodes to delete: 1 (3) ..., tx: 0, lsn: 0/0C1C5458, ..., desc: checkpoint: redo 0/C1C5000; ...; online, ... (4)
-
Мастер-сервер инициирует распределенную транзакцию, в рамках которой создается файл данных таблицы (строка 1 в логах мастера и сегмента, соответственно).
-
Сегмент выполняет
prepare
(командаDTX_PROTOCOL_COMMAND_PREPARE
в терминах менеджера распределенных транзакций), отправляет ответ мастеру (строка 2 в логе сегмента). -
В этот момент просыпается
checkpointer
, который создает контрольную точку (строки 3 и 4 в логах мастера и сегмента). -
checkpointer
сохраняет список пар идентификаторовxid:relfilenode
в записиorphaned relfilenodes to delete
в WAL-логе, так как транзакция еще не завершена, и эти файлы формально рассматриваются как возможные кандидаты на удаление при восстановлении (строки 2 и 3 в логах мастера и сегмента). -
Мастер получает ответ от сегментов, вставляет в WAL-лог запись
distributed commit
(строка 4 в логе мастера). -
Мастер обновляет commit log для транзакции, помечая ее как успешно завершенную.
-
Далее мастер должен сделать запрос на
commit prepared
транзакции к сегментам (командаDTX_PROTOCOL_COMMAND_COMMIT_PREPARED
), но не успевает это сделать и аварийно завершает свою работу. -
В это же время аварийно завершает работу один (или несколько) сегментов, тем самым при последующем старте будет инициирован процесс восстановления после сбоя.
-
Пользователь стартует кластер.
-
startup-процесс на мастере и сегменте начинает проигрывать WAL-лог начиная с
redo
-записейlsn: 0/C1C4298
для мастера (orphaned relfilenodes to delete
-запись) иlsn: 0/C1C5000
(такжеorphaned relfilenodes to delete
). -
Согласно начальному виду алгоритма, перед завершением процесса восстановления startup-процессом запускается удаление брошенных файлов, и так как на сегменте запись
commit prepared
в WAL-логе отсутствует (мастер не успел ее отправить) — файлы данной транзакции считаются брошенными и удаляются с диска. -
Мастер находит прерванную распределенную транзакцию и запускает процесс восстановления распределенной транзакции, в рамках которой отправляет на сегменты запись
commit prepared
, тем самым завершая согласно 2PC-протоколу распределенную транзакцию.
Лог мастера:
"LOG","00000","Crash recovery broadcast of the distributed transaction 'Commit Prepared' broadcast succeeded for gid = 1747898739-0000000005.",,,,,,,0,,"cdbdtxrecovery.c",98,
WAL-лог сегмента:
..., tx: 0, lsn: 0/0C1C5578, ..., desc: commit prepared 727: ... gid = 3953992400-0000025774 gid = 1747898739-0000000005 gxid = 5
Таким образом, на шаге 11 на сегменте были удалены файлы той таблицы, которая на финальном шаге восстановления все-таки была успешно создана, так как транзакция в итоге была зафиксирована!
Результат крайне печален, но закономерен:
postgres=# SELECT * FROM heap;
ERROR: could not open file "base/12812/49154": No such file or directory (seg0 slice1 127.0.0.1:6002 pid=57716)
ПРИМЕЧАНИЕ
Почему эти же файлы не были удалены на мастере? Разница в том, что при восстановлении мастера, которое также стартует с WAL-записи |
Что делать в этом случае, было совершенно непонятно. Формально на момент завершения процесса восстановления на сегменте файл числился брошенным (commit prepared
-записи нет, статус транзакции TRANSACTION_STATUS_IN_PROGRESS
). Каким образом отделять транзакции, которые еще могут примениться в процессе восстановления распределенных транзакций?
Перед тем как мы нашли ответ на этот вопрос, потребовалось детальнее изучить реализацию 2PC-протокола и особенно реализацию обработки ошибочных ситуаций.
2PC-протокол решает следующую задачу: протокол гарантирует, что все участвующие серверы баз данных получают и выполняют одно и то же действие (либо фиксируют, либо откатывают транзакцию) независимо от локального или сетевого сбоя. Если какой-либо сервер баз данных не может зафиксировать свою часть транзакции, всем серверам баз данных, участвующим в транзакции, нельзя фиксировать свою работу.
Как известно, для поддержки внешних (по отношению к ядру PostgreSQL) менеджеров транзакций реализован механизм prepared-транзакций.
Неявно мы уже увидели элементы реализации 2PC-протокола в Greengage/Greenplum. База реализации протокола такова:
-
Мастер рассылает на сегменты запрос на подготовку фиксации транзакции (выражение
PREPARE TRANSACTION
). Тут имеет смысл отметить, что после выполнения этой команды транзакция уже не будет привязана к текущей сессии и может быть применена (или наоборот будет запрошен откат) из любой другой сессии. Это как раз понадобится для следующей фазы 2PC-протокола и обработки ошибки в частности. -
В случае успешного выполнения всеми сегментами этого запроса и получения мастером ответа от всех сегментов он запрашивает сегменты применить подготовленную ранее транзакцию (выражение
COMMIT PREPARED
). Обратного пути после этого шага уже нет: мастер должен добиться, чтобы все сегменты выполнили этот запрос. -
В случае успешного выполнения всеми сегментами
COMMIT PREPARED
распределенная транзакция считается примененной, и далее этот факт отражается в WAL-логе мастера.
Сложности как всегда начинаются с рассмотрения вариантов обработки ошибок. Для нас сейчас важен сценарий отказа мастера, когда сегменты ответили готовностью зафиксировать транзакцию (prepare
-запись) и мастер отразил факт намерения зафиксировать распределенную транзакцию distributed commit
-записью в своем WAL-логе.
Это сценарий при восстановлении мастера отрабатывается следующим образом:
-
Мастер находит запись
distributed commit
(менеджер распределенных транзакций восстановил контекст, т.е. мастеру известно о такой транзакции). -
Мастер отправляет запрос на сегменты о намерении все-таки зафиксировать эту транзакцию. Физически за этот шаг отвечает отдельный фоновый рабочий процесс
dtx recovery process
. -
Сегменты, получив такой запрос, добавляют в свой лог
commit prepared
. -
Мастер, получив ответ, финально фиксирует распределенную транзакцию, вставляя в WAL-лог
forget
-запись.
Информация о prepared
-транзакции фиксируется в специальном 2PC-файле, его содержимое также сохраняется в WAL-записи с типом XLOG_XACT_PREPARE
. Greengage/Greenplum-специфичная часть (относительно PostgreSQL) заключается в том, что при обработке такой WAL-записи обработчик вычитывает ее содержимое и сохраняет его в специальной хеш-таблице (crashRecoverPostCheckpointPreparedTransactions_map_ht), ключом в которой является идентификатор транзакции (верхнего уровня), а содержанием записи является 2PC-файл. Также элементы в эту хеш-таблицу попадают при обработке checkpoint
-записи из WAL-лога, если контрольная точка содержит такие записи (то есть на момент создания есть распределенные транзакции, которые могут потребовать восстановления своего контекста в случае запуска процесса восстановления). При фиксации или откате транзакции элементы из хеш-таблицы удаляются. По завершении процесса восстановления одним из последних шагов является обработка оставшихся в списке prepared
-транзакций.
Таким образом, присутствие в хеш-таблице идентификатора транзакции (и ее подтранзакций, если они есть) означает, что файлы, связанные с этой транзакцией, удалять ни в коему случае нельзя, так как такая подготовленная транзакция может быть зафиксирована в ходе обработки ошибочных ситуаций!
Финальная схема удаления брошенных файлов стала выглядеть так, и уже эта реализация попала в релиз.
Вместо заключения
Разработка данной фичи заняла у нас почти год. Помимо самой реализации — аккуратной, вдумчивой разработки в одной из самых критичных частей ядра (восстановление после сбоев, создание и удаление файлов данных таблиц) — много времени ушло на аналитическую работу, которая заключалась в поиске и проверке возможных сценариев отказа.
Да, с точки зрения решения задач обычных пользователей фича себя никак не проявляет, но ее задача в другом — в решении проблемы администраторов СУБД. И мы точно знаем, что она востребована.
Источник: Удаление брошенных файлов