Содержание
<nav class="toc-nav"> <ul> <li><a href="#постановка">Постановка</a></li> <li><a href="#акт-первый-база-в-которой-ничего-не-было">Акт первый: база, в которой ничего не было</a></li> <li><a href="#акт-второй-чтение-кластера-без-сервера">Акт второй: чтение кластера без сервера</a></li> <li><a href="#акт-третий-бэкап-в-котором-данные-были">Акт третий: бэкап, в котором данные были</a></li> <li><a href="#акт-четвёртый-postgresql-10-всё-таки-появился">Акт четвёртый: PostgreSQL 10 всё-таки появился</a></li> <li><a href="#акт-пятый-репетиция-которая-всё-окупила">Акт пятый: репетиция, которая всё окупила</a></li> <li><a href="#что-показало-слияние">Что показало слияние</a></li> <li><a href="#финал">Финал</a></li> <li><a href="#пять-выводов-за-которые-заплачено-временем">Пять выводов, за которые заплачено временем</a></li> </ul> </nav>История одного воскресенья: три дампа, три кластера, разбор бинарных страниц и 729 электронных листков нетрудоспособности, вернувшихся на место.
Постановка
Медицинская организация, АРМ ЛПУ - программа Соцфонда для оформления электронных больничных. Однажды утром из неё исчезли все ЭЛН. Не часть, не за период - вообще все.
Под АРМ ЛПУ лежит PostgreSQL 10, база fss. Первая версия, которая приходит в голову любому админу: при обновлении программы инсталлятор молча поставил второй экземпляр СУБД, программа смотрит в пустую базу, а старая живёт рядом. Версия оказалась почти верной - но «почти» здесь стоило целого дня.
Отдельная сложность: серверы были на руках физически, но полностью изолированы от сети. Ни удалённого доступа, ни возможности что-то доустановить онлайн - только дампы, которые я снимал и переносил через флешку, и то, что уже стояло в системе.
Акт первый: база, в которой ничего не было
Первым я снял текстовый дамп схемы public на 4,6 МБ. Разбор простой: считаем строки между COPY <таблица> (...) FROM stdin; и \.. Результат - 15 111 строк, и все до одной справочные: МКБ-10 (14 742), справочник сообщений, роли медработников. Таблицы fc_eln_data_history, fc_eln_periods, certificate - пустые.
Но пустая таблица ещё ничего не доказывает. Данные могли удалить. И вот тут первая полезная улика:
SELECT pg_catalog.setval('fc_eln_data_history_seq', 1, false);
Все последовательности в дампе стояли на 1, is_called = false. Последовательность - счётчик, который не откатывается: если из неё хоть раз брали значение, оно останется в дампе навсегда, даже когда строки удалят. Единица означает ровно одно: в эту базу не записали ни одного больничного никогда. Это не удаление. Это чистая база.
Вывод для протокола: пустая таблица говорит «сейчас пусто», последовательность - «было или не было». Разница принципиальная, и смотреть надо на вторую.
Акт второй: чтение кластера без сервера
Дальше скопировал целый каталог data - 78 МБ, весь кластер PostgreSQL с Windows-машины. Правильный способ его прочитать - поднять PostgreSQL 10 и посмотреть. На Ubuntu 24.04 в репозиториях PostgreSQL 16, десятки нет, docker на машине отсутствует, root тоже.
Поэтому кластер пришлось читать напрямую, парсером на Python. Формат хранения PostgreSQL открыт и несложен:
- файл делится на страницы по 8 КБ;
- заголовок страницы - 24 байта, из него нужен
pd_lower, граница массива указателей; - дальше массив
ItemIdпо 4 байта: смещениеv & 0x7FFF, флаги(v >> 15) & 3, длина(v >> 17) & 0x7FFF; - у кортежа 23-байтный заголовок, в нём
t_infomask(жив ли кортеж) иt_hoff(где начинаются данные); - имена таблиц и их
relfilenodeлежат вpg_class, а самpg_classнаходится черезpg_filenode.map.
Двести строк кода - и каталог data читается без единого процесса СУБД. Только чтение, никакого риска для оригинала.
Результат: база fss, 77 таблиц в трёх схемах, 30 796 живых строк - и снова одни справочники. Зато в схеме mchd нашлись настоящие данные: 30 машиночитаемых доверенностей. То есть базой пользовались, просто не для больничных.
И контрольный выстрел - заголовок pg_control:
next XID : 47147
За три с половиной года жизни кластера - 47 тысяч транзакций. У работающей базы ЛПУ их были бы миллионы. Плюс файлы таблиц ЭЛН нулевого размера с датой изменения мая 2023: в них не писали ни разу. TRUNCATE обновил бы дату и сменил relfilenode, так что и эта версия отпала.
Акт третий: бэкап, в котором данные были
Третьим снял fss.backup - 38 МБ в custom-формате pg_dump. Который, напомню, нечем распаковать: pg_restore тоже часть отсутствующего PostgreSQL.
Формат PGDMP документирован исходниками и разбирается за вечер: заголовок с версией, TOC с описанием объектов, затем блоки данных - каждый со своим dumpId и потоком zlib, нарезанным на чанки «длина + байты».
Внутри оказалось то, что искали: 410 больничных за 23.12.2019 - 04.03.2024, 624 периода лечения, 2 212 запросов в Соцфонд, девять врачей.
Тут же вылезла ловушка самодельного парсера. Мои числа стабильно на три превышали известные: МКБ-10 показывал 14 745 вместо 14 742. Причина - я считал переводы строки, а в конце каждого блока идёт служебный хвост: строка \. и два пустых перевода. Поправка на три, и цифры сошлись с эталоном до единицы.
Вывод для протокола: самодельный парсер обязан проверяться на данных с заранее известным ответом. Без такой сверки он выдаёт правдоподобную чушь.
Акт четвёртый: PostgreSQL 10 всё-таки появился
Дальше нужно было не просто прочитать, а восстановить и проверить. Ставить на сервер можно было что угодно - но ни sudo, ни компилятора, ни заголовочных файлов там не было, а скачать их неоткуда: сеть отрезана.
Выручили standalone-сборки EDB: единственный tar.gz на 162 МБ, распаковывается в домашний каталог и работает. Версия 10.23 - ровно та же, что сняла дамп.
Дальше трюк, который сэкономил остаток дня: кластер PostgreSQL 10, созданный на Windows, запускается на Linux. Архитектура та же, размер страницы тот же, контрольные суммы страниц выключены, все параметры pg_control совпадают. Нужно лишь поправить копию каталога:
chmod 700, иначе сервер откажется стартовать;dynamic_shared_memory_type = posixвместоwindows;pg_hba.confнаtrust, удалитьpostmaster.pid.
Осталось одно препятствие. В pg_database прописана локаль Russian_Russia.1251, которой на Linux нет, и сервер падает с FATAL до того, как пустит в однопользовательский режим. Классический совет «зайди в single-user и поправь» не работает.
Решение - побайтовая правка. Поля datcollate и datctype имеют тип name, фиксированные 64 байта, смещения 72 и 136 от начала данных кортежа. Пишем C и 63 нуля - длина не меняется, страница остаётся валидной, контрольных сумм нет. Четыре строки в pg_database, и кластер с Windows поднимается на Linux.
Теперь можно было проверять настоящим SQL. Парсер, кстати, не ошибся ни в одной цифре.
Акт пятый: репетиция, которая всё окупила
Появилась возможность делать то, ради чего всё затевалось: репетировать восстановление на копии боевой базы.
Первый же прогон поймал ошибку в моём собственном плане восстановления. После заливки я собирался выставить последовательности так:
SELECT setval('fc_requests_seq', (SELECT COALESCE(MAX(id),1) FROM fc_requests));
У таблицы fc_requests нет колонки id. Первичный ключ называется request_id. На боевой базе это была бы ошибка посреди процедуры, между заливкой и расстановкой счётчиков.
Дальше пошли грабли послабее, но того же рода. Нельзя было заливать в тот экземпляр, где схема новее: сверка показала 234 миграции против 26, и подмена базы снесла бы модуль доверенностей. Заливка одних и тех же данных дважды дублировала журналы, потому что дедупликация шла по идентификатору, а идентификаторы после переномеровки освобождались. Лечится сравнением по содержимому:
DELETE FROM t_err e USING (
SELECT md5((to_jsonb(p) - 'id')::text) AS h FROM public.fc_reestr_errors p
) k WHERE md5((to_jsonb(e) - 'id')::text) = k.h;
Три запуска подряд - цифры не меняются.
Что показало слияние
Когда добрался до копии настоящей продуктивной базы, картина сложилась окончательно.
Периоды не пересекались вообще: старый бэкап заканчивался 04.03.2024, продуктив начинался 14.05.2024. Идентификаторы больничных тоже: 5-498 против 545-902.
Но главное - в продуктиве лежали нетронутыми 2 212 запросов в Соцфонд и 23 сертификата подписи из старого бэкапа. Байт в байт, с теми же идентификаторами.
Это меняет диагноз целиком. База не подменялась и не терялась. Из неё удалили строки - ровно из трёх таблиц: сами больничные, периоды лечения, записи по уходу за членами семьи. Всё остальное осталось на месте. Так не выглядит ни сбой, ни миграция при обновлении. Так выглядит адресное удаление.
Финал
Подменять базу целиком в итоге не стал - у живой схемы оказалось на 208 миграций больше и отдельный модуль доверенностей, который такая подмена убила бы.
Вместо этого собрал доливку: отдельная схема с данными 2024 года на 749 КБ и SQL-скрипт, который одной транзакцией добавляет только недостающее. С картой идентификаторов, переклейкой внешних ключей, проверкой сертификатов по серийным номерам и VALIDATE CONSTRAINT до фиксации.
Итог: 729 больничных за 23.12.2019 - 27.09.2026. Все 410 восстановленных записей совпали с источником побайтно.
Не закрытой осталась дыра в 46 больничных за март-май 2024 - их нет ни в одном из существующих источников.
Пять выводов, за которые заплачено временем
1. Последовательности честнее таблиц. Пустая таблица не отвечает на вопрос «удалили или не было». setval(..., 1, false) и счётчик транзакций в pg_control - отвечают.
2. Отсутствие инструмента - не приговор. Формат хранения PostgreSQL и формат его дампов открыты. Двести строк на Python читают каталог data без сервера, ещё сто - custom-дамп без pg_restore. А потом выясняется, что бинарники всё-таки ставятся без root, а Windows-кластер поднимается на Linux.
3. Репетиция окупается с первого раза. Ошибка в имени колонки, не тот экземпляр, дубли при повторе, кодировка - всё это поймано на копиях. До боевой базы не дошло ничего.
4. Проверяй инструмент на известном ответе. Парсер, завышавший счёт на три строки, выглядел абсолютно убедительно.
5. Бэкап, который пишет всегда в один файл, - это не бэкап. Штатный скрипт из инструкции складывал дамп в C:\backup_db_fss\fss.backup с перезаписью. Отработай он по расписанию после пропажи данных - и восстанавливать было бы нечего. Ротация по дате стоит пятнадцати минут работы и однажды спасёт всё.