业务应用数据库架构的版本管理 ── 防止「每个客户数据库都不一样」的迁移实践

· 更新日期: · · 数据库, SQLite, SQL Server, 迁移, 架构管理, C#, .NET, 维护, 判断表, Windows开发

「装在 A 公司的数据库里有这一列,但 B 公司的数据库里却没有。而且现在已经没人知道这一列到底是在哪个版本加上去的了」──只要接手过安装在各个客户处的业务应用的维护工作,大概率都会遇到这种状况。

更新操作手册上写着「请将这段 SQL 在数据库中执行」。但实际是否执行过,只有现场的操作人员自己知道,于是逐渐混入了忘记执行的客户、执行到中途报错就被放置不管的客户,以及因为跳版本更新而漏掉了中间某次 ALTER TABLE 的客户。几年之后,大量工时就会被无止境地吸入「只在特定客户处才会出现的错误」的排查工作之中。

本博客曾在《Windows 应用数据存储位置的选择》一文中介绍过为架构标注版本号的最小实现代码,并在《在 C# 业务应用中使用 SQLite》一文中讲解了 SQLite 的运维设计。本文作为这两篇文章的延续,将深入探讨 如何对数据库架构的变更进行版本管理,以及如何安全地将其应用到分散在各个客户处的众多数据库中。本文以 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 是否已经执行,数据库的任何地方都不会留下记录,而当唯一的确认手段只剩「目视检查表定义」时,遗漏就必然会发生。
  • 中途失败被放置不管。 操作手册中 5 条 SQL 语句中的第 3 条报错时,操作人员既无法判断该继续还是回滚,结果往往就是「反正应用还能跑,那就这样吧」。这个数据库从此就拥有了一份世界上独一无二的架构,不再与任何一个版本相符。
  • 跳版本更新。 从 v1.2 直接跳到 v1.5 的客户,需要正确地依次经历 v1.3 与 v1.4 的架构变更,但在依赖操作手册的运维方式下,这一点很难做到。
  • 紧急现场补丁。「只给这一个客户先加上这一列」这种情况会发生,导致日后的正式更新出现重复应用错误。

对于只有一台服务器的 Web 系统来说,数据库只有一份,状态随时都能掌握。桌面业务应用真正困难的地方在于,同一款应用的数据库会分散在数十甚至数百台客户、据点的电脑上,而且并不是所有电脑都处于同一个版本。让人一台一台去处理的运维方式,会随着台数的增加而必然崩溃,因此结论只有一个:让应用自身具备检查自己的数据库、并将其提升到最新架构的能力。

3. 基本模式:架构版本 + 前向迁移

这套机制的骨架只有三个要素。

  1. 数据库自身持有一个架构版本号(与应用产品版本无关,是专门用于架构的整数)。
  2. 架构变更以带编号的迁移的形式,持续追加到应用程序的代码中。
  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 就能回答。

运维规则只有两条,必须严格遵守。

  • 不要改写已出货的编号。 即使 v3 的 SQL 存在缺陷,也要在 v4 中修正。改写会制造出新的不一致:一部分数据库「应用的是旧版 v3」,另一部分「应用的是新版 v3」。
  • 数据转换也要包含在迁移之中。 不仅是新增列,把既有数据的迁移(UPDATE)也放进同一个编号里完成。正如《业务应用的日期时间与时区》一文所述,如果从一开始就把日期时间列统一为 UTC 与固定格式,后续的迁移会变得更简单。

还有一点,只有在给既有系统事后引入这套机制时才需要做的工作。一直靠手工补丁运维的数据库,有可能出现「user_version 仍然停留在 0,而实际架构却已经局部推进」的状态(第 2 章中的紧急补丁正是这种情况)。如果直接把这样的数据库接入这条迁移链,针对已经应用过的变更执行 ALTER TABLE 就会因为「列已存在」而报错并失败。在引入本机制的首个发布版本中,应该作为一次性的基线处理,检查实际的架构(SQLite 可以用 PRAGMA table_info 确认列是否存在),对已知打过手工补丁的数据库刻入对应的版本号,之后再把后续变更全部交给前向迁移。只有从最初的发布版本就引入本机制的情况,才能跳过这一步。

SQL Server 没有与 user_version 对应的机制,因此需要在专用表(例如 schema_version)中逐行 INSERT「版本号・应用时间・应用时的应用版本」。由于历史以行的形式保留了下来,日后排查时会更有优势。

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 自行实现 零依赖就足够,SQLite 的 ALTER TABLE 限制终归还是要自己去了解
以原生 SQL 为主 + SQL Server,且已经积累了大量操作手册 SQL DbUp 等迁移库 既有 SQL 可以直接资产化为脚本,不必自建应用状态管理
存储过程、视图较多 DbUp 等迁移库 对于无法从模型生成的对象,以 SQL 脚本的形式管理更直接
数据库规模小、变更频率也低 自行实现 把机制的维护成本降到最低

DbUp 是「一款帮助将变更部署到 SQL Server 数据库的 .NET 库」,它会把已执行的脚本记录到日志表中,只执行尚未执行的部分。它同样支持 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,共 5 点,推荐做法是生成 SQL 脚本,在部署环节中应用。7

不过,这条建议的前提是「数据库只有一份、并且存在部署环节」的服务器系统。对于每台客户端 PC 都拥有本地数据库的桌面应用来说,带着脚本挨家挨户跑一遍现场,恰恰就是第 2 章所述问题本身,因此启动时调用 Migrate() 事实上就是标准做法。剩下要做的是针对遗留顾虑采取相应措施。

  • 并发执行:从 EF Core 9 开始,Migrate() 会自动获取锁,防止多个进程同时执行迁移7 更早的版本需要按第 6 章所述自行做串行化处理。不过需要注意,这个锁只会让「迁移的执行」之间相互串行,并不能阻止旧版本应用在迁移进行期间正常地读写数据。在共享数据库的场景下,前提是要配合最低版本检查(5.2 节)或维护时间窗口一起使用。
  • 事先审查 SQL:务必审查自动生成的迁移内容,并在发布前使用与真实数据相当的数据库进行彩排(6.3 节)。
  • 不要与 EnsureCreated() 混用:混用会导致架构在没有迁移历史的情况下被构建出来,之后再调用 Migrate() 就会失败。从一开始就统一使用 Migrate()7

在 SQLite 提供程序下,包含列的类型变更或删除的迁移,会以「新建表 → 复制数据 → 删除旧表 → 重命名」的表重建方式执行,并且也无法生成幂等脚本。5 是否要使用 EF Core 本身,可以参考《在 C# 业务应用中使用 SQLite》第 8 章中的整理。

5. 编写不会崩坏的迁移

单条迁移应该遵循的原则是「不要在一次发布中破坏向后兼容性」。

5.1 破坏性变更通过 expand-contract(两阶段发布)完成

新增列是安全的,但删除、重命名、变更类型会破坏「以旧形式为前提的某些东西」。即便是本地 SQLite,应用与数据库一对一,通常也至少会存在以下情况之一:(a) 出现故障时有可能把应用回退到旧版本;(b) 存在直接读取数据库的其他工具(报表工具、CSV 导出工具、Access 联动);(c) 新旧客户端会同时访问同一个 SQL Server 的架构。因此,破坏性变更要拆成 expand(扩展)→ contract(收缩)两个阶段。

变更内容 一次性完成会发生什么 安全的两阶段做法
重命名列 引用旧列名的旧版应用・报表会立即出错 expand:新增列,并从旧列复制数值过去。新版应用向两列都写入,但读取仍以旧列为准(这是因为在旧版应用与新版应用同时运行的共享数据库中,旧版应用只会写入旧列;也可以用数据库触发器来做同步) → contract:在把旧版应用挡在外面之后,先把旧列的最新值做最后一次复制到新列,再把读取切换到新列,最后删除旧列(如果在挡住旧版应用之前就切换,就会漏掉旧版应用只写入旧列的那部分更新)
删除列 旧版应用的 INSERT/SELECT 会报错 expand:应用只是停止引用该列(列本身保留) → contract:数个版本之后再删除
类型・含义变更(例如本地时间→UTC) 新旧数值混杂在同一列中,悄无声息地出错 expand:新增列,存入已转换的数值。共存期间的处理方式与重命名相同(新版应用两列都写,读取以旧列为准) → contract:挡住旧版应用之后,先对旧列做最后一次转换,再切换读取,然后删除旧列
新增 NOT NULL 约束 已有的 NULL 行会导致应用失败,旧版应用写入 NULL 也会因违反约束而立即出错 expand:准备默认值,把所有客户端都更新到会写入非 NULL 值的版本 → contract:挡住旧版应用之后,用 UPDATE 把残留的 NULL 补齐,然后再添加约束

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 文件,说明是上一次失败留下的痕迹,应将其删除
}

在文件名中加入架构版本号,恢复时就能一眼看出「要回退到哪一步」。关于运行中数据库的简单文件复制为何容易导致损坏等备份细节,请参考《在 C# 业务应用中使用 SQLite》第 7 章。对 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 如果启动时一直卡在等待锁上,可以先确认没有其他进程正在执行迁移,再删除残留的锁表(__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,因此从另一个 Windows 账户的会话中打开同一个 Mutex 时,有可能出现 UnauthorizedAccessException。如果前提是要跨多个账户使用,可以用 System.Threading.AccessControlMutexAcl 在创建时为相关用户授予同步・修改权限,或者改用下面提到的数据库侧锁定。由于 Mutex 无法跨越多台机器,共享数据库场景下应该靠数据库侧的串行化来解决,例如「在分发更新之前先在服务器端完成应用」或「在开始应用时获取数据库侧的锁(SQLite 用 BEGIN IMMEDIATE,SQL Server 用应用锁)」。

6.3 彩排 ── 测试从「最旧的数据库」一次性应用到最新版

迁移中的缺陷几乎不会在开发机上被发现,因为开发机上的数据库始终是最新架构,数据也很干净。真正会出问题的,是客户那边又旧、又大、还装着意料之外数据的数据库。发布之前至少要做以下三件事。

  • 把每个架构版本的数据库文件保存为测试用固件(fixture),并自动化测试从各个版本一次性应用到最新版的过程。「从 v1 到 v5」「从 v3 到 v5」这类跳版本的模式,正是客户现场的现实情况。对 SQLite 来说,只需要把数据库文件放进代码仓库即可,这类测试算是比较容易编写的一类。
  • 用与真实数据相当的量与质来测试。 全是 NULL 的列、意料之外的重复数据、在巨大表上重建所需的时间(见 5.1 节),只有在数据足够接近真实情况时才会暴露出来。条件允许的话,最好用匿名化后的客户数据库来做彩排。
  • 测试失败路径。 在应用过程中把进程强行杀掉,确认下次启动时能正确恢复(从回滚后的版本重新开始应用)。

7. 总结

  • 「每个客户的数据库都不一样」并不是负责人不够细心的问题,而是由人来执行 SQL 操作手册这种运维方式在结构上的必然结果。对于数据库分散各处的桌面业务应用来说,唯一的办法就是让应用自身把自己的数据库更新到最新状态。
  • 骨架是数据库自身持有的架构版本号(SQLite 用 PRAGMA user_version1)加上在启动时应用带编号的前向迁移。用 C# 实现的话,只需要几十行自行编写的代码即可成立。
  • 手段大致有三类:EF Core Migrations、DbUp 等迁移库,以及自行实现。是否已经在用 EF Core、以及手头有多少原生 SQL 资产,是选择的依据(见第 4 章的判断表)。EF Core 在启动时调用 Migrate() 这一点,官方文档中列出了明确的注意事项,7 使用时要配合并发对策与彩排。
  • 破坏性变更要通过 expand-contract 两阶段发布来完成,「旧版应用打开新数据库」这一事故则用最低版本检查来阻止。SQLite 的 ALTER TABLE 限制与重建步骤,请遵循官方文档。3
  • 原则是一次迁移对应一个事务,版本号更新也在同一个事务中完成。SQL Server 存在无法纳入事务的 DDL,4 因此要把这些例外操作分离出来。只有把应用前的 VACUUM INTO 备份2,以及从最旧版本一次性应用的彩排都做到位,才算得上是一份「能够交付给客户」的迁移。

如果你也对操作手册式的 ALTER 运维方式感到似曾相识,不妨从下一次发布开始,先加入「记录版本号」和「启动时应用」这两点试试看。只要打好了这个地基,两阶段发布和备份这些内容,都可以之后再一点一点补上。

相关文章

相关咨询领域

合同会社小村软件(KomuraSoft LLC)承接安装在各个客户处的业务应用的数据库设计与迁移基础设施的引入、对因操作手册式运维而产生差异的架构进行调查与恢复一致,以及针对 EF Core、原生 SQL 两种配置分别设计更新分发方案。

参考链接

  1. SQLite, Pragma statements supported by SQLite - user_version。关于 user_version 是存储在数据库文件头部(偏移量 60)的整数,预留给应用程序自由使用,SQLite 自身不会用到这个值。  2 3

  2. SQLite, VACUUM。关于 VACUUM INTO 不会修改原始数据库,而是能在另一个文件中为运行中的数据库创建一致的快照,可作为备份 API 的替代方案。  2 3

  3. SQLite, ALTER TABLE。关于 SQLite 的 ALTER TABLE 仅限于重命名表、重命名列、新增列、删除列,删除列存在诸多限制,以及其他架构变更需要在事务内通过新建表 → 复制数据 → 删除旧表 → 重命名这一官方步骤来完成。  2 3 4

  4. Microsoft Learn, ALTER DATABASE (Transact-SQL) 以及 CREATE FULLTEXT INDEX (Transact-SQL)。关于 ALTER DATABASE 必须在自动提交模式下执行,不允许出现在显式或隐式事务内,以及 CREATE FULLTEXT INDEX 不能放在用户事务内。  2 3

  5. Microsoft Learn, SQLite EF Core Database Provider Limitations。关于在 SQLite 提供程序下,许多迁移操作会以表重建的方式执行,且无法生成幂等脚本。  2

  6. DbUp, DbUp Documentation 以及 Supported Databases。关于这是一款帮助将变更部署到 SQL Server 数据库的 .NET 库,会记录已执行的 SQL 脚本并只执行尚未执行的部分,同时也支持 SQLite、PostgreSQL、MySQL 等。  2 3

  7. Microsoft Learn, Applying Migrations (EF Core)。关于运行时(启动时)应用迁移为何被认为不适合用来管理生产数据库的 5 个理由、推荐生成 SQL 脚本进行应用、不应将 EnsureCreated() 与 Migrate() 混用、EF Core 9 及以后版本的 Migrate() 会自动获取整个数据库的锁,以及 SQLite 提供程序的锁以表的形式实现、在异常终止时可能残留。  2 3 4 5

  8. Microsoft Learn, Managing Migrations (EF Core)。关于 EF Core 在应用迁移时会尽可能自动将每条迁移包裹进事务,以及部分操作在某些数据库上无法在事务内执行。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

业务应用的数据库架构变更应该如何管理?
不应该依赖人工执行 SQL 操作手册,而应该把带编号的迁移(架构变更的代码)直接内置到应用程序本体中,在启动时自动应用。让数据库自身记录当前的架构版本(SQLite 用 PRAGMA user_version,SQL Server 用专用表),应用程序只需按顺序在事务中应用尚未应用的版本号。采用这种方式,即使从 v1.2 跳跃更新到 v1.5,中间的所有架构变更也都会被应用,「每个客户的数据库形态都不一样」这种状况就不会再从结构上发生。
可以在应用程序启动时调用 EF Core 的 Migrate() 吗?
这是一个有条件的现实选择。Microsoft 的官方文档以多实例同时应用、需要赋予应用架构变更权限、无法事先审查将要执行的 SQL 等理由,对在生产环境中于启动时应用迁移提出了警示,并建议服务器应用改为生成 SQL 脚本后再应用。另一方面,对于每台客户端 PC 都持有本地数据库的桌面业务应用来说,到现场执行脚本这种运维方式根本无法成立,因此启动时调用 Migrate() 事实上就成了标准做法。在这种情况下,也务必要配合同时启动对策(EF Core 9 及以后版本的自动锁定,或自行实现的 Mutex)以及应用前的备份。
如果迁移中途失败,数据库会变成什么状态?
只要把一次迁移包裹在一个事务里,并且把版本号的更新也包含在同一个事务中,失败时就会回滚到该次迁移开始之前的状态,不会留下不完整的架构。SQLite 的 CREATE TABLE、ALTER TABLE 等 DDL 语句同样可以在事务内执行,官方的表重建步骤本身就是以事务为前提编写的。SQL Server 也可以在事务内执行大多数 DDL,但 ALTER DATABASE 与全文索引相关操作等属于例外,因此应把这些例外操作拆分到单独的迁移中。此外,只要在应用前有自动备份,即使发生最坏情况,也可以通过替换文件来恢复。
如果各个客户的数据库架构已经各不相同,应该如何使其恢复一致?
首先要确定一个「应有的正确架构」,并调查各客户的数据库,梳理出与该基准之间的差异。接下来,针对没有版本号的数据库,编写一个能检测出实际存在的各种模式并将其统一为规范形态的初始迁移,并在该迁移完成的时点刻入版本号。在 SQLite 中,可以通过 sqlite_master 或 PRAGMA table_info 机械化地判断某一列是否存在,并用「如果没有该列就添加」这种防御性 SQL 来吸收差异。此后只要把所有变更都纳入带编号的迁移中,差异就不会再次发生。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表