pgmig — история разработки инструмента управления изменениями в БД или чего нам не хватило в Liquibase и Flyway

в 10:11, , рубрики: ci/cd, flyway, liquibase, postgresql, sql, Блог компании Ростелеком

Введение

Всем привет! На связи разработчики БД - Юрий Плотников и Андрей Григорьев, в этот раз рассказывать мы будем вдвоем, вернее не совсем вдвоем - об одном "фирменном" решении расскажут коллеги из нашего центра компетенций СУБД.

Пара слов о заглавной картинке и логотипе

Несмотря на то, что история pgmig началась раньше, большое развитие он получил при разработке наших "сказочных" продуктов (youtube).

Поэтому мы решили, что ему также место среди сказочных персонажей - домовенок, приводящий процессы в порядок

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

Как и многие из вас мы занимаемся разработкой веб-приложений. Современное приложение чаще всего состоит из 3 слоев - фронтэнд, который мы видим в браузере, бэкенд, который работает на сервере и база данных, которая хранит (и позволяет ими манипулировать) наши данные. Все достаточно очевидно.

Пойдем дальше, поговорим о том, что происходит при разработке. Код приложений, как правило - это одна (или несколько) папок на диске и при разработке мы как раз создаем, обновляем, удаляем, переименовываем файлы исходных кодов в ней. А чтобы ничего не потерялось, и для того, чтобы знать, что и почему изменилось, конечно же мы используем систему контроля версий. И, хотя их разнообразие на сегодняшний день велико, мы используем самую популярную – git. Что нам дают эти системы в целом думаю все знают - совместная разработка в одних и тех же файлах, автослияние, разрешение конфликтов, история версий, ветвление (так называемый git flow).

Теперь посмотрим, что не так во всей этой картине применительно к базам данных. База данных отвечает за хранение данных, а поэтому она имеет несколько особенностей.

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

Вторая особенность вытекает из первой: чтобы иметь образ БД в виде файлов и контролировать версионность, нужно прилагать дополнительные усилия и специально создавать и хранить в git файлы с объектами БД (таблицами, представлениями, функциями и процедурами), так как все они хранятся в самой БД, а не в виде файлов на диске. Для доступа к ним мы используем соответствующие IDE для БД, которых, на самом деле несколько разных, но мы будем говорить о DBeaver, так как мы его знаем, любим и даже ведем свой отдельный форк (а иногда и "контрибьютим в апстрим" (добиваемся, чтобы наши изменения были включены в официальную версию)).

Иллюстрация gitflow

Иллюстрация gitflow
О форке DBeaver

Рискнем показаться занудами, но у нас разработкой в одном репозитории БД занимается не один и не два разработчика. Чтобы быстро разобраться в коде, меньше тратить время на ревью кода хочется, чтобы он (код) был отформатирован единообразно. Мы, в команде, договорились о правилах форматирования, именования и того, что должно быть в файлах таблиц, типов, процедур и т.д. Так вышло, что наши правила немного не совпадают с реализацией в нашем любимом DBeaver, да и форматтер, встроенный в него не сильно балует настройками. Поэтому мы создали свой форк DBeaver, который форматирует объекты так как нам нравится (кроме тел функций), а также он предлагает шаблоны для объектов с уже готовыми дополнительными конструкциями (шаблоны объектов официальной сборки забиты хардкодом внутрь самой IDE и их нельзя изменить без пересборки).

Таким образом, с одной стороны все происходит примерно также, как с кодом - мы меняем объекты, которые тоже достаточно "отдельны" и можно их представить, как набор отдельных файлов. Но, погодите-ка, а git-то как прикручивать? Файлов то на диске нет...

И вот здесь нас ждут первые две проблемы, которые нам нужно решить.

Проблемы, решения

Первая - мы заводим отдельно репозиторий исходных кодов БД, с соответствующей структурой - схемы это папки, внутри схемы - папки по типам объектов, в них - сами объекты в виде файлов.

Структура БД в виде файлов и папок на диске
Структура БД в виде файлов и папок на диске
Как создать такую структуру БД

Если вам надо выгрузить в отдельные объекты уже имеющуюся БД, то существуют разные способы, один из них - снять дамп метаданных и воспользоваться нашим решением, которое "порежет" его на отдельные объекты. Решение не идеальное - мы периодически его дорабатываем, но свою работу делает:

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

- а как же так? Ведь если даже файлы репозитория у каждого разработчика хранятся локально в виде копии, БД-то ведь вещь коллективная, если одновременно менять одни и те же объекты разными людьми, то мы будем постоянно перезатирать изменения друг друга.

И эта именно та самая вторая проблема, которую мы решаем способом организации нескольких копий БД разработки, которые мы называем "тонкие клоны".

О тонких клонах

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

Есть еще несколько проблем помельче, но тоже стоит о них сказать:

  • Функции и процедуры в Postgres бывают перегруженные, т.е. под одним именем может быть любое их число. Чтобы записать каждую из них в отдельный файл, мы решили дописывать к имени файла постфикс , наример вида "_ovl2", "_ovl3"

  • Если с процедурами, функциями и прочими представлениями все относительно просто, их можно удалить и создать снова, то с таблицами и последовательностями все чуть сложнее. SQL-операторы, изменяющие их, отличаются от операторов, создающих объекты. Самый яркий пример - в качестве кода объекта-таблицы мы храним соответствующий "CREATE TABLE ...", тогда как добавление колонки делается через "ALTER TABLE ADD COLUMN ...". Таким образом, после изменения объекта в файл нужно получить новый скрипт создания объекта

Вкратце о проблемах

Вкратце о проблемах

Подведем промежуточный итог, что у нас получилось:

  1. Есть БД разработки

  2. Есть тонкие клоны этой БД для каждого разработчика / фичи

  3. Есть git-репозиторий объектов БД, который мы ведем "без отрыва от разработки в БД"

Да, получилось несколько посложнее привычного редактирование объектов в БД, но зато мы обеспечили совместную разработку.

Перейдем к доставке изменений, миграциям

Настало время поговорить о CI/CD и "миграциях", так как мы же не хотим менять объекты в продакшене полагаясь только на внимательность релиз-мастера.
Здесь есть узнаваемые решения, назовём пару из них - Liquibase и Flyway. Но речь пойдет не о них.
Вкратце как это работает:

  1. У нас есть БД разработки, в ней есть определенное число доработанных (новых, измененных) объектов

  2. У нас БД тест, прод, которые нам нужно довести до состояния БД разработки

  3. Для этого мы пишем некий скрипт обновления (назовем его чейнджсет), который будет производить изменения

  4. Мы помним, что главная особенность БД – её персистентность, и для миграции таблиц мы должны применять не скрипты создания, а скрипты, меняющие существующие таблицы. Но если мы говорим о Postgres, главная причина даже не в этом, а в:

    1. В обработке зависимостей. В БД есть своя специфика, которая заключается в следующем - изменение зависимых объектов не всегда возможно. Действительно, если представление ссылается на таблицу, то мы не можем эту таблицу удалить - сначала надо удалить представление. И этих нюансов предостаточно, особенно учитывая, что набор полей таблицы или представления может выступать в качестве типа данных, возвращаемого, например, функцией

    2. Иногда ченджсет включает в себя явную вставку каких либо "инициализирующих" данных

И что в итоге, спросите вы?

  1. Разрабатываем объекты в БД

  2. Ведем их же в репозитории исходного кода БД в виде скриптов создания

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

И это все вручную! Мне одному кажется, что это перебор? К счастью, не одному, а еще команде единомышленников. Попробуем хоть что-нибудь автоматизировать.

Итак, представляем наш ...

Мигратор, он же pgmig

Основные отличия, вкратце

Мигратор, в отличие от "коллег" (Liqubase / Flyway) предлагает свою концепцию:

  • Разработчики вносят изменения в файлы объектов БД

  • Скрипт миграции создается мигратором на основе этих изменений

Поиск дельты

Поиск дельты

Насколько мы знакомы с "общепринятыми" инструментами - они предполагают создание скриптов миграции явно, вручную (если не рассматривать миграции в формате xml, которыми, как нам кажется, мало кто пользуется).

Теперь чуть более подробно - кому он нужен?

Мигратор - универсальный инструмент для повышения производительности и уменьшения затрат внимания на установку и тестирование в БД.

Так как наш мигратор в большой степени опирается именно на git, а не на метаданные какой-либо БД, то мы можем позволить себе вести разработку практически как нам нравится и потом собрать из дельты в git скрипт миграции.

Еще одна особенность нашего мигратора заключается в том, что он позволяет собрать пустую БД на любой коммит любой ветки git. А это дает нам возможность, в свою очередь, проводить тестирование на собранной из такого скрипта БД, например, в докер-контейнере. То есть, если нам понадобилось воспроизвести поведение БД на какой-то момент времени или на момент в разработке, то мы можем это сделать за пару минут. И при этом мы не ведем специального скрипта создания БД, все скрипты ведутся в одном экземпляре!

Какие же проблемы подстерегают тех, кто большой командой разрабатывает бизнес логику в БД? Самый простой пример и то, что нам не понравилось в миграциях Liqubase (далее LB) это когда одна функция правится двумя разработчиками. LB при этом предлагает сделать каждому из них свою миграцию и, конечно, каждый положит в миграцию свой код функции. Что будет дальше? Дальше останется вариант того, что сольет свой Merge Request (далее MR) последним. Понятно, что так вести разработку опасно и сложно, поэтому мы решили, что основной единицей хранения у нас будет результирующий файл в папке схемы. Это позволяет на этапе MR решить конфликты и получить относительно рабочий вариант, ну по крайней мере, он будет учитывать доработки всех авторов. А чтобы смерженный код попал в БД, мы предусмотрели использование ссылки на файл с функцией / представлением в скрипте установки, а не внесение самого текста функции в скрипт установки.

Другая сторона – это миграции таблиц. Здесь как раз требуется найти разницу между тем, что было до миграции и тем, что мы хотим увидеть. Это можно сделать разными путями и первый вариант был смотреть на изменившиеся строки и пытаться diff-ы переделать в alter конструкции. Это в целом работает, но с каждой новой конструкцией, такое преобразование становилось все сложнее и запутаннее. Поэтому мы решили перейти на парсинг скриптов в синтаксические деревья. В этом случае мы знаем какое дерево было до разработки и какие элементы поменялись. Из этого знания уже довольно просто создать изменяющую инструкцию. Но есть еще одна сложность: понять, что было сделано во время разработки, эволюция поля или ограничения, или разработчик решил заменить один объект другим?

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

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

Еще более подробно - о подходах к формированию патчей

Немного поговорим о еще одной особенности мигратора, которая появилась по, так скажем, историческим причинам. Есть два подхода к тому какими квантами устанавливать патчи. Один предлагает Liquibase: это поддерживать набор ченджсетов и порядок их установки, а при запуске процесса установки ставить только то, что еще не устанавливалось и в строгой последовательности. Второй вариант подразумевает, что каждая задача или фича должна сопровождаться своим набором изменений БД и может устанавливаться независимо от других задач. Оба варианта имеют свои плюсы и минусы.

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

 Однако, у такой схемы есть и очевидные минусы. Если для нескольких фич требуются одинаковые изменения в структурах таблиц, ну, например, для трех новых фич нужно новое поле в таблице. В этом случае мы должны либо размножать такие изменения по всем фичам, либо делать отдельную техническую фичу по добавлению поля. При этом мы должны все время держать в голове, что эту техническую задачу нужно поставить до того, как будут установлены бизнес задачи. Или закладываться на то, что одна из бизнес-задач, точно попадет в установку раньше других. Как вы понимаете, все эти варианты вероятностные и ошибки будут. Точнее мы знаем, что они периодически встречаются и это требует дополнительного внимания.

Вариант с ведением ченджсетов в этом свете кажется уже более интересным, но и он скрывает в себе подводные камни, которые хочется обойти. С проблемой параллельной разработки процедур и функций понятно как бороться: можно пойти в сторону использования ссылок на скрипты создания, как мы сделали в позадачном режиме или, второй вариант, вставлять в скрипт установки готовый код функции, но делать это уже после слияния изменений в общую ветку. В этом случае мы будем устанавливать такую функцию, которая учитывает изменения всех разработчиков. Здесь мы не ждем подвоха. Но как нам получить список всех изменений, совершенных с момента создания предыдущего скрипта установки? Первое, что напрашивается – поставить метки или создать коммиты специального вида и рассматривать изменения между такими коммитами. Это красиво и работает, при этом даже если кто-то сделает слияние в общую ветку до того, как мы сгенерировали ченджсет, это не повлияет на его генерацию, так как мы рассматриваем изменения между зафиксированными точками в истории разработки. Однако и здесь не может обойтись без подводных камней. Во многих командах принято создавать ветку релиза и в ней делать исправления багов перед выводом в продакшн. И в ветке релиза могут появляться изменения. И, хотя она и общая, но отделена от ветки разработки и изменения могут появиться в обеих ветках и разные. На первый взгляд это тупик. Мы изначально попадаем в ситуацию разных историй миграции на разных серверах. На тестовом и продакшн контуре будет история миграций с исправлениями ошибок. На разработчетском стенде будет своя история с продолжением наращивания функционала и исправление ошибок «приедет» в нее только во время релиза. Но в git тоже история имеет ветвления! Так почему бы и нам не поступить так же?! Мы делаем структуру списка миграций так, чтобы у миграции была родительская миграция или несколько, тогда мы можем отслеживать корректность и непрерывность установки миграций по веткам. Главное, чтобы история установок миграций была непрерывна, то есть, чтобы можно было двигаясь по истории установок дойти от последней установленной миграции до самой первой.

Перейдем к практике - подготовительные действия

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

Для начала подготовим репозиторий БД. Как я уже сказал выше - если БД уже существует, то тексты объектов можно получить из дампа с помощью утилиты, но мы рассмотрим "чистый" вариант.
Структура репозитория (все имена в нижнем регистре):

  1. Верхний уровень папок - наименования схем

  2. Затем идут папки по объектам: tables, views, types, functions, procedures, sequences. Также есть папка init_data, в которой размещаются скрипты вставки/обновления данных (например справочников)

  3. В папках - объекты. Расширение всех файлов - sql

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

Описание настроечного файла

[project]

# Префикс проекта - название проекта в таск-трекере. Наименование веток в git, как правило, соответствуют номерам задач

prefix=PROJ

[branch]

# основная ветка разработки

default=origin/develop

# Это описание договоренности именования веток. Мы решили именовать ветки (в синтаксисе регулярных выражений)

# (feature|bugfix|hotfix)/PROJECT-${task_number}. То есть номер задачи нужно брать из 1 элемента массива, разделенного «/»

taskPos=1

[path]

# Для выделенного репозитория и системы хранения схема -> тип -> скрипты schemaPos=0, typePos=1.

# Но можно сделать и тип -> схема -> скрипты. Ничего не мещает, тогда schemaPos=1, typePos=0.

# А если есть репозиторий project -> db -> схема -> тип -> скрипты, то schemaPos=1, typePos=2 #

# не всегда скрипты БД хранятся в отдельном репозитории. Иногда скрипты БД являются подпапкой основного репозитория.

# И тогда schemaPos описывает на какой «глубине» находится имя схемы 

schemaPos=0

# typePos описывает глубину типа объекта

typePos=1

 [db]

# схема для служебных объектов мигратора - протоколирование установки "миграций"

releaseSchema=prrelease

[contours]

# в этом разделе описываются "контуры", т.е. наши среды - тестирования, разработки и другие, затем описывается каждый контур

contour1=prdev

contour2=prtest

contour3=prprod

contour4=local

 

[contour-prdev]

ipAddress=x.x.x.x

port=5432

dbName=proj_dev_db

userName=proj_owner_dev

 

[contour-prtest]

ipAddress=y.y.y.y

port=5432

dbName=proj_test_db

userName=proj_owner_test

 

[contour-japrod]

ipAddress=z.z.z.z

port=5432

dbName=proj_db

userName=proj_owner_prod

 

[contour-local]

ipAddress=127.0.0.1

port=5432

dbName=proj_db

userName=proj_owner

Описание процесса установки обновлений БД

Перейдем к самому основному - процессу установки обновлений БД.

Входящие условия

Под входящими условиями будем понимать БД (на тестовом или продуктивном стенде), находящуюся в "стабильном" состоянии, например после установки релиза. При этом, данному состоянию БД также соответствует репозиторий объектов БД, иными словами можно сказать, что они тождественны. Важно технически установить признак (в репозитории) - тэг или специальный коммит, на момент которого состояние репозитория соответствовало состоянию БД. Договоримся, что ветка репозитория в данном случае должна быть develop.

Стенд разработки, а также тонкие клоны будем считать "нестабильными" стендами.

Скрипты миграций должны содержать в себе все скрипты установки объектов БД, в том числе полные тексты процедур, функций и представлений. Также в скриптах миграций должны быть предусмотрены необходимые (временные) удаления объектов от которых зависят изменяемые объекты, а также все скрипты обратного восстановления этих объектов после установки изменяемых.

Разработка объектов БД

Разработка объектов БД ведется разработчиками. В общем случае нет четких правил, где производится разработка - на DEV стенде или на "личных" копиях БД - тонких клонах. Разработчикам следует договариваться между собой или иметь ввиду, что объекты на DEV стенде могут быть изменены разными разработчиками (и даже "затерты"). Окончательной "точкой правды" являются версии объектов в репозитории. Для фиксации объектов в репозитории разработчик заводит свою ветку, например feature/PROJ-XXXX, после окончания разработки - сливает ветку с develop, при наличии проблем слияния, разработчик(и) решает их в обычном порядке, примерно как это производится с "обычным кодом". 

Примечание: под объектами БД также понимаются объекты init_data, содержащие команды инициализации таблиц данными, установки сиквенсов и т.д.

Установка кумулятивного обновления

Подготовка кумулятивных обновлений

Подготовка кумулятивных обновлений

Установка кумулятивного обновления (далее КО), как правило, связана с установкой нового релиза ПО или установкой хотфиксов. При этом подготавливаются новые версии приложений, а также набор кумулятивных обновлений. 

Подготовка к КО

Когда наступает момент подготовки к КО необходимо зафиксировать состояние репозитория кода БД (ветка develop) на момент "сейчас", при этом все последующие слияния в develop - уже не попадут в текущий КО. Таким образом мы рассматриваем набор изменений в репозитории между двумя явно отмеченными "точками" (на временной шкале), который зафиксирован. 

Формирование скрипта обновления

Скрипт обновления подготавливается разработчиком со специальной ролью - "релиз менеджер БД" (РМБД), при этом происходит:

  1. РМБД запускает инструмент preparer (подробнее о нем ниже), получая скрипт установки КО в автоматизированном режиме

  2. РМБД записывает скрипт установки КО в репозиторий (ветка develop)

  3. РМБД оповещает команду разработки о создании скрипта установки КО

  4. Все разрабочтики анализируют скрипт установки КО в части своих работ (ориентируясь на комментарии к коммитам в которых указаны авторы), вносят изменения, фиксируют скрипт в репозитории (особое внимание уделяют порядку выполнения скриптов в init_data)

  5. РМБД тестирует сктипт установки КО на тонком клоне. Если скрипт установки устанавливается не успешно, то повторяются шаги 4,5

  6. Далее возможны два варианта установки обновлений БД:

    1. Включение скрипта обновления в ресурсы приложения и установка при его старте (примерно как Liquibase)

    2. Отдельная установка с помощью CLI мигратора

Окончание установки КО

Установленная "точка" фиксации состояния репозитория в момент "подготовки к КО" становится новой "опорной" точкой, с момента которой будет проводиться следующая установка КО.

Функции мигратора

Пройдемся более подробно по основным функциям мигратора.

Preparer

Один из самых ярких бриллиантов в нашей короне, расскажем о нем подробно. Функции:

  • Составляет список изменившихся файлов

  • Анализирует характер изменений

  • Формирует скрипт установки обновления

Если вкратце - это путь из точки A в точку B (технически - анализируются изменения между двумя коммитами), подробнее же - расскажем ниже.

Если не вдаваться в нюансы, то пусть A - это последний коммит, перед тем, как мы начали "пилить очередную фичу", а B - последний коммит, когда мы закончили ее пилить. Препарер анализирует все файлы, измененные (а также добавленные и удаленные) на момент B, сравнивает их с состоянием файлов на момент A и генерирует скрипт-разницу. Рассмотрим подробнее, что мы умеем:

  1. Создание объектов. Тут все более менее просто, объекты создаются чаще всего "как есть". Важно соблюсти верный порядок создания объектов, ведь, допустим, если таблица в своем описании использует сиквенс, то он, очевидно, должен быть создан раньше нее;

  2. Мы генерируем безопасный код, который можно запускать несколько раз. Добиваемся мы этого путем добавления "if not exists" там, где это возможно. В случаях, когда невозможно - делаем анонимные SQL-блоки;

do
$$
begin
 alter table jgevent.logged_event_ref add constraint logged_event_ref_fk1 foreign key (entity) references jgevent.logged_entity_ref(id);
exception when sqlstate '42P07' or sqlstate '42710' or sqlstate '42P16' then
  null;
end;
$$;
  1. Изменение объектов. Некоторые объекты можно "безболезненно" пересоздать (функции, процедуры и представления, с некоторыми оговорками), некоторые (таблицы) требуют тщательного анализа;

  2. Мы анализируем зависимости объектов и выстраиваем цепочки созданий / удалений;

  3. Мы генерируем сообщения (ворнинги) разной степени важности, особенно в случаях, когда меняются объекты, хранящие данные (таблицы, колонки);

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

  5. Неразобранные конструкции помечаются соответствующим образом (ворнинги тоже генерируются);

  6. (Опционально) Перед сравнением объектов мы вызываем pgFormatter. Изначально мы хотели на этом построить алгоритм сравнения

  7. Пройдемся по объектам, отметим их особенности.

Особенности генерации скриптов объектов

Таблица

Создание таблицы

К 'create' добавляется 'if not exists'

Удаление таблицы

drop table %s

Комментарий к таблице

comment on table %s is %s

Удаление комментария к таблице

comment on table %s is null

Гранты к таблице

grant/revoke select on table %s to/from %s

Гранты к связанному сиквенсу

grant %s on sequence %s to %s

Создание столбца

alter table %s add column if not exists %s;

В случае, если создается not null столбец, то генерируется вспомогательный update, после чего not null устанавливается отдельно

Удаление столбца

alter table %s drop column if exists %s

Переименование столбца

alter table %s rename %s to %s

Установка модификатора not null

Если у колонки нет секции default, то генерируется два оператора (первый - в виде комментария, который надо):

-- update %s set %s = [place_value_here] where %s is null;

(в случае, если default есть, то подставляется значение из него - вместо [place_value_here])

alter table %s alter column %s set not null;

Удаление модификатора not null

alter table %s alter column %s drop not null

Смена типа столбца

alter table %s alter column %s type %s using %s::%s

Установка модификатора default

alter table %s alter column %s set default %s

Удаление модификатора default

alter table %s alter column %s set default null

Комментарий к столбцу

comment on column %s is %s

Удаление комментария к столцу

comment on %s is null

Внешние и уникальные ключи

Удаление ключа

alter table %s drop constraint if exists %s

Поддерживается синтаксис ключа как в составе таблицы, так и отдельные (неименованные ключи не приветствуются)

Добавление ключа

do $$
begin
alter table %s add constraint %s foreign key (%s) references %s(%s);
exception when sqlstate '42P07' or sqlstate '42710' or sqlstate '42P16' then
null;
end;

Представления

Создание представления

К 'create' добавляется 'if not exists'

Удаление представления

drop %s if exists %s;

"Мягкое" пересоздание

В pg можно не удалять представление, если колонки добавляются в его конец. Это очень облегчает обработку зависимостей, мигратор анализирует такие ситуации и действует соответственно

Другие объекты

Функции и процедуры

Объекты обновляются через удаление и создание

Типы

Умеем добавлять значения к enum

Последовательности

Удаляем и создаем

Инициализационные данные

У нас есть специальный объект "init_data" - в него мы размещаем скрипты, которые необходимы для вставки / изменения данных

Немного расскажу про неразобранные блоки. Если мигратор совсем-совсем не может разобраться в каком-либо объекте - он не "проглотит" это, а честно вставит в результирующий скрипт, отметив соответствующим образом ("ADDED UNPARSED SECTION:"), сгенерировав глобальные "ворнинги".

Обработка зависимостей

Выше мы уже говорили, что зависимости - это очень важно. Да, в Postgres это сделано не так удобно, как в Oracle, когда объект менять можно, а зависимые объекты просто помечаются как "не валидные" и требуют перекомпиляции, но уж нет, так нет.

Остановимся чуть подробнее.

Что такое зависимость?

Это если в представлении используется другое представление или функция или если функция возвращает тип строки представления или принимает на вход параметр тип строки представления.

create or replace view jgabstract.v_example as
select *
  from jgabstract.v_another_view;

В чем же проблема?

Проблема в том. что если мы хотим изменить jgabstract.v_another_view , то это не получится сделать просто изменением представления, а придется вначале удалить jgabstract.f_example, а затем jgabstract.v_example. Причем делать это нужно именно в такой последовательности, так как jgabstract.f_example зависит от jgabstract.v_example, а она зависит от jgabstract.v_another_view.

Классический пример зависимостей
CREATE OR REPLACE VIEW abstract.v_example
AS
SELECT *
FROM abstract.v_another_view;

CREATE OR REPLACE FUNCTION abstract.f_example(p_id bigint)
RETURNS v_example
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN query
    SELECT * FROM v_example WHERE id = p_id;
END; $$;

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

Что же делать?

Первое, что приходит в голову - не создавать зависимостей. Это хорошо и по возможности к этому нужно стремиться. Но. В таком случае мы сами уменьшаем базу переиспользования кода. То есть запросы. содержащиеся в представлениях, приходится копировать в другое представление, копировать поля из представления в table() типы, возвращаемые функциями. 

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

Как же ставить миграции при наличии зависимостей?

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

Установка, как уже говорилось ранее, состоит из 2х шагов:

  • Удаление объектов. Чтобы это сделать нужно получить дерево зависимостей. Здесь есть два пути - либо мы получаем дерево зависимостей из БД ("нечестный" путь), и это самый точный способ получения зависимостей для удаления объектов. Но нужно понимать, что сразу после окончания установки, дерево зависимостей может измениться и старый скрипт станет непригодным. Либо - мы анализируем объекты в git репозитории - это более сложный, но зато "честный" путь;

  • Создание и воссоздание объектов. Это вторая часть. Здесь решением может быть только получение зависимостей из текста объектов из git, так как в БД нового дерева еще нет.

Опишем примерный алгоритм сортировки объектов и выстраивание их в дерево зависимостей:

  • Вычитываем текст представлений и функций, а затем ищем вхождение имен представлений в тексте

  • Если найдено совпадение найдено, то имя найденного объекта вносим в зависимости рассматриваемого объекта

  • Сортировка объектов:

    • Перемещаем в начало списка все объекты, у которых нет зависимостей (пусть их N)

    • Просматриваем весь список, начиная с позиции N+1

    • Переставляем местами объекты, если объект зависит от другого объекта, находящегося далее по списку

    • Снова просматриваем список с позиции N+2, N+3....до тех пор, пока N < длины списка

Алгоритм можно улучшить:

  • Можно собирать объекты от которых зависит рассматриваемый объект только до границы начала кода ($$, $function$, $procedure$)

  • Можно поискать алгоритм перебора, чтобы перемещать объект сразу на его место в списке, убеждаясь, что ранее нет объектов. которые от него зависят. Сложность в том, что список объектов - это массив и список зависимостей - тоже массив

  • Убрать из проверки комментарии

Также мы умеем выделять кольцевые зависимости и сообщать о них.

Дальше - дело техники, перед установкой формируем цепочку drop объектов, после установки - восстанавливаем в обратном порядке.

Есть еще совсем уж "экстремальный" режим - try reorder. Работает так - в случае, если что то не ставится (какой то элемент ченджсета) - скрипт все равно идет дальше, а потом возвращается к тем элементам, которые поставить не удалось. Если еще за один цикл хоть на один "проблемный" блок стало меньше - идем на новый круг. Если дело было в том, что объекты создаются не в том порядке, то таким способом можно поменять их местами и достичь успеха.

Предупреждения, дополнительные сведения в файле ченджсета

Примеры предупреждений:

Table 'schema.type_name' column(s) not null modifier was set - check manually please. File: /path/to/file.sql
PERSISTENCE WARNINGS: Table column(s) was(were) altered (types, not nulls) and/or DROPPED, type changed count: 1; drop count: 0. Check these situations manually please.

Примеры дополнительных сведений:

-- TYPES
--#block:type, name = schema.type_name, action = NEW TYPE, file = pathtofile.sql
--   commit: fc3f743cd511f96ac88bc904abd7be2b802aea37; author = Юрий Плотников (email@email.ru); message = commit message

Deploy

Ну, наверное уже понятно, что если скрипт получили препарером, самое время его установить на БД, поэтому - Deploy. Расскажем о двух режимах, как это можно сделать:

Подключение мигратора к приложению:

Подключение мигратора к приложению производится добавлением в сборку приложения свежей версии pgmig (pgmig-1.x-SNAPSHOT.jar). Обращаться к мигратору следует через API com.rit.pgmig.runners.ExternalDeployRunner. Вызов производится созданием объекта:

Код
ExternalDeployRunner(
            String releaseSchema,    // Схема, в которой должна быть установлена таблица migration_log
            String databaseHost,     // Хост сервера БД
            int databasePort,        // Порт БД
            String databaseName,     // Имя БД
            String databaseUser,     // Пользователь, владелец объектов БД
            String databasePassword, // Пароль БД
            Map<String, List<String>> migrations // Список файлов миграций и changeset (без .list)
    )

И запуском run()

Пример создания:

Код
class deployer {
    private val changesetFileName = "changeset.list"
     
    fun loadFileMap(): HashMap<String, List<String>> {
        val migrations = HashMap<String, List<String>>()
        val migrationsList: List<String> = getMigrationFolderResource("resource://path_to_migrations/")
        for (fileName in migrationsList) {
            migrations.put(
                    if (fileName.endsWith(changesetFileName))
                        fileName.substring(fileName.lastIndexOf(File.separatorChar)).replace(".list", "")
                    else fileName.substring(fileName.lastIndexOf(File.separatorChar)),
                    loadFile(fileName)
            )
        }
        return migrations
    }
     
    fun loadFile(fileName: String): List<String> {
        val loader = ClassLoader.getSystemClassLoader()
        val stream = loader.getResourceAsStream(fileName.replace("resource://", ""))
                ?: throw FileNotFoundException("Cannot open resource folder $fileName")
        val reader = BufferedReader(InputStreamReader(stream))
        return reader.lines().toList() as List<String>
    }
 
    fun getMigrationFolderResource(path: String): List<String> {
        val resourcePath = path.replace("resource://", "")
        val loader = ClassLoader.getSystemClassLoader()
        val stream = loader.getResourceAsStream(resourcePath)
                ?: throw FileNotFoundException("Cannot open resource folder $resourcePath")
        val reader = BufferedReader(InputStreamReader(stream))
        return reader
                .lines()
                .filter { l: String -> l.endsWith(".sql") || l == changesetFileName }
                .map { l: String -> "$resourcePath/$l" }
                .toList()
    }
 
    fun runDeployer() {
        try(val runner = ExternalDeployRunner("jgrelease", "${JAGA_DB_HOST}", ${JAGA_DB_PORT}, "${JAGA_DB_NAME}", "${JAGA_DB_OWNER_USERNAME}", "${JAGA_DB_OWNER_PASSWORD}", loadFileMap())) {
            runner.run()
        } catch (ex: Exception) {
            log.error("Deploy database changes crashed. Error: ${ex.message}")
        }
    }
}

Второй режим - установка обновлений из командной строки:

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

java -jar pgmig-1.0-SNAPSHOT -config=/path/to/config.ini -db_host=1.2.3.4 -db_port=5432 -db_name=kolobok_db -db_user=kolobok_user -password=secret -migrations_path=/path/to/folder/with/migrations -func_check
Протоколирование миграций

Протоколирование миграций

Create database

Помните, мы рассказывали о том, что мы умеем получать скрипт создания пустой БД на любой коммит разработки? Сейчас как раз самое время поговорить об этой возможности. Так как мы храним БД в репозитории в виде скриптов, причем скриптов создания объектов, а справочные и настроечные данные в виде insert скриптов, то создание общего скрипта БД имеет только одну сложность. И это зависимости. Причем на этот раз зависимыми могут быть не только функции и процедуры, но и таблицы, которые могут ссылаться друг на друга внешними ключами. Из этой ситуации мы придумали, как нам кажется, простой и эффективный выход. Мы отделяем команды создания внешних ключей от скриптов таблиц и вставляем их всех в самый конец скрипта создания БД. То есть внешние ключи создаются после создания всех таблиц и вставки справочных данных, да, ведь и данные в случае внешних ключей тоже могут обрасти зависимостями.

Если смотреть в целом, то вначале мы создаем пользователей, схемы и другие объекты уровня БД. Далее мы применяем такую же схему с сортировкой объектов, как и при создании скрипта миграции: добавляем в скрипт типы, представления, таблицы и функции. Потом наступает черед скриптов инициализации данных, и в конце, список всех внешних ключей.
Создание некоторых объектов нужно не всегда, например, мы могли уже создать всех пользователей или все схемы. Для такого случая мы предусмотрели различные ключи для отключения генерации глобальных объектов. А может быть и наоборот, мы работаем с тестовой БД и тогда мы можем включить инструкции удаления всех объектов перед созданием БД.

О тонких клонах

В самом начале статьи мы обещали рассказать, как у нас устроены "тонкие клоны" БД. Дадим слово специалистам ЦК СУБД, они расскажут свое "техническое решение по быстрому созданию зон тестирования и разработки":

Схема организации площадки тонких клонов

Схема организации площадки тонких клонов

В основе нашего технического решения используются технологические возможности файловой системы zfs. Файловая система zfs (Zettabyte File System) имеет много особенностей и различного функционала, для нашего технического решения используется ее технология «копия по записи» (copy-on-write). Эта технология позволяет создавать снимок (snapshot) файловой системы очень быстро, практически мгновенно. Также могут быть созданы перезаписываемые снимки (clone), которые потом могут быть смонтированы и использованы для записи данных.

Почти во всех типовых архитектурных решениях нашего круга для резервного хранения используется файловое хранилище на базе NFS (Network File System), его мы и используем в данном решении для постоянной актуализации копии продуктивных данных.
Для организации зон тестирования и разработки необходимо выделить ресурсы в виде одной или нескольких ВМ (виртуальных машин), либо «железного» сервера или серверов. Ресурсы должны выделяться исходя из требуемого количества тестовых зон или зон для разработки. Важно учитывать что все экземпляры БД (базы данных Postgres, на каждом отдельном сервере, будут разделять оперативную память, процессоры и дисковую подсистему между собой. Также надо учитывать накладные расходы которые вносят дельты исходного экземпляра и клонов, они индивидуальны и зависят от активности изменений в основной базе и клонах конкретной ИС.

В основе актуализации продуктивных данных лежит технология «проигрывания» WAL (файлов журналов предзаписи Write-Ahead Log аналог redo логов в Oracle) файлов, копируемых из смонтированного в режиме RO (Read Only) файлового хранилища резервных копий продуктивной базы данных. Таким образом данное технической решение никаким образом не влияет на продуктивный контур не нагружает его ресурсы и использует только хранилище резервных копий.

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

Для использования файловой системы zfs на серверах необходимо установить пакеты rpm zfs. Необходимо также будет установить бинарные пакеты той же версии БД и пакет агента резервного копирования (pg_probackup).

После установки пакетов надо создать и смонтировать файловую систему zfs. Также, при нехватке места в дисковой подсистеме, можно использовать еще одну возможность этой ФС, такую как создание раздела с включенной компрессией, что на порядки уменьшает утилизацию дисковой подсистемы.

Для первоначального запуска необходимо восстановить из полной резервной копии (full backup) данные БД. Затем запускается экземпляр БД в режиме постоянного восстановления (recovering).

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

Затем можно запускать экземпляры БД каждый из них на своем tcp порту. После запуска экземпляры также будут находиться в режиме восстановления и соответственно данные будут в RO (Read Only), поэтому их надо перевести в обычный режим, активировать (promote). После этого необходимо поменять некоторые параметры БД, с учетом количества экземпляров и имеющихся ресурсов, перезапустить их и можно отдать в эксплуатацию.

Каждая копия экземпляра БД или все, по запросу, могут пересоздаваться, используя ansible playbook, разработанный кругом Postgres.

Для запуска плейбука необходимо определить некоторые входные параметры:

pg_all_instance количество экземпляров запускаемых на сервере, по этому параметру будут рассчитываться ресурсы выделяемые каждому экземпляру;

pg_count_instance количество экземляров пересоздаваемых данным плейбуком;

pg_begin_port TCP с которого начнется отсчет создаваемых экземпляров;

pg_is_running параметр определяющий будет ли исходный экземпляр находиться в режиме постоянного восстановления или кастомизирован и его запускать не надо путь к бинарникам и версия Postgres еще один важный параметр max_connections максимальное количество возможных соединений с БД, он тоже оказывает сильное влияние на распределение ресурсов памяти.

Первая роль это удаление клонированных экземляров, она достаточно простая:

Клоны останавливаются, удаляются, затем удаляются снапшоты клонов и соответствующие каталоги клонированных экземпляров.

Вторая роль создает клонированные экземпляры:

Вычисляются параметры использования памяти клонами.

Останавливается основной экземпляр, создаются снапшоты и соответствующие им клоны. Основную БД можно запускать далее в режиме постоянного восстановления, она нам больше не нужна. Устанавливаются рассчитанные ранее параметры в конфигурационные файлы клонов. Делается их активация (promote). Для применения параметров делается перезагрузка БД.

Все тестовая зона готова для работы.

Выполнение этого плейбука как правило занимает по времени меньше минуты. Для примера - на одном из наших крупных проектов сейчас раскатка стейдж зоны происходит около 8 часов (НТТМ) и это происходит достаточно часто, привлекая наши ресурсы для этих работ. Уже есть договоренность с коллегами, чтобы перейти на наше решение и настроить раскатку препрода через имеющийся у них Jenkins собственными силами.

Хочется отметить ключевые моменты использования этого решения:

  • Экономия аппаратных ресурсов для создания слоев БД тестовых и разработки

  • Очень быстрая скорость развертывания или пересоздания любого клонированного экземпляра с актуальными данными продуктивной среды

  • Полностью отсутствует влияние разворачивания и эксплуатации этих слоев баз данных на продуктивную среду

  • Возможность создания нескольких изолированных сред для тестирования и разработки

  • Сейчас в проработке задачи, востребованные нашими заказчиками по обезличиванию (обфускации) данных для этих зон, при использовании персональных данных в ИС

  • Задачи по реализации процедуры смены паролей при пересоздании тестовых зон и зон для разработки

  • Есть планы по доработке ansible сценария для быстрого создания зон тестирования и разработки под управлением оркестратора Patroni.

pgCodeKeeper

Насколько нам известно, наиболее близким конкурирующим продуктом по духу и по фичам – является pgCodeKeeper. Он начал разрабатываться в 2006 году и имеет значительную фору по законченности реализации. А так как в его разработке принимает участие примерно в 20 раз больше разработчиков, то и по наполненности он тоже выигрывает. Однако нам не понравилось, что все перегруженные варианты функций должны храниться в одном файле. Также pgCodeKeeper получает дельту только между состоянием разработчетской БД и текущим состоянием GIT, что затрудняет работу, когда над проектом работают несколько человек и они могут затрагивать одни и те же объекты. Также pgCodeKeeper не имеет embedded версии, включаемой в состав приложения, как это реализовано в Liquibase.

Планы на будущее

Оркестратор тестов

Мы говорили в начале, что наш мигратор интересен тем, кто активно разрабатывает логику в БД, а какая же может быть современная разработка без тестирования? И в наших текущих планах, начать использование pgTap для unit тестирования, а оркестрировать запуски тестов мы хотим из нашего мигратора, как один из основных кейсов его использования.

Заключение

В заключении хочется порассуждать про "зрелость" нашего мигратора. По нашим ощущениям - детство он уже успешно преодолел, перестал быть "наколенным" продуктом, который надо постоянно дописывать, исправлять и обвешивать "костылями". Мы совершенно намеренно полностью ушли от первоначальной версии (Powershell) и переписали весь код на Java. Мы (наконец-то) уделили должное внимание CI/CD части мигратора (в части автоматизации установки обновлений). Сейчас мигратор уверенно используется на наших проектах.

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

Заинтересовала наша разработка? Приходите в личку, попробуем вместе.

Работаете у нас и тоже хотите попробовать? Тем более приходите! У нас в планах - записать "крекер" по мотивам этой статьи.

Какая же статья на хабре без котов? Кот Николай (празднует сегодня свое 18-летие) и по прежнему передает всем привет!

pgmig — история разработки инструмента управления изменениями в БД или чего нам не хватило в Liquibase и Flyway - 8

Автор:
plotn1

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js