Есть особый тип историй, где никто не хотел ничего плохого, а получилось как в плохом романе: заговор не состоялся, злодей не появился, а бедствие всё равно случилось просто потому, что сложились обстоятельства, диск и человеческая привычка нажимать на самую очевидную кнопку. Эта история именно такая. В ней нет злого умысла, есть только одиночный кластер PostgreSQL, два с половиной терабайта «боевых» данных 1С и одна фраза, которая потом будет звучать в переписке админов как эпитафия: «А давайте почистим WAL, раз места нет».

Одинокий кластер и большая инфобаза
Жил-был одиночный кластер PostgreSQL со сборкой для 1С от Postgres Professional с десятками 1С-баз (10+ из которых активные) и примерно 2,5 ТБ данных. Больше трёх лет система существовала на PostgreSQL 15 без бэкапов и архивов WAL. И вдруг в этом кластере решили восстановить ещё одну большую инфобазу с многопоточной утилитой 1С.
Всё бы хорошо, но диск виртуальной машины был copy-on-write и «нарисован» больше, чем место на гипервизоре. Такое бывает: виртуалка ещё видит зелёные лампочки и свободные гигабайты, а на хосте файл диска уже упёрся в потолок хранилища. Снаружи уже дым, а внутри ещё всё «в порядке».
Приходит алерт, потом ещё один. Потом уже не алерт, а нервное напряжение. Админы идут смотреть и находят pg_wal, который растёт прямо на глазах.

В идеальном мире люди в такой ситуации смотрят логи и ищут причину роста WAL, аварийно останавливают сервер, думают про чекпойнты, смотрят на состояние дисков и не делают резких движений.
В реальном мире у них пищит мониторинг, бизнес смотрит в затылок, место заканчивается прямо сейчас, а каталог pg_wal выглядит как очевидный подозреваемый.
Админы начинают удалять WAL. Сервер при этом продолжает работать, а восстановление инфобазы продолжает писать.
Самым разумным было бы выдернуть машину из розетки и уже потом думать. Но админы послали постмастеру kill -9. После такого база, конечно, не запустилась. Это нормальная реакция PostgreSQL, у которого сначала украли WAL, потом прибили главный процесс, а теперь просят проснуться бодрым и деловым.
Вызываем pg_resetwal
Дальше админы вызвали pg_resetwal.

В этой утилите нет никакой романтики. pg_resetwal может позволить PostgreSQL стартовать, когда нормальный механизм восстановления уже не может сойтись. Но это подразумевает не починить базу, а скорее отключить часть сигнализации и открыть дверь в дом, где неизвестно, какие несущие стены остались на месте.
После pg_resetwal сервер действительно стартовал, но 1С не подключилась.
Тут на сцену вышел маг — специалист по 1С и PostgreSQL, который пробовал подключаться не через 1С, а напрямую, через pgAdmin и SQL. Но ничего не изменилось: select 1 выполнялся, а запросы посложнее зависали наглухо.
Чуда не случилось, и история «пожара в серверной» превратилась в расследование.
Куда ходит бэкенд
У инженеров поддержки не было прямой консоли, команды передавались через несколько рук, а состояние базы было таково, что обучать людей пользоваться gdb или strace было тревожно. Но повезло с особенностью сборки: в PostgreSQL для 1С от Postgres Professional была встроена утилита crash_info. Изначально она предназначалась для того, чтобы при падениях вроде segfault сбрасывать полезную диагностику: стек вызовов, стек запросов и прочее. Но у неё есть и более любопытный режим: можно послать бэкенду специальный сигнал, и он сам запишет стек в файл — добровольный отчёт о том, чем он сейчас занят.
Но не все kill одинаково вредны. Девятым сигналом базу уже приложили, а сороковой мог помочь понять, что происходит. Мы взяли зависший бэкенд, послали сигнал и принялись наблюдать. Каждый раз мы видели одно и то же: _btmoveright. То есть бэкенд ходит по B-tree-индексу вправо, а по какому индексу? Попробуем выключить обычные индексные сканы:
SET enable_indexscan TO off; Снова смотрим бэкенд — он опять ходит вправо. Вглядимся повнимательнее в запрос: # Planner queries: 00 SELECT DISTINCT att.attname as name, att.attnum as OID, pg_catalog.format_type(ty.oid,NULL) AS datatype, att.attnotnull as not_null, CASE WHEN att.atthasdef OR att.attidentity != '' OR ty.typdefault IS NOT NULL THEN True ELSE False END as has_default_val, des.description, seq.seqtypid FROM pg_catalog.pg_attribute att JOIN pg_catalog.pg_type ty ON ty.oid=atttypid JOIN pg_catalog.pg_namespace tn ON tn.oid=ty.typnamespace JOIN pg_catalog.pg_class cl ON cl.oid=att.attrelid JOIN pg_catalog.pg_namespace na ON na.oid=cl.relnamespace LEFT OUTER JOIN pg_catalog.pg_type et ON et.oid=ty.typelem LEFT OUTER JOIN pg_catalog.pg_attrdef def ON adrelid=att.attrelid AND adnum=att.attnum LEFT OUTER JOIN (pg_catalog.pg_depend JOIN pg_catalog.pg_class cs ON classid='pg_class'::regclass AND objid=cs.oid AND cs.relkind='S') ON refobjid=att.attrelid AND refobjsubid=att.attnum LEFT OUTER JOIN pg_catalog.pg_namespace ns ON ns.oid=cs.relnamespace LEFT OUTER JOIN pg_catalog.pg_index pi ON pi.indrelid=att.attrelid AND indisprimary LEFT OUTER JOIN pg_catalog.pg_description des ON (des.objoid=att.attrelid AND des.objsubid=att.attnum AND des.classoid='pg_class'::regclass) LEFT OUTER JOIN pg_catalog.pg_sequence seq ON cs.oid=seq.seqrelid WHERE att.attrelid = 3740287157::oid AND att.attnum > 0 AND att.attisdropped IS FALSE ORDER BY att.attnum
Видим, что в запросе очень много обращений поpg_class, pg_namespace, и понимаем, что системные индексы — тоже b-tree. Любой уважающий себя запрос, скорее всего, хочет узнать информацию о таблицах, которые в нём участвуют. А вся информация об этих таблицах хранится опять же в таблицах.
В ERP 1С много таблиц и полей, большой каталог. Чтобы запрос узнал, с чем имеет дело, он идёт в системный каталог, тот идёт в свои индексы. А если эти индексы повреждены, даже чтение метаданных превращается в прогулку по кругу. Так появилась гипотеза: бэкенд застревает не в пользовательских, а в системных индексах.

Написали патч, чтобы вообще не использовать системные индексы. С ним стало полегче: что-то начало выполняться.
Попробовали REINDEX SYSTEM, но за ночь он не завершился.
А потом обнаружили, что патч мы писали зря: в PostgreSQL уже есть параметр ignore_system_indexes, который говорит серверу, чтобы при чтении тот не использовал системные индексы. Но и это не панацея, ведь на записи сервер всё равно может упереться в системные индексы.
Как вытащить данные
Системные индексы отключили и ставим следующую цель — вытащить данные. pg_dump начали с самой критичной для производства базы, и он выполнялся очень долго.
Решили посмотреть кусок SELECT в stat_activity с помощью crash_info:
SELECT t.tableoid, t.oid, ..., pg_catalog.pg_get_indexdef(i.indexrelid) AS indexdef, ... FROM unnest('{16385, ... ,136377}'::pg_catalog.oid[]) AS src(tbloid) JOIN pg_catalog.pg_index i ON (src.tbloid = i.indrelid) JOIN pg_catalog.pg_class t ..., indexname
Снова сигнал 40.
И выясняется довольно обидная вещь: даже с --data-only pg_dump всё равно собирает информацию об индексах, в том числе вызывает pg_get_indexdef(...) по множеству OID. Для здоровой базы это может быть просто внутренней подготовкой. Для базы, где чтение системных индексов отключено, а каталог огромный, это превращается в отдельное приключение.
То есть мы вроде попросили только данные, а pg_dump такой: «Конечно, только данные. Но сначала я всё-таки посмотрю определения индексов».
Бизнес простаивает уже сутки. Ждать по три часа до очередного падения на повреждённой таблице — не стратегия, а способ состариться.
Маленький патч и большая разница
Дальше была инженерная часть из серии «если инструмент мешает спасать данные, его надо аккуратно подпилить».
Патч к pg_dump был несложным: если включён --data-only, не надо вытаскивать определения индексов, которые для дампа данных не нужны. Флажок пробросили куда надо, регрессионные тесты прошли. С таким pg_dump дело пошло.
Дальше началась классика восстановления:
Запускаем дамп.
Он падает на повреждённой таблице.
Исключаем эту таблицу.
Запускаем снова.
Находим следующую проблему.
Повторяем.
Если проблема внутри таблицы, ищем повреждённые строки дихотомией. Условно делим диапазон пополам, смотрим, какая половина читается, какая — нет, и так сужаем круг. Удалить плохую строку на месте нельзя: DELETE тоже может упереться в повреждённые структуры. CREATE TABLE AS рядом тоже не вариант, если создание или запись опять задевает системные индексы.
Спасает отдельный живой кластер и вынос в него данных, например через FDW.
Примерно за сутки самую важную базу удалось поднять. Что-то потерялось, но оказалось несущественным. Бизнес потом перепроверял документы за сутки до падения, потому что гарантировать абсолютную корректность после такой аварии невозможно.
Остальные базы восстанавливали ещё около месяца. Некоторые размотало так, что смотреть было больно. Но производство ожило.
Зачем идти в upstream
На этом историю можно было бы закончить. Данные вытащили, клиента спасли, все устали, идём спать. Но мы решили пойти в апстрим и рассказать сообществу о произошедшем.
Вопрос был такой: почему pg_dump --data-only в тяжёлом сценарии с ignore_system_indexes тратит так много времени на то, что для дампа данных вроде бы не требуется?
Чтобы не идти в апстрим с настоящей клиентской базой, собрали искусственный пример из 15 тысяч таблиц по пять индексов и одной записи в каждой:
DO $$ DECLARE i integer; j integer; BEGIN FOR i IN 1..15000 LOOP EXECUTE 'CREATE TABLE tab' || i || ' AS SELECT 1 AS f'; FOR j IN 1..5 LOOP EXECUTE 'CREATE INDEX idx_tab' || i || '_' || j || ' ON tab' || i || '(f)'; END LOOP; END LOOP; END; $$;
Потом:
PGOPTIONS='-c ignore_system_indexes=on' time pg_dump --data-only test > test.sql
Результат на быстром сервере:
без патча — примерно 62 минуты;
с патчем — примерно 7 минут.
Разница такая, что в аварии между этими числами может поместиться рабочий день, ночная смена и несколько очень выразительных телефонных разговоров.
Обсуждение в PostgreSQL получилось характерное.
Сначала прозвучало: надо было идти в hackers, а не в bugs. Потом Том Лейн сказал, что «патч, возможно, правильный, но страшно что-нибудь задеть», и назвал этот случай unsupported bug. Формулировка «неподдерживаемая ошибка» занятная, то ли инженерный термин, то ли мираж.
Но Дэвид Роули предложил улучшить сам запрос. Первая попытка красиво ускорялась, но вмешивался кеш системного каталога. Вторая была серьёзнее, ускоряла примерно в 3,5 раза при ignore_system_indexes, но это был более сложный переписанный запрос.
А потом обсуждение заглохло словами Тома Лейна: «Выглядит многообещающе, но я слишком устал, чтобы ревьюить весь патч».
И это, пожалуй, тоже хороший вывод из истории. Даже у людей, которые десятилетиями держат на себе инфраструктурную цивилизацию, ревью не появляется из воздуха. Кто-то должен сесть и сделать работу.
Выводы
Что получилось в итоге:
Самые важные данные вытащили.
Клиенту где-то повезло: повреждённым оказалось то, без чего можно было жить.
Бизнес перепроверил критичный период вручную.
Админы поняли, как делать не надо, а потом, когда начали делать как надо, у них ещё и RAID развалился. Такая уж у этой истории карма: стоило признать необходимость нормальной инфраструктуры, как инфраструктура решила подчеркнуть тезис.
Главные уроки не новы, но после таких рассказов они звучат лучше любой методички:
Делайте бэкапы до аварии.
Архивы WAL нужны, чтобы восстановиться до нужной точки, а не до состояния «что удалось вытащить пинцетом».
Не удаляйте pg_wal, если не понимаете, что именно делаете. Впрочем, если понимаете, то, скорее всего, не удалите.
pg_resetwal — инструмент последнего рубежа. Он может помочь открыть дверь, но за дверью не обязательно окажется здоровая база.
Заранее подключайте диагностические инструменты, чтобы спасти время. В этой истории crash_info был не декоративной функцией, а способом понять, где именно бэкенд застрял, когда не было нормальной консоли и комфортной отладки.
И финальное — не сдавайтесь слишком рано. Даже если у вас нет логов и бэкапов, удалён WAL, база после pg_resetwal, запросы висят, а бизнес стоит, есть шанс выиграть. Здесь вам помогут инженерное упрямство, хорошие инструменты и привычка смотреть, что происходит на самом деле.
А бэкапы всё равно сделайте.