Язык SQL и реляционные базы с нами уже более сорока лет. За это время стандарт SQL прошёл через множество ревизий, и, судя по всему, процесс развития на этом не останавливается. Реляционные базы в качестве хранилищ данных десятилетиями царствовали безраздельно, царствуют и поныне, и лишь только в последнее время их немного теснят альтернативные подходы.
SQL практически всемогущ, если вопрос касается извлечения данных. (Не все знают, но одним SQL-запросом можно графически построить множество Мандельброта). Но одна проблема продолжает быть в нём концептуально не решена: проблема миграции схем данных.
Я думаю, на разных этапах своей карьеры в IT мы все сталкивались с тем, как это бывает тяжело: контролировать структуру рабочей базы данных и выполнять её обновления по мере разворачивания новых версий софта. Баги, возвращающиеся после того, как их вроде уже исправили, ошибки «поле отсутствует в таблице», жалобы «я исправил хранимку, а вы затёрли!» — знакомо ли вам это всё?
Острота этой проблемы особенно ясна по контрасту с тем, насколько кардинально решена задача контроля версий исходного кода: системы типа Git или Subversion позволяют вам держать под контролем совместную работу многих разработчиков, не терять ни одного изменения, «записывать все ходы» и собирать результаты труда команды воедино. Но эта благостная картина заканчивается там, где заканчивается область применимости систем контроля версий. Если мы говорим о структуре схемы данных, то до сих пор применение систем контроля версий для их разработки — весьма ограничено.
Так легко и соблазнительно бывает внести изменения структуры прямо на базе! Если такие изменения оказываются не задокументированы, то в будущем наступает необходимость развивать систему и приближается катастрофа. Но и осознав необходимость документировать каждый шаг по изменению структуры БД, мы приходим к тому, что делать это — очень неудобно, а язык DDL определения схемы данных нам в этом никак не помогает. Например, добавление поля в таблицу требует команды ALTER TABLE ADD. Выполнить это выражение имеет смысл ровно один раз и ровно тот самый момент, когда в таблице поле ещё отсутствует, а необходимость в нём – уже есть. Выполнение этого скрипта в другие моменты приведёт либо к ошибке выполнения самого скрипта, либо, что хуже, к неправильному состоянию структуры базы данных.
Как же агрегировать все изменения, производимые разработчиками, в единый результат и как проконтролировать его выполнение?
Подход №1: накопление change-скриптов
Практическое решение этой задачи вручную, без использования каких-либо дополнительных инструментов, заключается в создании в кодовой базе проекта папки с пронумерованными скриптами модификации структуры базы данных, в накапливании их в этой папке и во введении строжайшей дисциплины среди участников проекта разработки. Должен быть формализован процесс записи изменений в очередном скрипт-файле и процесс «накатки» изменений на базы данных.
Более продвинутым и удобным способом решения является использование систем типа Liquibase. Liquibase берёт на себя контроль номера последнего выполненного изменения структуры данных и обеспечивает прогон скриптов модификации структуры в линейном порядке, один и только один раз. Это уже очень много, и использование Liquibase однозначно ликвидирует хаос, царящий при разработке и обновлении схемы данных.
Но тем не менее, использование Liquibase и других инструментов, основанных на накоплении change-скриптов, не делает задачу модификации схемы данных столь же легкой и техничной, как легка и технична модификация программного исходного кода вашего приложения.
Во-первых, change-скрипты накапливаются. Достаточно долго идущий проект тянет за собой большой «хвост» из этих скриптов, большинство из которых утрачивают свою актуальность, т. к. содержат в себе изменения, выполненные очень давно. Сохранение всего «хвоста» бывает бессмысленно, т. к. изменение в одном месте лога может отменять результат изменения в другом месте: допустим, в ходе развития системы мы попробовали какой-то вариант и отказались от него, но два изменения уже навсегда остаются change-log-е. Глядя на разросшийся лог, становится невозможно понять наше текущее представление о структуре базы. А если мы хотим воспроизвести структуру базы данных «с нуля» на, допустим, совершенно новом проекте, нам придётся в процессе выполнения скрипта повторять весь её путь эволюционного развития!
Во-вторых, при таком подходе сохраняется колоссальная разница между сложностью модификации структуры БД через changeset-ы и простотой модификации исходного кода программы.
Допустим, у вас есть код, описывающий класс в вашем любимом языке программирования, и вам в процессе улучшения системы понадобилось один метод в класс добавить, а другой – удалить. Что вы делаете? Берёте и меняете исходный код класса, а затем коммитите его на систему контроля версий, не так ли? Не вынуждены же вы создавать change set с командами вида:
alter class drop method foo;
alter class add method bar(…) {
…
}
Почему мы должны это делать для структуры схемы данных?
Причина, конечно, в том, что в базе данных лежат ещё и данные, с которыми надо что-то делать при изменении структуры. Об этой проблеме мы ещё поговорим, но сначала давайте взглянем на другой возможный подход к решению нашей задачи.
Подход №2: идемпотентный DDL-скрипт
Проблемы, схожие с теми, что возникают при миграции схемы базы данных, давно возникали при конфигурировании серверов.
Как автоматизировать установку нужного софта на сервер и обеспечить обновление конфигураций? Мы можем написать shell-скрипт, который, будучи выполнен на пустой виртуальной машине, всё на ней установит и сконфигурирует. Это аналог скрипта создания структуры БД (CREATE TABLE…-скрипта), но его проблема в том, что он может быть выполнен только один раз и только на пустой машине. Если машина уже развёрнута и работает, а по новой спецификации для работы системы нам нужна, например, другая версия Java, как тут постуить — дописывать change скрипт, сносящий старую версию и устанавливающий новую версию Java? Ну а если нам нужно будет воспроизвести конфигурацию на пустой машине — нам что же, проходить через все шаги, которые мы прошли исторически?
Главный, ключевой вопрос, который при этом возникает: можно ли править инфраструктуру/схему данных так же легко, как мы правим исходный код – прямым изменением её описания и записи в контроль версий?
Ответом на эти вопросы для задачи конфигурирования серверов явилось появление принципа Infastructure as Code (IaC) и целого класса систем, известных как configuration management-системы: Ansible, Chef, Puppet и т. д. В основе всех систем этого вида стоят два главных принципа: идемпотентность (idempotence) и конвергентность (сходимость, convergence). Оба этих термина позаимствованы из математики. Если отбросить ненужный формализм, применительно к нашей проблематике термины эти обозначают следующее:
- Идемпотентный и конвергентный скрипт описывает не действия, которые надо произвести над объектом, а состояние, в которое нужно привести объект.
- Идемпотентность означает, что если такой скрипт выполнен успешно и объект уже находится в нужном состоянии, то повторное выполнение скрипта ничего не изменит и ни к чему не приведёт. Например, если мы говорим о скрипте configuration management системы, декларирующем установку необходимых пакетов, то, если эти пакеты уже установлены, при повторном запуске скрипт просто не выполнит никаких операций.
- Конвергентность означает, что если скрипт не был выполнен или завершился неуспешно, при его повторном выполнении система будет стремиться к желаемому состоянию. Например, если установка одного из пакетов завершилась с ошибкой, т. к. в момент скачивания пакета пропала сеть, то повторный запуск скрипта приведёт к тому, что пропущенный пакет доустановится (а те, что установились в прошлый раз, останутся на своём месте).
Данные принципы являются общими и применимы не только к программной конфигурации машин. Например, система Terraform позволяет вам в виде кода специфицировать необходимый набор виртуальных серверов и их аппаратную конфигурацию и контролировать ваш парк виртуальных машин в облаке.
Я думаю, сказанного уже достаточно для того, чтобы понять, каким образом эти принципы могут помочь для контроля схемы данных. Предположим, что наша база данных поддерживает ключевое слово “CONVERGE” и у нас имеется такой скрипт:
CONVERGE TABLE OrderHeader(
id VARCHAR(30) NOT NULL,
date DATETIME DEFAULT GETDATE(),
customer_id VARCHAR(30),
customer_name VARCHAR(100),
CONSTRAINT Pk_OrderHeader PRIMARY KEY (id)
);
Ключевое слово CONVERGE должно интерпретироваться как «приведи таблицу к желаемой структуре». То есть: если таблицы нет — создай, если таблица есть, посмотри, какие в ней поля, с какими типами, какие индексы, какие внешние ключи, какие default-значения и т. п. и не надо ли что-то изменить в этой таблице, чтобы привести её к нужному виду.
Если бы базы данных могли поддерживать такое ключевое слово, т. е. если бы была возможность писать для баз данных идемпотентные и конвергентные DDL-скрипты — необходимости в написании этой статьи не возникло бы. Все мы просто держали бы в системе контроля версий “CONVERGE TABLE”-скрипты, описывающие желаемую на текущий момент схему данных, и работали бы с ними точно так же, как работаем с исходным кодом: нужно новое поле в таблице — добавили, нужен другой состав полей в индексе — отредактировали. (Я слышу ваш вопрос: а как же быть с миграцией данных — но терпение, я к этому перейду уже скоро.)
К сожалению, в мире реляционных БД движения к поддержке настоящей идемпотентности DDL пока этого не происходит. Всё, что до сих пор было сделано в базах данных по направлению к идемпотентности DDL-кода – это поддержка конструкции CREATE IF NOT EXISTS, но это, скажем прямо – довольно слабая попытка. Скрипт CREATE TABLE IF NOT EXISTS, конечно, внешне поведёт себя как идемпотентный (не выдаст ошибку, если таблица уже создана), но не как конвергентный (не будет модифицировать структуру уже созданной таблицы).
Приходится уповать на внешние инструменты. Идемпотентный и конвергентный DDL доступен, например, в системе Celesta. Чтобы для разработчиков и для инструментов разработки (например, визуального редактора ER-диаграмм) выглядеть как обычный DDL-скрипт, в Celesta применяется ключевое слово CREATE, хотя в Celesta оно обладает смыслом именно гипотетического CONVERGE. При каждом старте, Celesta сравнивает актуальную структуру базы данных, к которой она присоединена, с желаемой структурой, описанной в виде DDL-скрипта CelestaSQL, и при необходимости выполняет минимально необходимую серию CREATE/ALTER/DROP-команд. На сегодня поддерживаются четыре типа баз данных: PostgreSQL, Oracle, MS SQL Server и H2 (последняя, в основном, для нужд организации модульного тестирования).
Идемпотентный скрипт, задающий структуру БД, нельзя просто взять и линейно выполнить. Как известно, одни объекты в базе данных зависят от других объектов — например, таблицы ссылаются друг на друга через внешние ключи, представления и индексы зависят от таблиц и т. д. Поэтому, прежде чем выполнять серию создания / перестроения объектов, их необходимо ранжировать в порядке зависимости друг от друга: говоря формально, выполнить топологическую сортировку графа зависимостей, и далее обрабатывать объекты, начиная с тех, от которых не зависит ничего. Часто бывает необходимо сбросить внешние ключи, чтобы затем восстановить их после модификации соединяемых ими таблиц. Эта задача решена в Celesta, и это позволяет без проблем выполнять апгрейд для абсолютного большинства случаев.
Миграция данных
Так как же быть с трансформацией данных, ведь простого ALTER не всегда достаточно? Что делать, если мы, допустим, захотим добавить в непустую таблицу NOT NULL-поле и не снабдим его DEFAULT-значением? Ведь если такое поле не заполнить предварительно данными, то база данных не даст выполнить ALTER TABLE ADD-скрипт. А если мы хотим добавить Foreign Key, но не все данные в таблице удовлетворяют ограничению? А если, допустим, логика приложения изменилась и требуется перенести данные из одного столбца в другой?
Всё это вопросы совершенно корректные, но для начала заметим, что для большинства изменений, которые вы производите в базе данных в процессе развития вашего приложения, никакой миграции не требуется и простого ALTER-скрипта достаточно. Вам не надо мигрировать данные, если вы просто добавляете новую таблицу или новую колонку в таблицу (NULLABLE или с DEFAULT-значением). Вам не надо мигрировать данные, если вы добавляете или перестраиваете индекс. Не нужно ничего мигрировать, если изменяется запрос для view. Практика применения системы Celesta показывает, что подавляющее большинство производимых разработчиками изменений относится именно к такому типу.
Если же миграция данных действительно необходима, то, да: придётся написать и выполнить одноразовый миграционный скрипт. Вот только сохранять этот скрипт «на века» не нужно и история с его отладкой и применением гораздо проще, чем в системах, построенных на change log.
Рассмотрим случай добавления внешнего ключа на непустую таблицу, не все записи которой удовлетворяют такому ключу. В процессе обновления Celesta попробует создать такой ключ при помощи команды ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY. Если ей это удаётся – отлично, если нет – система останавливается и Celesta сообщает, что апдейт такого-то объекта она выполнить полностью не сумела по такой-то причине, с таким-то сообщением БД.
Для changelog-систем нет ничего хуже changeset-а, выполнившегося наполовину и зафиксированного в промежуточном состоянии: в такой ситуации система находится «посередине» между двумя ревизиями и ситуацию можно разрулить лишь вручную. Отсутствие поддержки откатываемых DDL-транзакций во многих базах вносит дополнительные трудности.
Для «конвергентных» систем, в отличие от «changelog»-систем, апдейты, не выполненные до конца, не являются проблемой, т. к. для генерации ALTER-команд система сравнивает текущее фактическое состояние базы с желаемым, и изменения, не выполненные при одной попытке, она будет пытаться доделать в другой.
Столкнувшись с ситуацией, когда апдейт не может быть выполнен автоматически (ситуация, повторюсь, довольно редкая), вы можете сделать одноразовый скрипт. Допустим, наполняющий таблицу-справочник нужными данными и тем самым создающий для Celesta условия автоматического выполнения апдейта. Этот скрипт можно отладить на свежей копии production-базы, затем выполнить на production базе, затем произвести Celesta-апдейт.
После всего этого ваш скрипт можно просто выкинуть, потому что больше он вам не понадобится никогда! Ведь ваши рабочие базы уже находятся в нужном вам состоянии по структуре, а если вы задумаете делать новую базу «с нуля», то тогда вам не надо заставлять базу проходить весь тот путь, который вы прошли, дорабатывая её структуру в процессе разработки.
Возможно, такой подход покажется кому-то менее надёжным, чем использование changelog-систем, которые заставляют вас думать о необходимых шагах по миграции данных при каждом изменении структуры и о встраивании таких шагов в changeset. Но если подумать, то становится понятно, что надёжность changelog-систем в этом плане – мнимая. Как известно, не бывает программ без потенциальных ошибок, это правило относится и к скриптам модификации данных. Тот факт, что на имеющемся у вас наборе данных скрипт модификации change set-а был отлажен и показал корректную работу, на самом деле не гарантирует со 100% уверенностью, что он выполнится без ошибок на любых данных. В случае с применением идемпотентного DDL мы по крайней мере не объявляем скрипт модификации данных не подлежащим изменению, защищённым контрольной суммой атрибутом ревизии. В случае, если ошибка всё же произойдёт, мы всегда можем повторять попытки апдейта до тех пор, пока не сведём систему к желаемой структуре. Ваши данные не будут потеряны, т. к. Celesta никогда автоматически не выполняет drop столбцов и таблиц, содержащих данные, оставляя эту операцию на выполнение вручную.
* * *
К сожалению, область применения CelestaSQL ограничена использованием его в паре с системой Celesta для создания кода бизнес-логики, поэтому именно для вашего проекта на сегодня, пожалуй, я бы порекомендовал Liquibase. Однако Celesta — проект открытый и одно из возможных направлений его развития – создание инструмента миграции структуры БД общего назначения.
Хотя лично я бы предпочёл, чтобы разработчики БД когда-нибудь реализовали реальную поддержку идемпотентности и конвергентности DDL.
Автор: IvanPonomarev