Версионирование схемы БД бизнес-приложения — практика миграций, предотвращающая «у каждого клиента своя база»
· Го Комура · База данных, SQLite, SQL Server, Миграция, Управление схемой, C#, .NET, Обслуживание, Таблица решений, Разработка Windows
«В БД, установленной у компании А, есть этот столбец, а в БД компании Б его нет. И уже никто не помнит, в какой версии его добавили» — если вам достаётся сопровождение бизнес-приложения, устанавливаемого отдельно у каждого клиента, с большой вероятностью вы столкнётесь именно с такой ситуацией.
В инструкции по обновлению написано: «выполните этот SQL на базе данных». Но был ли он реально выполнен, знает только тот, кто работал на месте, и со временем среди клиентов накапливается смесь: где-то шаг забыли выполнить, где-то он упал с ошибкой на середине и так и остался, а где-то при обновлении перескочили через версию и потеряли промежуточный ALTER TABLE. Спустя годы трудозатраты бесконечно уходят на расследование «ошибки, которая случается только у этого одного клиента».
В этом блоге мы уже разбирали минимальную форму версионирования схемы в статье «Как выбрать место хранения данных Windows-приложения» и проектирование эксплуатации SQLite в статье «Использование SQLite в бизнес-приложениях на C#». Эта статья продолжает эту тему и углубляется в то, как версионировать изменения схемы БД и безопасно применять их к множеству баз данных, разбросанных по клиентам. Основным материалом служит SQLite, но проектирование в равной мере обобщается и на SQL Server (Express).
1. Сначала вывод
- Изменения схемы должны поставляться не как SQL-инструкция, а как код (нумерованные миграции), упакованный прямо в приложение, и применяться автоматически при запуске. Любой процесс, зависящий от того, что человек выполнит инструкцию, ломается в тот момент, когда БД оказывается разбросана по клиентам.
- БД сама должна хранить текущую версию схемы. В SQLite
PRAGMA user_version— это область, зарезервированная именно для этой цели.1 В SQL Server историю применения ведут в отдельной таблице. - Миграции идут только вперёд и только дописываются. SQL под уже выпущенным номером никогда не переписывается — исправление вносится под новым номером. Тогда даже обновление с пропуском версий, с v1.2 на v1.5, сводится к «просто применить по порядку то, что ещё не применено».
- Разрушительные изменения (удаление столбца, переименование) выполняются двухэтапным релизом expand-contract. Сначала выходит релиз только с добавлением, а релиз, удаляющий старую форму, — только после того, как обращения к ней исчезнут.
- От аварии, когда старая версия приложения открывает новую БД, защищаемся проверкой минимальной версии. Принцип: не позволять приложению писать в схему из будущего, которую оно не понимает.
- Перед применением делаем автоматическое резервное копирование. В SQLite
VACUUM INTOодной инструкцией создаёт согласованную копию2, и восстановление при сбое сводится к замене файла. - Одна миграция = одна транзакция, обновление номера версии — в той же транзакции. SQLite умеет откатывать DDL в рамках транзакции.3 В SQL Server есть исключительные DDL, поэтому такие операции выносятся в отдельную миграцию.4
2. Почему возникает проблема «у каждого клиента своя БД»
Если разложить причины, все они сводятся к «эксплуатации, рассчитанной на то, что это делает человек».
- Пропуск ручного ALTER. Нигде в самой БД не остаётся записи о том, был ли выполнен SQL из инструкции, и как только единственный способ проверки — «визуально посмотреть определение таблицы», пропуски гарантированно случаются.
- Оставленный без внимания сбой на середине. Если из пяти SQL-команд инструкции третья завершается ошибкой, исполнитель не может решить, продолжать или откатывать, и получается «приложение же работает, так и оставим». Эта БД теперь имеет уникальную, единственную в своём роде схему, не совпадающую ни с одной версией.
- Обновление с пропуском версий. У клиента, переходящего с v1.2 сразу на v1.5, нужно корректно пройти изменения схемы и v1.3, и v1.4 вместе, а при эксплуатации по инструкции сделать это трудно.
- Экстренный патч на месте. Возникает ситуация «этому одному клиенту столбец добавили заранее», а при последующем официальном обновлении это оборачивается ошибкой повторного применения.
В веб-системе с одним сервером БД одна, и её состояние всегда известно. Принципиальная сложность десктопных бизнес-приложений в том, что БД одного и того же приложения разбросана по десяткам, сотням компьютеров клиентов и филиалов, причём далеко не все они обязательно на одной версии. Процесс, при котором человек разбирается с каждой машиной по отдельности, ломается пропорционально их числу, так что вывод один: дать самому приложению возможность обследовать собственную БД и довести её до актуальной схемы.
3. Базовый паттерн: версия схемы + последовательные миграции вперёд
Каркас механизма состоит всего из трёх элементов.
- Сама БД хранит номер версии схемы (отдельное от версии продукта целое число, предназначенное только для схемы).
- Изменения схемы дописываются в код приложения как последовательность нумерованных миграций.
- При запуске (сразу после подключения к БД) приложение по порядку, внутри транзакций, применяет все миграции с номером больше текущей версии.
Для SQLite местом хранения номера версии может служить PRAGMA user_version. Это целое число, хранящееся в заголовке базы данных (по смещению 60), и официальная документация прямо указывает: «приложение может свободно использовать его, сама SQLite это значение не использует».1 Без создания отдельной таблицы один-единственный файл БД может сам заявлять свою версию.
Самописная реализация на C# становится практичной уже в следующих нескольких десятках строк.
using Microsoft.Data.Sqlite;
public static class SchemaMigrator
{
// Список только для дописывания. SQL под уже выпущенным номером переписывать нельзя категорически
private static readonly (int Version, string Sql)[] Migrations =
{
(1, "CREATE TABLE customer (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"),
(2, "ALTER TABLE customer ADD COLUMN phone TEXT"),
(3, """
CREATE TABLE invoice (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customer(id),
issued_at TEXT NOT NULL, -- храним в UTC, в фиксированном формате
amount INTEGER NOT NULL -- суммы - целые числа в минимальной единице валюты
)
"""),
};
public static void Migrate(SqliteConnection conn)
{
// Ошибка при дописывании (дубликат или неверный порядок номеров) превращается
// в тихое повторное применение или пропуск, поэтому обнаруживаем это до применения чего бы то ни было
for (int i = 1; i < Migrations.Length; i++)
if (Migrations[i].Version <= Migrations[i - 1].Version)
throw new InvalidOperationException(
"Номера версий миграций должны строго возрастать и быть уникальными.");
int current = GetUserVersion(conn);
int latest = Migrations[^1].Version;
if (current > latest)
// Случай, когда БД, созданную более новым приложением, открывает старое приложение (см. 5.2).
// Безопаснее остановиться здесь, чем трогать незнакомую схему
throw new InvalidOperationException(
$"Эта база данных (схема v{current}) создана более новой версией " +
"приложения. Обновите приложение.");
foreach (var (version, sql) in Migrations)
{
if (version <= current) continue;
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
// Обновление версии фиксируем в той же транзакции.
// Это устраняет состояние "изменение внесено, но номер остался старым"
cmd.CommandText = $"PRAGMA user_version = {version}";
cmd.ExecuteNonQuery();
tx.Commit();
}
}
private static int GetUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
Это структурно решает проблемы из раздела 2. Пропуски не случаются (проверка идёт при каждом запуске), сбой на середине откатывается (раздел 6), пропуск версий тоже не проблема (если БД от v1.2 находится на схеме v2, приложение v1.5 просто применит 3, 4 и 5 по порядку). Даже на вопрос «в каком состоянии БД этого клиента» можно ответить, один раз прочитав PRAGMA user_version.
Строго соблюдаются только два правила эксплуатации.
- Уже выпущенный номер не переписывается. Даже если в SQL версии v3 есть баг, исправление вносится в v4. Переписывание порождает новое расхождение: «применена старая v3» у одних и «применена новая v3» у других.
- Преобразование данных тоже включается в миграцию. Не только добавление столбца, но и перенос существующих данных (UPDATE) выполняется под тем же номером. Если с самого начала унифицировать формат и часовой пояс столбцов даты/времени на UTC и фиксированный формат — как описано в статье «Дата, время и часовые пояса в бизнес-приложениях», — последующие миграции упрощаются.
Есть ещё одна работа, нужная только при добавлении этого механизма в уже существующую систему. У БД, эксплуатировавшейся через ручные патчи, может быть состояние «user_version всё ещё 0, но реальная схема частично продвинулась вперёд» (именно это и есть экстренный патч из раздела 2). Поставив такую БД на цепочку миграций как есть, вы получите ошибку ALTER TABLE для уже применённого изменения — «столбец уже существует». В первом релизе, внедряющем этот механизм, нужно однократно, как базовый (baseline) шаг, обследовать реальную схему (в SQLite — проверить наличие столбцов через PRAGMA table_info), проставить соответствующий номер версии для БД с уже известными ручными патчами, и лишь после этого доверить всё дальнейшее последовательным миграциям вперёд. Пропустить этот шаг можно только если механизм был встроен с самого первого релиза.
В SQL Server нет аналога user_version, поэтому в отдельную таблицу (например, schema_version) построчно вставляют «номер версии, время применения, версию приложения на момент применения». Поскольку история сохраняется в виде строк, это устойчивее к последующему расследованию.
4. Использовать инструмент или писать самому — таблица решений
Достичь одного и того же можно тремя способами: EF Core Migrations, библиотека миграций (например, DbUp) и самописная реализация из предыдущего раздела.
| Ось сравнения | EF Core Migrations | Библиотека миграций (DbUp и т. п.) | Самописная реализация |
|---|---|---|---|
| Описание изменения | Автогенерация из изменения C#-модели | SQL-скрипты хранятся как есть в виде ресурсов | Строки SQL или код C# |
| Стоимость освоения | Высокая (нужно понимать модель, инструмент, ограничения) | От низкой до средней | Минимальная (достаточно понять несколько десятков строк) |
| Совместимость с SQLite | Средняя — изменение/удаление столбца превращается в пересборку таблицы, идемпотентные скрипты сгенерировать нельзя5 | Хорошая — в основном ориентирована на SQL Server, но поддерживает и SQLite и др.6 | Отличная — пишется напрямую с полным пониманием ограничений |
| Переиспользование существующего сырого SQL | Переиспользовать сложно (нужно заменить на определения модели) | Отличное — SQL из инструкций переносится почти как есть | Отличное — то же самое |
| Учёт применённого | Таблица истории (автоматически) | Таблица журнала (автоматически)6 | user_version / собственная таблица |
| Совместимость с форматом распространения | Упаковано в приложение, Migrate() при запуске (нюансы ниже) |
Упаковано в приложение, выполняется при запуске | Упаковано в приложение, выполняется при запуске |
Рекомендации по ситуациям:
| Ситуация | Рекомендация | Причина |
|---|---|---|
| Уже используется EF Core для доступа к данным | EF Core Migrations | Позволяет избежать двойного управления моделью и схемой; нет причин добавлять ещё один инструмент |
| В основном сырой SQL (ADO.NET / Dapper) + SQLite | Самописная реализация | Достаточно нулевых зависимостей; об ограничениях ALTER TABLE в SQLite всё равно придётся помнить самому |
| В основном сырой SQL + SQL Server, накоплено много SQL из инструкций | Библиотека вроде DbUp | Существующий SQL можно превратить в скрипты-ресурсы, не изобретая учёт применения самостоятельно |
| Много хранимых процедур и представлений | Библиотека вроде DbUp | Для объектов, которые нельзя сгенерировать из модели, управление в виде SQL-скриптов естественнее |
| БД небольшая, изменения редки | Самописная реализация | Минимизирует стоимость поддержки механизма |
DbUp — это «библиотека .NET, помогающая развёртывать изменения в базе данных SQL Server»: она записывает выполненные скрипты в таблицу журнала и выполняет только ещё не выполненные. Также поддерживает SQLite, PostgreSQL, MySQL и другие СУБД.6 Как путь миграции «превратить SQL из инструкций в автоматическое применение с учётом выполнения» — это, пожалуй, самый короткий вариант.
4.1 Нюансы использования EF Core Migrations в распространяемом приложении
При разработке миграции EF Core применяются через dotnet ef database update, но на компьютере клиента нет ни SDK, ни исходного кода. Реалистичный способ применения — context.Database.Migrate() при запуске приложения.
Здесь важно знать, что документация Microsoft прямо предостерегает от применения миграций при запуске как способа управления продакшен-базой данных. Причины таковы: (1) сбой или повреждение из-за одновременного применения несколькими экземплярами (до EF Core 9), (2) обращение других приложений к БД во время применения миграции может вызвать серьёзные проблемы, (3) приложению требуются повышенные права на изменение схемы, (4) практически нет механизма отката, (5) невозможно заранее проверить и исправить выполняемый SQL — и рекомендуется вместо этого генерировать SQL-скрипты и применять их в рамках процесса развёртывания.7
Однако эта рекомендация исходит из предпосылки серверной системы «одна БД, есть реальный процесс развёртывания». Для десктопного приложения с локальной БД на каждом компьютере клиента физическое перемещение по объектам со скриптами для их выполнения — это и есть та самая проблема из раздела 2, поэтому Migrate() при запуске становится фактическим стандартным решением. Оставшиеся опасения нужно закрыть отдельно.
- Параллелизм: начиная с EF Core 9,
Migrate()автоматически получает блокировку, предотвращающую одновременное выполнение миграций несколькими процессами.7 В более ранних версиях сериализацию нужно делать самостоятельно, как описано в разделе 6. Однако эта блокировка сериализует только сами запуски миграций — она не останавливает обычное чтение и запись старой версией приложения во время применения миграции. Для общей БД планируйте это в сочетании с проверкой минимальной версии (раздел 5.2) или окном обслуживания. - Предварительная проверка SQL: обязательно просматривайте сгенерированную миграцию и репетируйте её на БД с данными, сопоставимыми с продакшеном, перед релизом (раздел 6.3).
- Не смешивать с
EnsureCreated(): схема будет построена без истории миграций, иMigrate()впоследствии начнёт падать. Унифицируйте всё наMigrate()с самого начала.7
В провайдере SQLite миграция, изменяющая тип столбца или удаляющая его, выполняется как полная пересборка таблицы — создание новой таблицы, копирование данных, удаление старой таблицы, переименование, — и идемпотентные скрипты сгенерировать тоже нельзя.5 Стоит ли вообще использовать EF Core, разобрано в разделе 8 статьи «Использование SQLite в бизнес-приложениях на C#».
5. Как писать миграции, которые не ломаются
Принцип для каждой отдельной миграции: «никогда не делать изменение, ломающее обратную совместимость, в один релиз».
5.1 Разрушительные изменения — через expand-contract (двухэтапный релиз)
Добавление столбца безопасно, а удаление, переименование или изменение типа ломает «всё, что рассчитывало на старую форму». Даже при локальной SQLite и связи приложения с БД один к одному обычно найдётся хотя бы одно из: (а) возможность отката приложения на старую версию при сбое, (б) другой инструмент, читающий БД напрямую (инструмент отчётов, экспортёр CSV, интеграция с Access), (в) конфигурация с SQL Server, к которой одновременно обращаются старые и новые клиенты. Поэтому разрушительные изменения делятся на два этапа — expand (расширение) → contract (сужение).
| Изменение | Что произойдёт, если сделать за один раз | Безопасный двухэтапный вариант |
|---|---|---|
| Переименование столбца | Старые приложения/отчёты, ссылающиеся на старое имя, мгновенно ломаются | expand: добавить новый столбец и скопировать значения из старого. Новое приложение пишет в оба, а чтение по-прежнему считает эталоном старый столбец (потому что на общей БД, где одновременно работают старое и новое приложения, старое пишет только в старый столбец; можно также синхронизировать через триггер на стороне БД) → contract: после того как старые приложения отсечены, финально скопировать последние значения из старого столбца в новый, переключить чтение на новый столбец и удалить старый (переключение до отсечения старых приложений потеряет обновления, записанные старым приложением только в старый столбец) |
| Удаление столбца | INSERT/SELECT старого приложения завершаются ошибкой | expand: приложение просто перестаёт на него ссылаться (столбец остаётся) → contract: удалить через несколько релизов |
| Изменение типа/смысла (например, локальное время → UTC) | Старые и новые значения смешиваются в одном столбце, и всё тихо ломается | expand: добавить новый столбец и заполнить его преобразованными значениями. Период сосуществования обрабатывается как при переименовании (новое приложение пишет в оба, чтение считает эталоном старый столбец) → contract: после отсечения старых приложений выполнить финальное преобразование из старого столбца, переключить чтение, удалить старый столбец |
| Добавление ограничения NOT NULL | Применение падает на существующих NULL-строках. Запись NULL старым приложением тоже мгновенно нарушает ограничение | expand: подготовить значение по умолчанию и обновить всех клиентов до версии, пишущей не-NULL → contract: после отсечения старых приложений заполнить оставшиеся NULL через UPDATE, и только затем добавить ограничение |
Релиз со стороной contract (удаление) безопасно выпускать только после того, как проверка минимальной версии (следующий раздел) сможет реально отсечь старые приложения.
У SQLite есть свои особенности: ALTER TABLE поддерживает только переименование таблицы, переименование столбца, добавление столбца и удаление столбца, причём даже удаление столбца имеет множество ограничений — нельзя удалить столбец, входящий в PRIMARY KEY или UNIQUE-ограничение, или столбец, на который ссылается индекс, CHECK-ограничение, внешний ключ или представление. Любое другое изменение выполняется по процедуре, определённой официальной документацией: создать новую таблицу внутри транзакции, перенести данные через INSERT INTO new_X SELECT ... FROM X, затем удалить старую таблицу и переименовать.3 На большой таблице это превращается в полное копирование, поэтому нужно заранее закладывать время применения и свободное место на диске.
5.2 Защита от отката версии — проверка минимальной версии
При проектировании только с миграциями вперёд скрипты отката (down) не пишутся (у клиента им негде применяться, а непроверенный код только опасен). Вместо этого нужен механизм, останавливающий работу, если старая версия приложения открывает более новую БД. Именно это делает начало кода из раздела 3: если user_version больше максимального известного приложению значения, оно выбрасывает исключение и прерывает запуск.
Если заранее договориться, что «в релизах, откуда возможен откат, разрушительных изменений не будет (только expand)», то чтение новой БД старым приложением само по себе безопасно, и проверку можно ослабить до «предупредить и запустить в режиме только для чтения». Выбор зависит от того, насколько бизнес может позволить себе простой.
5.3 Автоматическое резервное копирование перед применением
Миграция — это операция над «продакшен-данными на чужом компьютере». Автоматизируйте практику «сначала бэкап, потом выполнение». В SQLite для этого идеально подходит VACUUM INTO — одна инструкция создаёт согласованный снимок в отдельный файл даже с работающей БД.2
// Резервную копию одного поколения делаем непосредственно перед применением, и только когда оно нужно
if (GetUserVersion(conn) < latest)
{
Directory.CreateDirectory(backupDir);
var backupPath = Path.Combine(backupDir,
$"app_schema_v{GetUserVersion(conn)}_{DateTime.Now:yyyyMMdd_HHmmss}.db");
// Создаём под временным именем и переименовываем только после успеха, чтобы незавершённый
// файл после отключения питания или принудительного завершения процесса не выглядел "готовым бэкапом"
var tempPath = backupPath + ".tmp";
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", tempPath);
cmd.ExecuteNonQuery(); // VACUUM выполняется вне транзакции
File.Move(tempPath, backupPath);
// Если при запуске остался файл *.tmp - это след предыдущего сбоя, его нужно удалить
}
Если включить версию схемы в имя файла, при восстановлении сразу видно, «до какого момента» откатываться. Подробности о резервном копировании, включая то, почему простое копирование файла работающей БД — источник повреждений, см. в разделе 7 статьи «Использование SQLite в бизнес-приложениях на C#». Для SQL Server эквивалент — выполнение BACKUP DATABASE перед применением; идея та же.
6. Эксплуатационные ловушки
6.1 Сбой на середине и транзакции — учитывайте различия СУБД
Код из раздела 3 оборачивает одну миграцию в одну транзакцию и включает обновление user_version в ту же транзакцию. Это работает потому, что SQLite умеет выполнять DDL (CREATE TABLE, ALTER TABLE и т. п.) внутри транзакции и откатывать его при сбое. Сама официальная процедура пересборки таблицы построена как «начать транзакцию, выполнить CREATE/INSERT/DROP/RENAME, зафиксировать».3 Даже если питание пропадёт на середине, БД при следующем запуске окажется в согласованном состоянии «непосредственно перед этой миграцией».
SQL Server тоже умеет выполнять множество DDL внутри транзакции, но есть исключения. Например, ALTER DATABASE нельзя использовать внутри явной транзакции, а CREATE FULLTEXT INDEX тоже нельзя разместить внутри пользовательской транзакции.4 EF Core тоже автоматически оборачивает каждую миграцию в транзакцию, когда это возможно, но прямо указывает, что «некоторые операции на некоторых базах данных нельзя выполнить внутри транзакции».8 Практическое правило сводится к одному: не смешивайте операцию, не входящую в транзакцию, с обычным изменением схемы в одной и той же миграции. При смене СУБД обязательно проверяйте, участвует ли DDL в транзакциях.
Классическая авария — «обновление версии в отдельной транзакции». Если само изменение прошло успешно, а процесс упал до обновления номера, при следующем запуске та же миграция выполнится повторно и упадёт с ошибкой «таблица уже существует», приводя к бесконечному сбою запуска. Если включить обновление номера в ту же транзакцию, это принципиально невозможно.
6.2 Одновременный запуск нескольких процессов — сериализация применения
Бизнес-приложение — это ПО, которое «утром все запускают одновременно». Несколько клиентов, обращающихся к общей БД (SQL Server), или многократный запуск на одном компьютере могут привести к одновременному выполнению миграций.
- Начиная с EF Core 9,
Migrate()автоматически получает блокировку на уровне всей базы данных, предотвращая одновременное применение (в более ранних версиях этой защиты нет). Отметим, что блокировка в провайдере SQLite реализована через специальную таблицу блокировки, и в официальной документации отмечено, что таблица может остаться, если процесс, применявший миграцию, аварийно завершится.7 Если запуск застрял в ожидании этой блокировки, убедитесь, что ни один другой процесс реально не выполняет миграцию, и восстановитесь, удалив (DROP) оставшуюся таблицу блокировки (__EFMigrationsLock). - В самописной реализации для локальной БД проще всего сериализовать через именованный Mutex.
// Добавляем префикс Global\, чтобы сериализация действовала в масштабе всего компьютера
// даже при запуске из нескольких сессий входа через RDP или переключение пользователей
// (Local\ ограничивается только текущей сессией)
using var mutex = new Mutex(false, @"Global\MyApp.SchemaMigration");
try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
// Предыдущий владевший процесс аварийно завершился, не вызвав Release.
// Несмотря на исключение, само владение уже получено, поэтому можно продолжать.
// Возможность того, что предыдущее применение завершилось на середине, покрывается
// последующей повторной проверкой версии и транзакциями для каждой миграции
}
try
{
SchemaMigrator.Migrate(conn);
}
finally
{
mutex.ReleaseMutex();
}
Тот, кто ждал, после получения блокировки снова проверяет версию (код из раздела 3 каждый раз заново проверяет version <= current перед применением), поэтому повторного применения не произойдёт. Отметим также, что именованный объект Global\ по умолчанию имеет ACL, унаследованный от создавшего его пользователя, поэтому открытие того же Mutex из сессии другой учётной записи Windows может вызвать UnauthorizedAccessException. Если предполагается использование из нескольких учётных записей, либо создавайте объект через MutexAcl из System.Threading.AccessControl, предоставив нужным пользователям права synchronize/modify, либо переходите к блокировке на стороне БД, описанной ниже. Поскольку Mutex не действует между машинами, для общей БД лучше опираться на сериализацию на уровне БД: «завершить применение на стороне сервера до раздачи обновления», «брать блокировку на стороне БД в момент начала применения (BEGIN IMMEDIATE для SQLite, application lock для SQL Server)».
6.3 Репетиция — тестируйте одномоментное применение от «самой старой БД»
Баги миграций почти никогда не находятся на машине разработчика, потому что БД на ней всегда находится на актуальной схеме, а данные чистые. Ломается то, что у клиента — старая, большая БД с неожиданными данными. Перед релизом нужно сделать как минимум три вещи.
- Хранить файл БД для каждой версии схемы как тестовую фикстуру и автоматизировать тест, применяющий миграции от каждой из них до последней за один проход. Паттерны с пропуском вроде «с v1 на v5» или «с v3 на v5» — это и есть реальность у клиентов. С SQLite достаточно поместить файл БД в репозиторий, так что такой тест писать сравнительно легко.
- Тестировать на данных, сопоставимых по объёму и характеру с продакшеном. Столбцы, полные NULL, неожиданные дубликаты, время пересборки на огромной таблице (раздел 5.1) не проявятся, если данные далеки от настоящих. По возможности репетируйте на анонимизированной БД клиента.
- Проверять сценарий сбоя. Убейте процесс в середине применения и убедитесь, что при следующем запуске происходит корректное восстановление (повторное применение начинается с версии, к которой произошёл откат).
7. Итог
- «У каждого клиента своя БД» — это не вопрос внимательности исполнителя, а структурное следствие эксплуатации, при которой человек выполняет SQL-инструкции. Для десктопного бизнес-приложения с БД, разбросанной по клиентам, единственный вариант — дать приложению самому обновлять собственную БД.
- Каркас — это номер версии схемы, который хранит сама БД (
PRAGMA user_versionдля SQLite1) плюс применение нумерованных миграций только вперёд при запуске. На C# для этого хватает нескольких десятков строк самописного кода. - Способов три: EF Core Migrations, библиотека вроде DbUp и самописная реализация. Выбор зависит от того, уже ли используется EF Core и сколько сырого SQL уже накоплено как актив (таблица решений в разделе 4). У вызова
Migrate()при запуске в EF Core официально перечислены нюансы,7 поэтому используйте его вместе с защитой от параллелизма и репетициями. - Разрушительные изменения выполняются двухэтапным релизом expand-contract, а авария, при которой старое приложение открывает новую БД, останавливается проверкой минимальной версии. Ограничения ALTER TABLE в SQLite и процедура пересборки следуют официальной документации.3
- Принцип: одна миграция = одна транзакция, обновление версии — в той же транзакции. В SQL Server есть DDL, не входящий в транзакцию,4 поэтому такие операции выносятся отдельно. Только вместе с резервным копированием через
VACUUM INTOперед применением2 и репетицией одномоментного применения от самой старой версии миграция становится тем, что действительно «можно поставить клиенту».
Если процесс с ALTER по инструкциям вам знаком, попробуйте в следующем релизе добавить хотя бы «запись номера версии» и «применение при запуске». Как только этот фундамент появится, двухэтапные релизы и резервное копирование можно будет добавлять постепенно.
Похожие статьи
- Использование SQLite в бизнес-приложениях на C# — режим WAL, блокировки, защита от повреждений, разграничение с EF Core
- Как выбрать место хранения данных Windows-приложения — таблица решений SQLite / JSON / реестр / Access
- Не только appsettings.json — практика управления конфигурацией Windows-бизнес-приложений
- Дата, время и часовые пояса в бизнес-приложениях — от ловушек DateTime до принципа хранения в UTC и проектирования тестов
Смежные области консультаций
В Komura Software LLC мы занимаемся проектированием БД и внедрением инфраструктуры миграций для бизнес-приложений, устанавливаемых у каждого клиента отдельно, расследованием и нормализацией схем, разошедшихся из-за эксплуатации по инструкциям, а также проектированием распространения обновлений как для конфигураций с EF Core, так и с сырым SQL.
- Разработка приложений для Windows
- Доработка и обслуживание существующего ПО для Windows
- Техническая консультация и ревью архитектуры
- Контакты
Справочные материалы
-
SQLite, Pragma statements supported by SQLite - user_version. О том, что user_version — целое число, хранящееся в заголовке базы данных (по смещению 60), зарезервированное для свободного использования приложением, и что сама SQLite это значение не использует. ↩ ↩2 ↩3
-
SQLite, VACUUM. О том, что VACUUM INTO не изменяет исходную БД, создавая согласованный снимок работающей базы данных в отдельном файле, и может использоваться как альтернатива backup API. ↩ ↩2 ↩3
-
SQLite, ALTER TABLE. О том, что ALTER TABLE в SQLite ограничен переименованием таблицы, переименованием столбца, добавлением столбца и удалением столбца, о многочисленных ограничениях при удалении столбца, и о том, что прочие изменения схемы выполняются по официальной процедуре: создание новой таблицы внутри транзакции, копирование данных, удаление старой таблицы, переименование. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, ALTER DATABASE (Transact-SQL) и CREATE FULLTEXT INDEX (Transact-SQL). О том, что ALTER DATABASE должен выполняться в режиме автокоммита и не допускается внутри явной или неявной транзакции, и что CREATE FULLTEXT INDEX нельзя разместить внутри пользовательской транзакции. ↩ ↩2 ↩3
-
Microsoft Learn, SQLite EF Core Database Provider Limitations. О том, что многие операции миграции в провайдере SQLite выполняются как пересборка таблицы, и что идемпотентные скрипты сгенерировать нельзя. ↩ ↩2
-
DbUp, DbUp Documentation и Supported Databases. О том, что это библиотека .NET, помогающая развёртывать изменения в базе данных SQL Server, записывающая выполненные SQL-скрипты и выполняющая только ещё не выполненные, а также поддерживающая SQLite, PostgreSQL, MySQL и другие СУБД. ↩ ↩2 ↩3
-
Microsoft Learn, Applying Migrations (EF Core). О пяти причинах, по которым применение миграций во время выполнения (при запуске) считается неподходящим для управления продакшен-базой данных, о рекомендации генерировать SQL-скрипты вместо этого, о недопустимости совместного использования EnsureCreated() и Migrate(), об автоматическом получении блокировки на уровне всей базы данных в Migrate() начиная с EF Core 9, и о том, что блокировка в провайдере SQLite реализована через таблицу, которая может остаться после аварийного завершения. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Managing Migrations (EF Core). О том, что EF Core автоматически оборачивает каждую миграцию в транзакцию при применении, когда это возможно, и что некоторые операции на некоторых базах данных нельзя выполнить внутри транзакции. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
CI/CD для приложений WinForms / WPF на практике — автоматизация от сборки до подписи и распространения через GitHub Actions
Практическое руководство по настройке CI/CD для приложений WinForms / WPF через GitHub Actions. Минимальный YAML для сборки и тестов на w...
Как безопасно вносить изменения в legacy-приложение без тестов — характеризационное тестирование и рефакторинг на практике
На примерах C# разбираем порядок характеризационного тестирования (метод golden master) для фиксации текущего поведения, способы создания...
До каких пор будут работать приложения VB6 ── состояние поддержки среды выполнения и практичный путь миграции на .NET
До каких пор будут работать приложения VB6? В статье разбирается асимметрия между политикой поддержки среды выполнения VB6 (поддерживаетс...
Как выбрать место хранения данных Windows-приложения — таблица решений для SQLite / JSON / реестра / Access
Где и в каком формате хранить данные Windows-приложения. Разбираем выбор между AppData и ProgramData, а также сильные стороны и подводные...
Чек-лист перед миграцией с .NET Framework на .NET
Практический чек-лист того, что нужно проверить перед миграцией с .NET Framework на .NET: типы проектов, неподдерживаемые технологии, зав...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Как управлять изменениями схемы БД в бизнес-приложении?
- Вместо процесса, при котором человек вручную выполняет SQL-инструкции из руководства, схемные изменения следует упаковывать в виде нумерованных миграций (кода изменения схемы) прямо в приложение и применять автоматически при запуске. БД сама должна хранить текущую версию схемы (PRAGMA user_version для SQLite, отдельная таблица для SQL Server), а приложение должно применять по порядку, внутри транзакций, только ещё не применённые номера. При такой форме даже обновление с пропуском версий, например с v1.2 сразу на v1.5, применит все промежуточные изменения схемы, и состояние «у каждого клиента своя форма БД» структурно перестаёт возникать.
- Можно ли вызывать Migrate() из EF Core при запуске приложения?
- Это условно реалистичный выбор. Документация Microsoft предупреждает об осторожности при применении миграций во время запуска в продакшене — из-за одновременного применения несколькими экземплярами, необходимости давать приложению права на изменение схемы, невозможности заранее проверить SQL и других причин — и рекомендует для серверных приложений применение через генерацию SQL-скриптов. Однако для десктопного бизнес-приложения с локальной БД на каждом клиентском компьютере процесс выполнения скриптов на месте попросту нереализуем, поэтому вызов Migrate() при запуске становится фактическим стандартным решением. И в этом случае обязательно сочетайте его с защитой от одновременного запуска (автоматическая блокировка в EF Core 9+ или самописный Mutex) и резервным копированием перед применением.
- Что произойдёт с БД, если миграция прервётся на середине?
- Если обернуть одну миграцию в одну транзакцию и включить обновление номера версии в ту же транзакцию, при сбое произойдёт откат к состоянию до начала этой миграции, и незавершённая схема не останется. SQLite позволяет выполнять DDL вроде CREATE TABLE и ALTER TABLE внутри транзакции, и сама официальная процедура пересборки таблицы написана в расчёте на транзакцию. SQL Server тоже позволяет выполнять многие DDL внутри транзакции, но есть исключения — например, ALTER DATABASE и операции, связанные с полнотекстовыми индексами, — поэтому такие исключительные операции стоит выносить в отдельную миграцию. А если есть автоматическое резервное копирование перед применением, даже в худшем случае можно восстановиться простой заменой файла.
- Если схема БД уже разошлась по разным клиентам, как её нормализовать?
- Сначала нужно определить единственную «правильную, эталонную схему» и обследовать БД каждого клиента, выявив расхождения с этим эталоном. Затем для БД без номера версии нужно написать базовую (baseline) миграцию, которая обнаруживает каждый реально встречающийся вариант и приводит его к эталонной форме, а по завершении этой миграции проставить номер версии. В SQLite наличие столбца можно механически определить через sqlite_master или PRAGMA table_info, и расхождения можно устранить защитным SQL вида «добавить столбец, если его ещё нет». Как только все последующие изменения будут идти через нумерованные миграции, расхождения больше не повторятся.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки