Хочу поделиться с вами моим первым успешным опытом восстановления полной работоспособности базы данных Postgres. С СУБД Postgres я познакомился пол года назад, до этого опыта администрирования баз данных у меня не было совсем.



Я работаю полу-DevOps инженером в крупной IT-компании. Наша компания занимается разработкой программного обеспечения для высоконагруженных сервисов, я же отвечаю за работоспособность, сопровождение и деплой. Передо мной поставили стандартную задачу: обновить приложение на одном сервере. Приложение написано на Django, во время обновления выполняются миграции (изменение структуры базы данных), и перед этим процессом мы снимаем полный дамп базы данных через стандартную программу pg_dump на всякий случай.

Во время снятия дампа возникла непредвиденная ошибка (версия Postgres – 9.5):

pg_dump: Oumping the contents of table “ws_log_smevlog” failed: PQgetResult() failed.
pg_dump: Error message from server: ERROR: invalid page in block 4123007 of relatton base/16490/21396989
pg_dump: The command was: COPY public.ws_log_smevlog [...]
pg_dunp: [parallel archtver] a worker process dled unexpectedly

Ошибка «invalid page in block» говорит о проблемах на уровне файловой системы, что очень нехорошо. На различных форумах предлагали сделать FULL VACUUM с опцией zero_damaged_pages для решения данной проблемы. Что же, попрробеум…

Подготовка к восстановлению


ВНИМАНИЕ! Обязательно сделайте резервную копию Postgres перед любой попыткой восстановить базу данных. Если у вас виртуальная машина, остановите базу данных и сделайте снепшот. Если нет возможности сделать снепшот, остановите базу и скопируйте содержимое каталога Postgres (включая wal-файлы) в надёжное место. Главное в нашем деле – не сделать хуже. Прочтите это.

Поскольку в целом база у меня работала, я ограничился обычным дампом базы данных, но исключил таблицу с повреждёнными данными (опция -T, --exclude-table=TABLE в pg_dump).

Сервер был физическим, снять снепшот было невозможно. Бекап снят, двигаемся дальше.

Проверка файловой системы


Перед попыткой восстановления базы данных необходимо убедиться, что у нас всё в порядке с самой файловой системой. И в случае ошибок исправить их, поскольку в противном случае можно сделать только хуже.

В моём случае файловая система с базой данных была примонтирована в «/srv» и тип был ext4.

Останавливаем базу данных: systemctl stop postgresql@9.5-main.service и проверяем, что файловая система никем не используется и её можно отмонтировать с помощью команды lsof:
lsof +D /srv

Мне пришлось ещё остановить базу данных redis, так как она тоже исползовала "/srv". Далее я отмонтировал /srv (umount).

Проверка файловой системы была выполнена с помощью утилиты e2fsck с ключиком -f (Force checking even if filesystem is marked clean):



Далее с помощью утилиты dumpe2fs (sudo dumpe2fs /dev/mapper/gu2--sys-srv | grep checked) можно убедиться, что проверка действительно была произведена:



e2fsck говорит, что проблем на уровне файловой системы ext4 не найдено, а это значит, что можно продолжать попытки восстановить базу данных, а точнее вернуться к vacuum full (само собой, необходимо примонтирвоать файловую систему обратно и запустить базу данных).

Если у вас сервер физический, то обязательно проверьте состояние дисков (через smartctl -a /dev/XXX) либо RAID-контроллера, чтобы убедиться, что проблема не на аппаратном уровне. В моём случае RAID оказался «железный», поэтому я попросил местного админа проверить состояние RAID (сервер был в нескольких сотнях километров от меня). Он сказал, что ошибок нет, а это значит, что мы точно можем начать восстановление.

Попытка 1: zero_damaged_pages


Подключаемся к базе через psql аккаунтом, обладающим правами суперпользователя. Нам нужен именно суперпользователь, т.к. опцию zero_damaged_pages может менять только он. В моём случае это postgres:

psql -h 127.0.0.1 -U postgres -s [database_name]

Опция zero_damaged_pages нужна для того, чтобы проигнорировать ошибки чтения (с сайта postgrespro):
При выявлении повреждённого заголовка страницы Postgres Pro обычно сообщает об ошибке и прерывает текущую транзакцию. Если параметр zero_damaged_pages включён, вместо этого система выдаёт предупреждение, обнуляет повреждённую страницу в памяти и продолжает обработку. Это поведение разрушает данные, а именно все строки в повреждённой странице.
Включаем опцию и пробуем делать full vacuum таблицы:

VACUUM FULL VERBOSE


К сожалению, неудача.

Мы столкнулись с аналогичной ошибкой:

INFO: vacuuming "“public.ws_log_smevlog”
WARNING: invalid page in block 4123007 of relation base/16400/21396989; zeroing out page
ERROR: unexpected chunk number 573 (expected 565) for toast value 21648541 in pg_toast_106070

pg_toast – механизм хранения «длинных данных» в Poetgres, если они не помещаются в одну страницу (по умолчанию 8кб).

Попытка 2: reindex


Первый совет из гугла не помог. После нескольких минут поиска я нашёл второй совет – сделать reindex повреждённой таблицы. Этот совет я встречал во многих местах, но он не внушал доверия. Сделаем reindex:

reindex table ws_log_smevlog



reindex завершился без проблем.

Однако это не помогло, VACUUM FULL аварийно завершался с аналогичной ошибкой. Поскольку я привык к неудачам, я стал искать советов в интернете дальше и наткнулся на довольно интересную статью.

Попытка 3: SELECT, LIMIT, OFFSET


В статье выше предлагали посмотреть таблицу построчно и удалить проблемные данные. Для начала необходимо было просмотреть все строки:

for ((i=0; i<"Number_of_rows_in_nodes"; i++ )); do psql -U "Username" "Database Name" -c "SELECT * FROM nodes LIMIT 1 offset $i" >/dev/null || echo $i; done

В моём случае таблица содержала 1 628 991 строк! По-хорошему необходимо было позаботиться о партициирвоании данных, но это тема для отдельного обсуждения. Была суббота, я запустил вот эту команду в tmux и пошёл спать:

for ((i=0; i<1628991; i++ )); do psql -U my_user -d my_database -c "SELECT * FROM ws_log_smevlog LIMIT 1 offset $i" >/dev/null || echo $i; done

К утру я решил проверить, как обстоят дела. К моему удивлению, я обнаружил, что за 20 часов было просканировано только 2% данных! Ждать 50 дней я не хотел. Очередной полный провал.

Но я не стал сдаваться. Мне стало интересно, почему же сканирование шло так долго. Из документации (опять на postgrespro) я узнал:
OFFSET указывает пропустить указанное число строк, прежде чем начать выдавать строки.
Если указано и OFFSET, и LIMIT, сначала система пропускает OFFSET строк, а затем начинает подсчитывать строки для ограничения LIMIT.

Применяя LIMIT, важно использовать также предложение ORDER BY, чтобы строки результата выдавались в определённом порядке. Иначе будут возвращаться непредсказуемые подмножества строк.
Очевидно, что вышенаписанная команда была ошибочной: во-первых, не было order by, результат мог получиться ошибочным. Во-вторых, Postgres сначала должен был просканировать и пропустить OFFSET-строк, и с возрастанием OFFSET производительность снижалась бы ещё сильнее.

Попытка 4: снять дамп в текстовом виде


Далее мне в голову пришла, казалось бы, гениальная идея: снять дамп в текстовом виде и проанализировать последнюю записанную строку.

Но для начала, ознакомимся со структурой таблицы ws_log_smevlog:



В нашем случае у нас есть столбец «id», который содержал уникальный идентификатор (счётчик) строки. План был такой:

  1. Начинаем снимать дамп в текстовом виде (в виде sql-команд)
  2. В определённый момент времени снятия дампа бы прервалось из-за ошибки, но тектовый файл всё равно сохранился бы на диске
  3. Смотрим конец текстового файла, тем самым мы находим идентификатор (id) последней строки, которая снялась успешно

Я начал снимать дамп в текстовом виде:

pg_dump -U my_user -d my_database -F p -t ws_log_smevlog -f ./my_dump.dump

Снятия дампа, как и ожидалось, прервался с той же самой ошибкой:

pg_dump: Error message from server: ERROR: invalid page in block 4123007 of relatton base/16490/21396989

Далее через tail я просмотрел конец дампа (tail -5 ./my_dump.dump) обнаружил, что дамп прервался на строке с id 186 525. «Значит, проблема в строке с id 186 526, она битая, её и надо удалить!» – подумал я. Но, сделав запрос в базу данных:
«select * from ws_log_smevlog where id=186529» обнаружилось, что с этой строкой всё нормально… Строки с индексами 186 530 — 186 540 тоже работали без проблем. Очередная «гениальная идея» провалилась. Позже я понял, почему так произошло: при удалении\изменении данных из таблицы они не удаляются физически, а помечаются как «мёртвые кортежи», далее приходит autovacuum и помечает эти строки удалёнными и разрешает использовать эти строки повторно. Для понимая, если данные в таблице меняются и включён autovacuum, то они не хранятся последовательно.

Попытка 5: SELECT, FROM, WHERE id=


Неудачи делают нас сильнее. Не стоит никогда сдаваться, нужно идти до конца и верить в себя и свои возможности. Поэтому я решил попробовать ешё один вариант: просто просмотреть все записи в базе данных по одному. Зная структуру моей таблицы (см. выше), у нас есть поле id, которое является уникальным (первичным ключом). В таблице у нас 1 628 991 строк и id идут по порядку, а это значит, что мы можем просто перербрать их по одному:

for ((i=1; i<1628991; i=$((i+1)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id=$i" >/dev/null || echo $i; done

Если кто не понимает, команда работает следующим образом: просматривает построчно таблицу и отправляет stdout в /dev/null, но если команда SELECT проваливается, то выводится текст ошибки (stderr отправляется в консоль) и выводится строка, содержащая ошибку (благодаря ||, которая означает, что у select возникли проблемы (код возврата команды не 0)).

Мне повезло, у меня были созданы индексы по полю id:



А это значит, что нахождение строки с нужным id не должен занимать много времени. В теории должно сработать. Что же, запускаем команду в tmux и идём спать.

К утру я обнаружил, что просмотрено около 90 000 записей, что составляет чуть более 5%. Отличный результат, если сравнивать с предыдущим способом (2%)! Но ждать 20 дней не хотелось…

Попытка 6: SELECT, FROM, WHERE id >= and id <


У заказчика под БД был выделен отличный сервер: двухпроцессорный Intel Xeon E5-2697 v2, в нашем расположении было целых 48 потоков! Нагрузка на сервере была средняя, мы без особых проблем могли забрать около 20-ти потоков. Оперативной памяти тоже было достаточно: аж 384 гигабайт!

Поэтому команду нужно было распараллелить:

for ((i=1; i<1628991; i=$((i+1)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id=$i" >/dev/null || echo $i; done

Тут можно было написать красивый и элегантный скрипт, но я выбрал наиболее быстрый способ распараллеливания: разбить диапазон 0-1628991 вручную на интервалы по 100 000 записей и запустить отдельно 16 команд вида:

for ((i=N; i<M; i=$((i+1)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id=$i" >/dev/null || echo $i; done

Но это не всё. По идее, подключение к базе данных тоже отнимает какое-то время и системные ресурсы. Подключать 1 628 991 было не очень разумно, согласитесь. Поэтому давайте при одном подключении извлекать 1000 строк вместо одной. В итоге команда преобразилоась в это:

for ((i=N; i<M; i=$((i+1000)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id>=$i and id<$((i+1000))" >/dev/null || echo $i; done

Открываем 16 окон в сессии tmux и запускаем команды:
1) for ((i=0; i<100000; i=$((i+1000)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id>=$i and id<$((i+1000))" >/dev/null || echo $i; done
2) for ((i=100000; i<200000; i=$((i+1000)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id>=$i and id<$((i+1000))" >/dev/null || echo $i; done
…
15) for ((i=1400000; i<1500000; i=$((i+1000)) )); do psql -U my_user -d my_database -c "SELECT * FROM ws_log_smevlog where id>=$i and id<$((i+1000))" >/dev/null || echo $i; done
16) for ((i=1500000; i<1628991; i=$((i+1000)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id>=$i and id<$((i+1000))" >/dev/null || echo $i; done
Через день я получил первые результаты! А именно (значения XXX и ZZZ уже не сохранились):

ERROR:  missing chunk number 0 for toast value 37837571 in pg_toast_106070
829000
ERROR:  missing chunk number 0 for toast value XXX in pg_toast_106070
829000
ERROR:  missing chunk number 0 for toast value ZZZ in pg_toast_106070
146000

Это значит, что у нас три строки содержат ошибку. id первой и второй проблемной записи находились между 829 000 и 830 000, id третьей – между 146 000 и 147 000. Далее нам предстояло просто найти точное значение id проблемных записей. Для этого просматриваем наш диапазон с проблемными записями с шагом 1 и идентифицируем id:
for ((i=829000; i<830000; i=$((i+1)) )); do psql -U my_user -d my_database -c "SELECT * FROM ws_log_smevlog where id=$i" >/dev/null || echo $i; done
829417
ERROR:  unexpected chunk number 2 (expected 0) for toast value 37837843 in pg_toast_106070
829449
for ((i=146000; i<147000; i=$((i+1)) )); do psql -U my_user -d my_database -c "SELECT * FROM ws_log_smevlog where id=$i" >/dev/null || echo $i; done
829417
ERROR:  unexpected chunk number ZZZ (expected 0) for toast value XXX in pg_toast_106070
146911

Счастливый финал


Мы нашли проблемные строки. Заходим в базу через psql и пробуем их удалить:

my_database=# delete from ws_log_smevlog where id=829417;
DELETE 1
my_database=# delete from ws_log_smevlog where id=829449;
DELETE 1
my_database=# delete from ws_log_smevlog where id=146911;
DELETE 1

К моему удивлению, записи удалились без каких-либо проблем даже без опции zero_damaged_pages.

Затем я подключился к базе, сделал VACUUM FULL (думаю делать было необязательно), и, наконец, успешно снял бекап с помощью pg_dump. Дамп снялся без каких либо ошибок! Проблему удалось решить таким вот тупейшим способом. Радости не было предела, после стольких неудач удалось найти решение!

Благодарности и заключение


Вот такой получился мой первый опыт восстановления реальной базы данных Postgres. Этот опыт я запомню надолго.

Ну и напоследок, хотел бы сказать спасибо компании PostgresPro за переведённую документацию на русский язык и за полностью бесплатные online-курсы, которые очень сильно помогли во время анализа проблемы.

Комментарии (31)


  1. dikkini
    24.11.2019 23:44
    +1

    ws_smev_log — СМЭВ? НИИ Восход?


    1. vodopad Автор
      24.11.2019 23:47

      Да, СМЭВ. Но нет, не «Восход».


      1. Aytuar
        27.11.2019 01:13

        Думаю «БАРС ГРУП» :-))
        Я сам такое проделывал когда там работал, и сразу сделал delete дохлым строкам.
        Нужно было хотя бы имя таблицы поменять чтобы не палиться. )


  1. gecube
    24.11.2019 23:46

    Эм… А если в этих данных были важные данные? То логичный ход — взять бекап этой таблицы, и сделать следующее: исторические данные взять из бекапа, а хвост из живой базы, склеить — это и будет продакшен версия на текущий момент.
    Если же в базе тупо был лог действий, который был не особо нужен — почему нельзя было тупо таблицу "обнулить" и начать с начала? Или FK мешали бы?
    Ну, что — опыт правда крутой, но статья какая-то недосказанная.
    И, да, первоисточник проблемы нашли? А где гарантия, что база опять не покорраптится?
    +1 поставил, но ждём… Продолжения.


    1. vodopad Автор
      24.11.2019 23:57

      А если в этих данных были важные данные?

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

      К сожалению, как это обычно бывает, бекап, который содержал таблицу ws_smev_log, запускался раз в неделю (командой pgdumpall). Но за успешностью его выполнения никто не следил, он просто аварийно завершался.
      В остальные дни бекап запускался с помощью pg_dump, но таблица ws_smev_log исключалась, чтобы сэкономить дисковое пространство. Поэтому бекапов таблицы ws_smev_log не было.
      Если же в базе тупо был лог действий, который был не особо нужен — почему нельзя было тупо таблицу «обнулить» и начать с начала?

      Данные в таблице не были очень критичными, но и просто так удалять их было нельзя. Обнулить не разрешили.


      1. dbax
        25.11.2019 14:03

        То-есть у Вас бэкапом называется pumpall?


        1. vodopad Автор
          25.11.2019 17:29

          да, раз в неделю делаем pg_dumapall, каждый день делается просто pg_dump с исключением таблицы ws_smev_log.


          1. dbax
            25.11.2019 17:33

            грустно.
            или это из серии «база особенно не нужна»?


            1. vodopad Автор
              25.11.2019 17:36

              А как нужно правильно? С помощью barman и подобных утилит?


              1. MountyPiton
                26.11.2019 13:14
                +1

                Например можно вот так: www.postgresql.org/docs/9.5/continuous-archiving.html
                С логами и восстановлением на нужное время. Это быстрее дампирования.


                1. gecube
                  26.11.2019 22:05

                  Ну, это очевидный трейдоф. Либо мы размазываем бекап по времени и получается стриминг. Либо мы забиваем на это и приходится выделять точные моменты времени для полного снапшота, но это "ударная" нагрузка на сервер, да и бекап может долго делаться.


                  1. Melkij
                    26.11.2019 23:39
                    +1

                    tradeoff там по объёму данных на бекап-машине (особенно для задачи типа «надо восстановить таблицу за 2017 год»). И по времени разворачивания бекапа тоже. При том в обе стороны в зависимости от условий. Pitr вообще-то штука не очень-то быстрая, т.к. имеет склонность упираться в одно ядро CPU и крайне критичная к латентности io. Тогда как pg_restore можно и параллелить.
                    Ну и ещё такой фокус: по-моему мне за несколько лет работы нужно было восстанавливать именно целиком инстанс только один раз, а обычно задача «вытащить несколько строк из одной таблицы на любую дату до Х». Что куда быстрее делается из custom/directory форматов pg_dump.

                    Поэтому pg_dump или basebackup с архивом wal — выбирать надо по ситуации. Можно выбирать оба и комбинировать подходящим для именно ваших требований способом.


  1. ky0
    24.11.2019 23:52

    Теперь вы наверняка поняли, зачем нужна реплика. Что, если бы проблемных строк было не три, а тридцать тысяч и в каждой — какие-нибудь финансовые данные клиента, не отражённые до сих пор в бэкапе?


    1. gecube
      24.11.2019 23:56

      Реплика, к сожалению, не спасет от drop database. От логического разрушения файлов БД — да


      1. ky0
        25.11.2019 00:03

        Она и не должна, в общем-то. Но в целом — можно же добавить вторую, с задержкой применения на пару часов.


  1. SLASH_CyberPunk
    25.11.2019 00:19
    +2

    Наглядное пособие, для чего нужна была задача от гугла про небоскреб и два стеклянных шара.


  1. Melkij
    25.11.2019 00:36

    В моём случае таблица содержала 1 628 991 строк! По-хорошему необходимо было позаботиться о партициирвоании данных

    Нет, не надо было. Всего лишь полтора миллиона строк — это где от партицирования проблем больше чем пользы.

    При тихом повреждении проверяйте диски и RAM. Даже если она ECC. Тихое повреждение страницы по вине сбоя ECC плашки памяти наблюдал сам.

    Затем я подключился к базе, сделал VACUUM FULL (думаю делать было необязательно),

    Обязательно. Но не всей базы, а только этой таблицы.

    Ну а в остальном подход верный:
    — сделать физическую копию и экспериментировать на копии
    — проверить железо
    — сверить данные с репликой если есть
    — определить повреждённые данные. Если повреждён toast как у вас — попытаться прочитать строки не select *, а добавляя поля переменной длины по одному. Возможно повреждено только одно из полей. reindex — если сломан индекс. По системному каталогу путь base/16490/21396989 можно сопоставить с логическим объектом
    — удалить повреждённые либо insert… select'ом создать копию таблицы рядом и удалить старую повреждённую таблицу.

    zero_damaged_pages лучше не трогать.

    PS: ну и имхо некорректно писать про postgresql и ссылаться на документацию стороннего форка.


    1. oxff
      25.11.2019 00:47
      +1

      PostgresPro переводят самую обычную официальную документацию. Плюс расширенные версии для двух своих форков (Pro Standard и Enterprise). Итого доступны три перевода. Там даже можно открывать параллельно рус и англ версии.


      1. Melkij
        25.11.2019 02:11

        Так ссылки и цитаты же как раз именно на форк приведены. Именно к тому и мой ps.


  1. oxff
    25.11.2019 01:06
    +1

    Чтоб не ждать так долго, можно было бы использовать метод половинного деления, каждый раз делая INSERT FROM SELECT во временную таблицу. Табличка небольшая, всего 1.6 миллиона записей.
    Делим диапазон ID пополам (1..800K; 800K..1.6M) и копируем данные во временную таблицу. Запрос для одной из половинок завершится с ошибкой. Берём эту половину диапазона и выполняем два запроса для четвертинок (напр, 800K..1.2M;1.2M..1.6M) и так далее. Несколько минут — и вы получили бы ответ.


    1. Tatikoma
      25.11.2019 11:15

      Запрос для одной из половинок завершится с ошибкой.

      Нет. Запрос для обоих половинок имеет шанс завершится с ошибкой.


      1. oxff
        25.11.2019 17:22

        И это будет означать что у нас не одна проблемная строка, а несколько. В этом случае мы проходим по обеим ветвям, которые, в свою очередь, могут также породить новые ветви. Написать простенький скрипт с рекурсией на Bash, Python, PL/pgSQL и т.п. займет минут 15. Запустить, подождать несколько минут — и вуаля.


        1. Tatikoma
          25.11.2019 17:31
          +1

          Разумеется. Я лишь за точность формулировок. По изначальной формулировке вторая половина не будет проверяться, в случае если ошибка нашлась в первой, — это следствие очевидной оптимизации.


      1. Able1991
        25.11.2019 17:29

        Их тоже разбить на половинки) бинарный поиск же


  1. mvv-rus
    25.11.2019 02:58

    А что, разве в Postgres нет штатной утилиты для восстановления целостности БД с потерей информации, такой как gfix в Inetrbase и его потомках (или DBCC CHECKDB в MS SQL, или eseutil /p для БД на Extensible Storage Engine от MS — Exchange и системные БД компонентов Windows Server)? Упущение, однако, IMHO.
    Ибо в Interbase в некие древние времена (ещё когда Delphi было модным средством разработки приложений) мне как-то пришлось воспользоваться gfix на боевой БД — и это было сильно проще, чем описанные в статье мучения.


  1. saddy
    25.11.2019 03:35

    Использовал функцию отсюда которая работала несколько минут для миллиона строк и позволяла удалить нужную строку даже если не было своего уникального поля-идентификатора. (восстанавливал таблицы 1C)


    1. maledog
      25.11.2019 11:58

      Мне тоже подумалось, что вызывать 1 500 000 раз консольную утилиту с выводом с консоль же не очень хорошая затея.


  1. darthunix
    25.11.2019 07:44

    Замечу, что всех описанных в статье страданий можно было избежать, просто посмотрев битую страницу через расширение pageinspect (https://www.postgresql.org/docs/current/pageinspect.html)


    1. yleo
      25.11.2019 14:31

      Угу, и дополню:


      • Можно было-бы увидеть и понять что было в испорченных строках. В результате (вероятно) восстановить данные полностью (либо ввести вручную), а также пронять причину ошибки (аппаратный сбой или баг в коде postgres).
      • Первый шаг автора не верный. Всё-таки стоило делать копию всех данных (ибо они не разрушены физически, т.е. нет проблем на HDD).


  1. puyol_dev2
    25.11.2019 21:43

    Вопрос к автору — когда снимали текстовый дамп, он случайно не обрывается на проблемной строчке?


    1. vodopad Автор
      25.11.2019 21:47

      Нет, про это написано в пункте «Попытка 4: снять дамп в текстовом виде».