С чего всё началось

Когда я пришёл на должность помощника DBA, я довольно быстро обнаружил, как в компании фиксируются изменения схемы боевых баз. Каждую ночь в 00:00 запускался скрипт: он выгружал всю схему Firebird одним файлом через isql -x, а затем разрезал этот файл на части. Кусок, создающий таблицу, попадал в каталог 01_TABLES отдельным файлом, названным по имени таблицы; процедуры — в свой каталог, триггеры — в свой. Получалось дерево, которое затем отправлялось в систему контроля версий.

На первый взгляд, это работает: схема лежит в git, изменения видны диффами. Проблема оказалась в другом. Разработчиков много, правки в схему вносятся вручную, а в истории остаётся один коммит в сутки от служебной учётной записи. Кто изменил процедуру, зачем и в каком порядке шли изменения внутри дня — по такой истории не восстановить. Диффы есть, а ответственности и контекста нет: изменения фактически анонимны.

Разбираться я решил не с середины, а с начала — с того, чем схема вообще снимается с базы.

Почему разрезание одного большого файла рассыпается

Прежде чем писать что‑то своё, я выписал, что именно ломается в подходе «выгрузить монолит и нарезать».

Права и комментарии оказываются не там, где объект. isql -x выдаёт гранты и комментарии единым потоком в конце скрипта. При нарезке они попадают в общие файлы — GRANTS.sql и COMMENTS.sql на всю базу. В результате выдача права на таблицу выглядит в истории как изменение строки в сорокакилобайтном файле, а не как изменение самой таблицы. При ревью такое не заметишь, а именно права чаще всего и меняют «тихо».

Нарезка текста хрупкая по своей природе. Разделитель приходится угадывать по тексту DDL. Тело процедуры с кириллицей в комментариях, вложенные блоки, ключевые слова внутри строковых литералов — каждый такой случай приходится обходить заново. Это парсер SQL, который никто не собирался писать, но который всё равно приходится поддерживать.

Нет атомарности. Если выгрузка оборвалась на середине, в каталоге остаётся неполное дерево. Коммитер это дерево послушно коммитит, и в истории появляются удаления объектов, которых никто не удалял. Ложная запись в журнале изменений хуже отсутствующей: на неё можно опереться в разборе инцидента и прийти к неверному выводу.

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

Смешанные кодировки убивают весь дамп. Базам, которые ведут родословную от InterBase, лет больше, чем мне на этой должности. Метаданные в них лежат в однобайтовой кодировке, но отдельные объекты содержат символы, которых в этой кодировке нет. Один такой объект — и монолитная выгрузка падает целиком.

Что я искал и чего не нашёл

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

Так появился fb-dump — сокращение от firebird‑dump.

Требование, которое определило всё остальное

Я выбрал микро‑архитектуру: каждый инструмент делает одну вещь и ничего не знает о соседях. Дампер снимает схему, коммитер кладёт дерево в git, планировщик решает, когда всё это запускать, применятор поднимает схему в пустую базу. Любым из них можно пользоваться отдельно, не разворачивая остальные.

Из этого требования вытекли остальные:

  • одна ответственность: база на входе, дерево файлов на выходе, и ничего больше;

  • никаких скрытых побочных эффектов: инструмент не создаёт журналов рядом с собой, не трогает .git, не читает .env;

  • детерминированный вывод: две выгрузки неизменной схемы дают одинаковые байты, иначе диффы забиваются шумом;

  • всё или ничего: неполного результата на диске быть не должно;

  • никакого знания о потребителе: инструменту всё равно, что с деревом произойдёт дальше.

Дисциплина полезная и неудобная одновременно. Каждый раз, когда хочется добавить «ещё маленькую удобную функцию», приходится спрашивать, чья это на самом деле работа. Половину идей я так и отложил в отдельные инструменты.

Как инструмент устроен

fb-dump написан на Python, из зависимостей — только firebird-driver для соединения и firebird-lib для доступа к схеме. isql не используется вовсе.

Ключевая деталь: firebird-lib даёт объектный доступ к системному каталогу. У соединения есть свойство schema, у схемы — коллекции tables, procedures, triggers и прочие, а у каждого объекта — метод get_sql_for, который возвращает его собственный DDL. Тип и имя объекта берутся из каталога, системные объекты отфильтровываются по флагу, а не по списку имён, который пришлось бы поддерживать руками.

Отдельно оговорю то, что сам поначалу считал плюсом: выигрыша в скорости здесь нет. firebird-lib читает метаданные объектов лениво, по одному, так что полная выгрузка базы на тринадцать тысяч объектов занимает у меня около двух минут — isql -x быстрее. Выигрыш в другом: в структуре вывода, в отсутствии парсера текста и в том, что один объект выгружается за секунды, без полного прогона.

Один объект — один файл

Главное решение по формату: файл содержит полное определение объекта. Не только CREATE TABLE, а всё, что к таблице относится. Вот реальный файл из выгрузки:

CREATE TABLE ZAKAZ_SPEC (  DAT_ BAS$DATE NOT NULL,  CARDINDEX BAS$ID,  QUANTITY BAS$SUMMA NOT NULL,  WEEK_NUMBERS BAS$VAR_255,  INROAD BAS$SUMMA
);
ALTER TABLE ZAKAZ_SPEC ADD CONSTRAINT U_ZAKAZ_SPEC_DAT_CARDINDEX  UNIQUE (DAT_,CARDINDEX)  USING ASCENDING INDEX U_ZAKAZ_SPEC_DAT_CARDINDEX;
COMMENT ON COLUMN ZAKAZ_SPEC.DAT_ IS 'date_type=timestamp';
GRANT SELECT ON ZAKAZ_SPEC TO USER AA;

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

Первое: столбцы описаны через домены (BAS$DATE, BAS$ID) — так, как они заданы в базе, без разворачивания в базовые типы. Домены выгружаются в свой каталог и остаются отдельными объектами со своей историей.

Второе: ограничения вынесены из CREATE TABLE в именованные ALTER TABLE ... ADD CONSTRAINT. Если завтра ограничение переименуют или изменят набор столбцов, дифф покажет одну инструкцию, а не переписанную с нуля таблицу. NOT NULL при этом остаётся частью описания столбца — в Firebird это его свойство, а не отдельный объект.

Третье, и для меня самое важное: комментарий и грант лежат здесь же. Именно этого не хватало в старой схеме с общими GRANTS.sql и COMMENTS.sql. Теперь выдача права на таблицу — это дифф файла таблицы. Обратная сторона решения: файл перестаёт быть «чистым DDL» и становится описанием объекта целиком. Меня это устраивает, потому что читает файл человек, а применяет — отдельный инструмент.

Грантополучатель, кстати, всегда указан с ключевым словом: TO USER AA, а не TO AA. Firebird при разборе неуточнённого имени сначала ищет роль, и если в базе окажутся пользователь и роль с одинаковым именем, воспроизведение дампа выдало бы права не тому. Мелочь, которая проявляется один раз в жизни и очень некстати.

Каталоги — это данные, а не код

В старом дереве имена каталогов вида 01_TABLES были прошиты в скрипт нарезки. Мне это не нравилось: у разных потребителей разные привычки, кому‑то нужны номера для порядка применения, кому‑то — русские имена, кому‑то плоский каталог без вложенности.

Поэтому раскладка задаётся данными. Есть три готовых набора (с номерами, без номеров, плоский), а если ни один не подходит — небольшой файл TOML:

base = "plain"
[dirs]
table = "Таблицы"
index = "Таблицы/Индексы"
procedure = "Процедуры"

Здесь base — набор, от которого отталкиваемся; категории, которые не упомянуты, берут имена из него. Каталоги можно вкладывать друг в друга, имена — любые, какие принимает файловая система.

Дополнительно каждое дерево несёт файл .fb-dump.toml со своей действующей раскладкой. Это даёт три вещи сразу. Дерево описывает само себя: потребителю не нужно догадываться, где лежат процедуры. Точечная выгрузка в существующее дерево читает этот файл и кладёт объект туда, куда положено, без дополнительных ключей. И он же служит признаком «это дерево сделал я»: в непустой каталог без такого файла инструмент писать откажется, чтобы случайный --out ~/work не стоил вам содержимого каталога.

Всё или ничего

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

Если хотя бы один объект прочитать не удалось — нет прав, странные метаданные, что угодно — инструмент не пишет ничего и возвращает код 3. Логика простая: неполное дерево коммитер превратит в удаления объектов, то есть в ложные записи в истории. Тот, кому неполный результат всё‑таки нужен, просит его явным ключом.

При этом отдельный сбойный объект не прерывает чтение: прогон доходит до конца, собирает список неудач и только после этого решает судьбу дерева. Разница существенная. Если бы прогон падал на первом же объекте, о втором и третьем я бы узнал только со следующей попытки, а на большой базе каждая попытка — это минуты.

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

Одна стрелка против двух тысяч процедур

Самая показательная история вышла с кодировками — на той самой копии боевой базы, ради которой всё и затевалось.

Под UTF-8 чтение падает сразу: метаданные хранятся в WIN1251, и первый же кириллический байт даёт ошибку декодирования. Указываю WIN1251 — и получаю другую ошибку: Cannot transliterate character between character sets. То есть часть метаданных в этой кодировке не представима.

Виновника я нашёл, сравнив выгрузку с исходником: в теле одной процедуры был комментарий с символом . В WIN1251 такого символа нет. А firebird-lib загружает коллекцию одним запросом, целиком — поэтому единственная стрелка в одном комментарии делала нечитаемыми все 2985 процедур базы.

Отсюда появился запасной канал: основная кодировка задаётся ключом, а для коллекции, которая на ней не прочиталась, инструмент лениво открывает второе соединение с другой кодировкой и дочитывает её оттуда, сообщая об этом предупреждением. Ничего не зашито: обе кодировки задаёт пользователь, потому что «WIN1251 против UTF-8» — это моя конкретная база, а не общее правило.

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

Проверка на живой базе

Офлайн‑тесты — это хорошо, но настоящую проверку даёт только реальная база. Взял копию боевой: 13 315 объектов — 1014 таблиц, 2985 процедур, 2821 триггер, 5540 генераторов, 812 индексов, 78 доменов, 35 исключений, 24 представления, 4 функции, 2 роли.

Полная выгрузка заняла 2 минуты 9 секунд, ни один объект не пропущен, на выходе 13 316 файлов — по одному на объект плюс файл уровня базы с диалектом, кодировкой и правами на уровне базы данных. Три выгрузки подряд дали побайтово одинаковые деревья.

Отдельно я сверил результат с деревом, которое строит наш внутренний инструмент по метаданным: DDL таблицы совпало символ в символ, а в моём файле вдобавок оказались комментарий столбца и грант, которых в старом дереве не было — они лежали в общих файлах.

А потом произошло то, ради чего вообще всё это затевалось. Между двумя выгрузками, сделанными с разницей в четверть часа, дерево изменилось. Из 13 316 файлов различался ровно один:

-         and oh.id = 17515837 --17509513
+         and oh.id = 17515841 -- 17515837 --17509513

Кто‑то правил процедуру прямо в базе: подставил новый идентификатор заказа, а прежний оставил комментарием — рядом с тем, который был до него. Ни задачи, ни автора, ни причины. Именно эти правки я и хочу перестать терять, и именно так они выглядят, когда их наконец видно.

Границы применимости

Чтобы не создавать ложных ожиданий, перечислю, чего инструмент не делает.

Выгрузка не является снимком на один момент времени. Метаданные читаются в транзакции уровня read committed, и правка, закоммиченная посреди прогона, может попасть в одни коллекции и не попасть в другие. Для дампа схемы это приемлемо, но выгружать лучше тогда, когда схему не правят. Выбор изоляции здесь не случаен: у нас действует правило снимать метаданные без ожидания на блокировках, чтобы читатель никогда не подвешивал процесс.

Вывод не совпадает байт в байт с isql -x: firebird-lib расставляет отступы и порядок предложений иначе, оставаясь семантически эквивалентным. Первая выгрузка поверх дерева, сделанного старым способом, даст один большой дифф, и это нормально.

Комментарии к параметрам функций не выгружаются — firebird-lib не предоставляет для них соответствующей операции. Комментарии к параметрам процедур, к столбцам и к самим объектам выгружаются.

Настройка SQL SECURITY снимается только для таблиц; для процедур, функций, триггеров и пакетов библиотека её не читает. Системные привилегии ролей тоже пока не выгружаются.

Теневые копии, BLOB‑фильтры, пользователи и сопоставления имён не считаются здесь объектами схемы. Пользователи вообще живут в отдельной базе безопасности, а не в схеме.

Firebird 2.5 и более ранние версии вне охвата: там другой драйвер и другой системный каталог.

Что дальше

На выходе первой части — самостоятельный инструмент, который снимает схему целиком или поштучно, кладёт её деревом в нужном мне формате и не знает ничего о том, что будет дальше. Помимо конвейера он уже пригодился в другом месте: наш внутренний MCP‑инструмент ищет по метаданным базы, и выгруженное дерево — как раз то, по чему удобно искать.

Во второй части — то, чего дампер принципиально не умеет: он показывает состояние, но не отвечает на вопрос «кто и когда». Речь пойдёт о механизмах внутри самой базы, которые фиксируют изменения схемы в момент, когда они происходят, и становятся тем источником правды, который потом опрашивает следующий инструмент.

Код инструмента открыт, лицензия MIT: fb‑dump. Замечаниям по делу буду рад, в том числе критическим — я здесь скорее в начале пути, чем в конце.

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


  1. kmatveev
    27.08.2026 15:04

    Очень неплохо.

    Процессы в вашей организации, конечно, грустные, git должен быть первичен, а база вторична. Но вообще такое, чтобы все на живой базе жили - не редкость.

    Насчёт чтения метаданных в транзакции read commited не понял: чем snapshot не подошёл? Вроде бы можно читать, не блокируя никаких других читателей и писателей.

    И ещё хотел спросить: из полученных скриптов можно создать новую базу? Если да, то как в этом случае определяется порядок накатки для таблиц, чтобы можно было foreigh key ограничения накатывать.


    1. levge
      27.08.2026 15:04

      К сожалению это частая ситуация, у меня тоже ночью бежит GitHub action который через power shell создаёт отдельный файл для каждого объекта, и комитит изменения. Azure SQL Server. Много раз пригодилось, а потом я нашел этому интересное применение, наверно надо отдельным постом, если кому-то интересно.


    1. deliciousNesquik Автор
      27.08.2026 15:04

      Спасибо большое за Ваш комментарий, мне очень приятно, что именно Вы мне написали, автор статей про внутренности Firebird, которые я читал, и было очень интересно. Тысячекратно благодарен вашим трудам!

      Про первичность git согласен полностью — к этому и пытаемся идти, только двигаться приходится от базы, а не от чистого листа. Править схему прямо в production здесь привычка десятилетней выдержки, и надо бы ее ломать инструментами которые вводим в эксплуатацию, а не просто говоря разработчикам что так нельзя) сколько не говори лучше делать не станут, а вот когда уже чисто физически не смогут сделать не так как хотят — уже другое.

      Про изоляцию — замечание по делу. Snapshot concurrency, не table stability действительно ничего не блокирует и дал бы согласованный слепок, а мне он для дампа схемы нужнее, чем текущее поведение. Read committed достался инструменту от общего правила внутри комманды: у нас все транзакции идут read committed + rec version + no wait.

      Про создание базы из дерева — здесь слабовато у меня получилось, и я его в статье обозначил слишком вскользь наверное или мягко. Простой склейкой файлов базу не поднять, и как раз из-за foreign key: ограничения лежат внутри файла своей таблицы (это осознанный размен — нужен был для того чтобы разработчику который смотрит файл имел сразу все зависимости, вообщем так у нас опять согласовалось), поэтому при алфавитной склейке ALTER TABLE … ADD CONSTRAINT … REFERENCES может встретиться раньше, чем создана таблица, на которую он ссылается. То же самое с представлениями поверх представлений.

      Порядок накатки — это скорее задача отдельного инструмента, который раскладывает дерево по фазам: сначала домены и генераторы, потом таблицы без ограничений, потом ограничения, потом индексы, потом представления и PSQL, в конце права и комментарии. Внутри фазы ограничений порядок уже не важен, а внутри PSQL цикличные зависимости снимаются тем, что процедуры и функции создаются через CREATE OR ALTER: сначала все заголовки, потом все тела. Дампер сознательно об этом не знает: он описывает состояние, а не порядок применения.


      1. kmatveev
        27.08.2026 15:04

        В организации, в которой я работаю, технологий много и процессы неоднородные.

        В Firebird много хранимок, и они продолжают плодиться, а разработчики следуют такому процессу: для каждого релиза создаётся git-ветка, и туда добавляют меняющие скрипты. С хранимками и функциями проще, там всегда CREATE OR ALTER PROCEDURE, с таблицами - там ALTER TABLE, для данных тоже изменяющие скрипты. Отслеживать зависимости между этими сложно, разработчик должен это хорошо понимать. Если решили, что какой-то функционал нужно в следующий релиз подвинуть - это очень больно. Но зато сразу есть скрипты, которые будут менять прод базу в процессе релиза.

        А разработчики, использующие Postgres, пошли по-другому: не пишут alter-скрипты, только создание базы с нуля. Структура такая: для каждой таблицы каталог, там скрипт создания таблицы, скрипт ограничений и скрипт грантов. Коллега написал инструмент на python, который сначала накатывает таблицы, потом ограничения, и в конце гранты. Это не один файл, как у вас, но хоть файлы, относящиеся к одной таблице, рядом лежат. У инструмента есть киллер-фича: он умеет, как ваш, создавать файлы из базы, а ещё умеет alter-скрипты создавать, имея старую базу и файлы для новой базы. В open source выложить не дадут, а жаль. Python для таких задач хорош, коллега, как вы, сначала пользовался isql/psql, но плюнул.

        А насчёт того, как жить разработчику, который очень привык сначала экспериментировать с базой, а потом в git коммитить. Есть две вещи, которые могут помочь понять, что разработчик делал. Во-первых, можно писать историю запросов из инструментария. Вроде бы это умеет DBeaver Pro, возможно это умеет IBExpert. Во-вторых, можно в firebird на сервере настроить трассировку, и все запросы будут в файл попадать.