☰
EF Core CodeFirst迁移实战:从模型变更到生产环境部署指南
2026/10/21 18:05:06 网站建设 项目流程

先交代一下背景。我最早接触CodeFirst的时候,还是在老一代的EntityFramework 6上,那时候团队里基本是“谁改模型谁自己手动改数据库”,开发机上随便改,一上测试环境就炸,不是缺字段就是类型对不上。后来被一个线上事故逼着切到完整的迁移工作流,才发现这东西不是锦上添花,而是CodeFirst模式下绕不开的基线能力。这篇文章就是把我这几年在实际项目里用迁移的经验完整梳理一遍,从最基础的命令到团队协作里的坑,最后再给一份可以直接参考的生产环境操作路径,希望能给正好在折腾实体框架迁移的同学省点时间。

1. 项目概述:为什么CodeFirst项目一定要把迁移机制当回事

1.1 从CodeFirst的开发模式说起

CodeFirst的核心思路是“代码即模型”,你不需要先画数据库表,也不需要先建库,而是直接在代码里定义实体类和数据库上下文,然后让框架根据这些代码把数据库生成出来。举个例子,你写一个客户类:

public class Customer { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } }

再定义一个上下文:

public class AppDbContext : DbContext { public DbSet<Customer> Customers { get; set; } }

然后在启动时配置好数据库提供程序和连接字符串,框架就能自动创建一张Customers表。这个模式对快速原型和早期迭代非常舒服,因为模型的变动直接反映到数据库,不用维护两套定义。但问题也出在“自动”两个字上,一旦项目跑起来,数据库里开始有真实数据,你就不能随便让框架重建库了,这时候就需要一种结构化的版本管理手段,也就是迁移。

1.2 迁移机制到底解决了什么问题

迁移的本质,是给数据库结构做版本管理。它把模型和数据库之间的差异,翻译成一系列可执行、可回溯的变更脚本。每一次模型改动,都可以生成一个对应的迁移文件,这个文件里既包含升级逻辑(Up方法),也包含回滚逻辑(Down方法),相当于给数据库操作装上了“后悔药”。

我用一个生活化的类比来解释:你装修房子,最开始是毛坯,你可以随便改水电线路。但等家具搬进去了,再想改水管,就得提前规划,不能直接砸墙。迁移就是装修公司给你的施工图纸,每改动一处,都记录在案,想改回去也有方案。没有迁移机制的CodeFirst,就相当于拆墙的时候不看图纸,全凭感觉来。

对于团队协作来说,迁移的价值更明显。开发A加了一个字段,开发B加了一张表,每个人都有自己本地生成的迁移文件,提交到代码仓库后,大家通过统一的迁移历史同步数据库结构。这套机制保证了一个词:可复现,任何一个新成员拿到代码库,执行一遍迁移命令,就能得到和团队其他人一致的数据库结构。

2. 迁移的完整工作流:从环境准备到生成第一个版本

2.1 先把工具链配齐

在动手敲命令之前,必须先确认开发环境里的几个关键依赖。这里我以当前主流的Entity Framework Core为主,如果你还在用老版EF6,概念是通用的,只是命令所在程序集不同,我会在关键地方单独标注。

需要安装的NuGet包主要是两个:

  • Microsoft.EntityFrameworkCore.SqlServer:这是针对SQL Server的数据库提供程序。如果你用的是PostgreSQL、MySQL或者SQLite,换成对应的提供程序包即可。
  • Microsoft.EntityFrameworkCore.Tools:这个包提供设计时工具,也就是你在程序包管理器控制台里敲Add-Migration、Update-Database这些命令所依赖的东西。

如果你习惯用命令行而不是Visual Studio内置的程序包管理器控制台,还需要确保环境里安装了.NET CLI,并在项目目录下执行过dotnet restore。CLI方式和PMC方式最终效果完全等价,选择哪一种纯粹看个人习惯。我个人的建议是:如果你主力IDE是Visual Studio,用PMC方便;如果是VS Code或者命令行党,就用CLI。

需要注意一个细节,初学阶段经常把两个包搞混。Tools包是必须装在“启动项目”上的,它负责在编译时读取模型快照;数据库提供程序包也要能被子项目引用到。如果你的解决方案是分层架构,比如领域层放实体、基础设施层放上下文,那迁移命令需要在包含DbConext程序集的非启动项目上执行,并用-StartupProject参数指定启动项目,否则会报“无法创建DbContext”的错误。

2.2 配置连接字符串和上下文注册

工具链配好后,需要在配置里声明连接字符串。以最常用的appsettings.json为例:

{ "ConnectionStrings": { "Default": "Server=(localdb)\\MSSQLLocalDB;Database=MyShopDb;Trusted_Connection=True;MultipleActiveResultSets=true" } }

然后在Program.cs里注册上下文。不同版本的项目模板写法略有差异,但核心逻辑是一样的:把连接字符串传给DbContextOptions。

builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

这一步看着简单,却是很多莫名其妙报错的根源。迁移命令在运行时需要实例化你的DbContext,而它依赖于服务容器里的配置。如果你配置了多个数据库提供程序,或者连接字符串名字写错了,迁移工具都会在生成迁移时直接报错,根本走不到下一步。另一个常见的做法是在设计时工厂类中硬编码连接字符串,专门为迁移工具提供上下文的实例化方式,这个后面聊到已有数据库再展开。

2.3 创建并应用初始迁移

环境就绪后,第一步是生成初始迁移。如果你在PMC控制台里,切换到项目目录所在的默认启动项目,执行:

Add-Migration InitialCreate

如果是CLI,执行:

dotnet ef migrations add InitialCreate

跑完命令后,项目里会多出一个Migrations文件夹,里面包含三个东西:

  • {时间戳}_InitialCreate.cs:这是迁移主体文件,定义了迁移的唯一标识、Up方法和Down方法。
  • {时间戳}_InitialCreate.Designer.cs:这个文件记录了迁移对应的目标模型快照,供后续生成新迁移时做差异对比。
  • AppDbContextModelSnapshot.cs:模型总快照,记录了数据库当前完整的模型状态,是增量迁移的“基准线”。

打开迁移主体文件,你会看到Up方法里的内容和我们手工建的建表语句差不多:

protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "Customers", columns: table => new { Id = table.Column<int>(type: "int", nullable: false) .Annotation("SqlServer:Identity", "1, 1"), Name = table.Column<string>(type: "nvarchar(max)", nullable: false), Email = table.Column<string>(type: "nvarchar(max)", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Customers", x => x.Id); }); }

Down方法则是对应的反操作,删除这张表。迁移文件生成后,数据库还没有任何变化,你需要把迁移真正应用到数据库。PMC执行:

Update-Database

CLI执行:

dotnet ef database update

这条命令会把所有未被应用的迁移按顺序执行。执行完毕后,数据库里会出现一张名为__EFMigrationsHistory的表,里面记录着每个迁移的Id和生效时间。这张表很关键,框架就是靠它来判断哪些迁移已经应用过、哪些还等着被应用。

2.4 对迁移文件结构的深入理解

很多初学者只看Up和Down方法,却忽略了Designer文件和Snapshot文件的作用。其实框架生成增量迁移时,并不是拿你的实体类直接和你上一次的迁移脚本做对比,而是拿当前模型和Snapshot文件里记录的模型做对比。也就是说,Snapshot文件是整个迁移系统的“记忆体”。

举个例子,假设你有Customer和Order两张表,执行完Add-Migration InitialCreate后,Snapshot里就记录了两张表的完整结构。下次你再改Customer实体,加一个Age属性,执行Add-Migration AddAge时,框架会读取Snapshot里的模型,和当前模型结构做对比,发现差异只有Customer多了一个Age字段,于是生成的迁移脚本就只有一条增加列的语句。如果你不小心删除了Snapshot文件且没有提交到代码仓库,框架就失去了基准模型,下次生成迁移时会尝试把库里所有表重新建一遍,造成非常大的破坏。

想清楚了这一层,也就明白了为什么迁移文件是团队协作中绝对不能随便删除的东西。你可以压缩历史迁移,但绝不能只删了某个人的增量迁移而不处理Snapshot。

3. 实战:典型模型变更的增量迁移全过程

3.1 新增字段:最常见也最简单的变更类型

现在进入实际开发中最高频的场景:在实体里加一个字段。假设业务同事说客户表需要记录手机号,我们在Customer类里添加:

public class Customer { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public string Mobile { get; set; } }

接着生成迁移:

Add-Migration AddMobileToCustomer

此时生成的Up方法大概长这样:

protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn<string>( name: "Mobile", table: "Customers", type: "nvarchar(max)", nullable: true); }

注意一点,我这里的Mobile是可空的,因为数据库里已经存在客户数据了,如果新增的非空字段没有默认值,执行迁移时SQL Server会报错。设计模型阶段如果明确这个字段必须有值,就得给出默认值:

public string Mobile { get; set; } = string.Empty;

这样生成的迁移会给所有已有行赋一个空字符串,满足非空约束。这个细节在生产环境变更时非常隐蔽,模型层的代码看着没问题,一大串Update-Database执行到一半突然失败,就是因为忽略了存量数据对非空字段的影响。

3.2 修改字段长度与类型:数据库层面远比想象中敏感

第二个常见场景是调整字段长度或精度。比如初始设计时Name是nvarchar(max),但后来想要限制长度,加数据注解:

[MaxLength(50)] public string Name { get; set; }

生成迁移后,你会看到类似这样的代码:

migrationBuilder.AlterColumn<string>( name: "Name", table: "Customers", type: "nvarchar(50)", maxLength: 50, nullable: false, oldClrType: typeof(string), oldType: "nvarchar(max)");

沿着类似思路,把长度从50改成100也走AlterColumn。这里面要小心的是:把字段长度改小之前,必须先确认存量数据中的最长值不会超过新长度,否则迁移会直接失败。有一次我就处理过一个案例:备注字段原本长度256,业务方想改成128,结果库里有一条写了180个字符的数据,迁移打到一半就中断了。排查方式是在执行迁移前先查一遍:

SELECT MAX(LEN(Remark)) FROM Orders;

如果最大值超过新长度,要么联系业务清理数据,要么用脚本先做截断,要么改变字段语义再考虑别的方案。

修改字段类型的情况更复杂。比如从字符串改为整型,框架生成的迁移往往是先DropColumn再AddColumn,因为数据转换规则不是一句ALTER TABLE就能说清的。一旦走了删除再新增的路线,原有字段上的索引、默认值、约束都会丢失,而且数据大概率也会丢失。此时最稳妥的做法,是在迁移文件里手动追加一段Sql,把旧值转换后放到新增列里,再删掉旧列,这样能最大程度保留数据。这类操作务必在Staging环境反复验证,不能拿生产环境当试验场。

3.3 表内既有数据需要一并初始化时怎么处理

新增字段后如果还要把存量数据一并处理,就涉及迁移脚本里的数据操作了。比较常规的做法是在迁移文件的Up方法里追加:

migrationBuilder.Sql( @"UPDATE Customers SET Mobile = CONCAT('000', CAST(Id AS VARCHAR(10))) WHERE Mobile IS NULL");

更规范的场景是使用HasData预置种子数据,不过HasData在迁移中的行为值得多说一句,它会被翻译成Insert语句的一部分,在迁移执行时写入数据库。如果只是想给新列填充默认值,在迁移执行后用UPDATE语句处理更灵活、可读性也更高。

团队里常出现一种误区:觉得数据修正脚本应该在应用层面做,而不是放到迁移里。但迁移的哲学恰恰是“数据库变更应该和代码版本绑定在一起”。比如你的代码假设Mobile列一定有值,并且写了很多基于该字段的逻辑,那这条UPDATE语句就必须和AddColumn出现在同一个迁移里,否则别人切换分支时就容易遇到“代码跑了但数据结构不对”的中间状态。

3.4 回滚和撤销迁移的正确姿势

变更做完了,代码也上线了,结果发现字段设计不合理,想回到上一个版本。这时候有两个层次的回滚思路。

层次一:数据库已经应用了迁移,要把它恢复到指定版本。命令是:

Update-Database 上一个迁移的Id

或者CLI:

dotnet ef database update 上一个迁移的Id

执行时框架会找到当前数据库对应的迁移版本,然后依次执行目标版本之后的那些迁移的Down方法,把结构往前回拨。回滚后数据库停留在目标版本,但迁移文件还在项目里。

层次二:迁移文件本身还没有应用到数据库,或者刚生成但发现内容不对。这时候可以用Remove-Migration删掉最近一次生成的迁移,同时会把对应的迁移文件和Designer文件一起移除,并更新Snapshot。但要注意,如果这个迁移已经被应用到数据库,Remove-Migration默认会提示你先回滚数据库,不能直接删。

还有一种更彻底的做法是把多个历史迁移压缩成一个基线迁移。当项目运行久了,迁移历史可能上百个,每次执行Update-Database都要挨个跑一遍,很耗时。压缩方式是把当前数据库结构视为基线,删除所有历史迁移文件和Snapshot,重新执行一次Add-Migration InitialCreate,但这个操作有一个前提:必须保证所有环境的数据库都已经和当前模型一致,否则回滚能力就断掉了。压缩迁移之前要评估团队的回滚需求,我的建议是至少保留最近的若干个可逆迁移。

4. 实战踩坑实录:高频问题与排查方案汇总

4.1 迁移冲突:两个人各改各的模型

团队协作中,最典型的冲突场景是:你和同事基于同一个基线版本,各自在自己分支上添加了不同的字段,然后都生成了迁移。合并代码后,Migrations目录下同时出现了两个时间戳几乎一样的迁移文件,传统的Git文本合并可能会把同一个迁移Id的状态弄乱,甚至让Snapshot处于不一致状态。

处理冲突的思路分成几步。首先是理解迁移文件的合并原则:每个人的迁移文件都可以保留,真正的冲突在于Snapshot文件。因为框架只能有一个基准模型,两个分支各自改了Snapshot,合并时Git会提示冲突,你需要手动决定后一个迁移的“基准”。

操作上,我习惯遇到这种情况后,先保留两个人的迁移文件,然后打开AppDbContextModelSnapshot.cs,把最近的模型合到一起。如果模型冲突不大,直接手动合并Snapshot即可;如果模型改动较大,有个更省心的方式:删掉刚才自动生成的那批迁移文件以及Snapshot,在合并后的代码状态下重新跑一次Add-Migration,把两个人的差异合并进同一个迁移里。但这种方式适合还没有部署到共享环境的阶段,一旦某个迁移已经在共享测试库上应用过,就不能随便删除了,只能通过新增一个“补充迁移”来修正结构。

最关键的底线是:不要为了解决冲突而去手动改别人已经提交并应用过的迁移文件。因为已应用迁移有固定的MigrationId,改内容会导致本地库和远程库的迁移哈希不一致,后续执行Update-Database时会莫名其妙地被判定为“历史已应用迁移被篡改”。

4.2 数据库已经存在,如何接入迁移体系

不少项目是老库新代码,数据库表早就手动建好了,现在想切换到CodeFirst迁移模式。这种情况下如果直接执行Add-Migration InitialCreate,生成的迁移会把所有现有表都CreateTable一遍,应用时必然失败,因为表已经存在。

正确的第一步是先做基线迁移。在EF Core里可以这样:

Add-Migration InitialCreate -IgnoreChanges

这个参数生成的迁移只有空的Up和Down方法,Snapshot文件则记录当前数据库完整的表结构。把它应用到数据库时不会执行任何ALTER或CREATE操作,只是在历史表里登记了一条迁移记录,从这一刻起,框架认为该数据库已经处于InitialCreate版本。之后所有的模型变更,再走正常的增量迁移就好。

老库接入迁移时还有一个隐含要求:数据库的表结构必须和实体模型能对得上,或者至少核心字段能对上。如果模型和库表差距很大,建议先用逆向工程生成一套模型,再来做基线迁移。

4.3 种子数据与迁移的正确搭配

项目里总会有一些枚举类、配置类的数据,希望系统初始化时就存在于数据库。在CodeFirst迁移里,最合理的做法是在模型配置阶段用HasData声明数据,例如:

modelBuilder.Entity<OrderStatus>().HasData( new OrderStatus { Id = 1, Name = "待付款" }, new OrderStatus { Id = 2, Name = "已付款" } );

这样在生成迁移时,框架会自动在Up方法里插入这些数据的Insert语句。HasData的主要优点是数据初始化跟着迁移走,别人拉到代码后执行Update-Database,各种基础数据直接就有了,不用在程序启动时额外初始化。

但HasData也有个很容易踩的坑:一旦某条种子数据被迁移应用过,后续修改种子数据时你得生成新的迁移来执行更新,不能直接改代码里的HasData就算完事。否则数据库里的数据是老值,Snapshot和代码里已经是新值,下次Add-Migration会报“模型配置的种子数据与迁移历史记录不一致”之类的错误。

如果企业应用有很复杂的初始化数据需求,我更倾向于在迁移脚本里用migrationBuilder.Sql写定制的数据脚本,因为HasData的表达式能力有限,而且每次模型快照更新时都容易产生连锁提醒。

4.4 生产环境上线:脚本化迁移比自动迁移更稳妥

生产环境对数据库的操作应该谨慎再谨慎,这是所有数据库变更的铁律。虽然EF Core支持在启动时自动执行迁移(调用context.Database.Migrate()),但我不建议在生产环境直接这样用。原因很简单:自动迁移在应用发布时自动执行,一旦某条迁移脚本出现问题,你很难在一个可控的时间窗口内主动处理。

我多年来固定下来的生产路径是:

  1. 在CI流水线里用命令生成迁移脚本,不直接执行数据库更新。
  2. 将生成的SQL脚本纳入发布物品,由数据库管理员审查后手工执行。

生成脚本的命令很简单:

Script-Migration

或者指定从一个版本到另一个版本:

dotnet ef migrations script 上一版本Id 目标版本Id -o upgrade.sql

生成的upgrade.sql是纯SQL文本,DBA可以放在数据库变更流程里走审批。执行并验证通过后,代码再发布的胜算就大了很多。如果因特殊契机需要自动迁移,至少也要把环境设置为开发测试环境,生产环境始终走脚本。

4.5 迁移执行的耗时与性能考量

迁移不只是“建个表”这么简单。当表格数据量达到数百万行时,对一张大表增加索引或重建字段类型,可能锁表时间极长,拖垮线上业务。面对这种情况,我会在迁移文件里手工调整操作步骤,比如:

  • 把单一大表的结构变更,拆成多个短批次迁移,而不是放在一个迁移里一次性执行。
  • 对添加索引的操作,先判断是否可以用在线索引重建。
  • 对修改字段类型的操作,先新增并存列,开启旧列回填,分阶段切换。

虽然EF Core迁移生成器产生的代码是标准且安全的,但性能优化往往需要人来介入。迁移文件本身就是C#代码,你完全可以在此基础上修改,只要保证Down方法能对称回滚即可。实战里不要觉得生成的迁移文件不能改,它只是一个脚手架,合理修改脚本让它更贴合数据规模,才是一个有经验的开发者该做的事。

5. 迁移的长期演进与治理策略

5.1 迁移文件属于谁、怎么组织

一个中大型项目跑上一年,迁移文件的数量可能超过几十个。如果不加治理,Migrations目录会慢慢变得混乱。我的建议是:

  • 命名规范:迁移名称必须清楚描述变更内容,如AddCustomerMobile、CreateOrderTable,不要用migration1这种名字。
  • 代码审查中加入迁移文件的审查环节:重点看Up/Down方法是否对称,字段变更是否存在数据风险,有没有多余操作。
  • 按模块划分上下文:一个大型系统如果有多个相对独立的数据库上下文,可以分别为它们建立迁移目录,而不是混在一起。

另外,单独使用一个初始迁移加多个增量迁移是正确的做法,但不要让同一个团队里所有人长期地各自创建迁移文件而不做整合。每周或每个迭代,安排一个人统一检查迁移历史,及时压缩不必要的历史细节,保持Snapshot可维护性。

5.2 从EF6迁移到EF Core时需要特别注意的点

如果你维护的老项目还在EF6体系下,这会比较自然。EF6的迁移命令名字与EF Core基本一致,但多个重要的API和配置项有变化。主要差异如下:

  • EF6使用config文件里的connectionStrings配置,EF Core则用appsettings.json,且依赖注入方式不同。
  • EF6的MigrationHistory表名为__MigrationHistory,EF Core为__EFMigrationsHistory。
  • EF6迁移生成操作在实体数据模型设计器中也有支持,但EF Core完全基于代码。
  • EF6支持迁移配置类里的Seed方法,EF Core建议用HasData迁移方案。

跨版本迁移时,不要指望原有迁移文件无损平移。比较稳妥的路径是:在老系统中把数据库结构导出,在新EF Core项目中做一次基线迁移,把历史又一次打包进InitialCreate,把旧版本历史彻底封存。这样的代价是丢失了旧迁移的回滚能力,但换来的是切换框架后代码模型完全可控,长期看利大于弊。

5.3 测试环境如何模拟真实迁移流程

最后强调一个很容易被忽视的执行细节:验证迁移的数据库一定要尽量接近生产环境。这句话包含两个维度。

第一是数据量维度。用空库验证迁移,只能证明语法正确;用生产数据的脱敏副本验证,才能暴露出性能和约束问题。尤其是给大表加非空字段、加唯一约束的迁移,这个小而重要的差距常常决定了上线当晚会不会加班。

第二是版本一致性维度。测试库执行的迁移序列必须和生产环境保持一致,不要在一部分环境里用手工SQL调整过结构,却不应用对应迁移记录。这样会导致测试环境和生产环境的版本路径不同,很多“为什么测试没这个问题、上线就有问题”的排查方向其实就藏在数据库上下文里。

6. 写在最后的个人经验

算下来这几年我经手的项目里,至少有七八个是因为迁移使用不当而出现数据库结构事故的,每次复盘几乎都能回到同一个原点:对迁移机制的滥用发生在项目初期,大家初衷都是觉得“代码写完库就建好了”,真正上了生产才开始补课。

如果你正在做一个新项目,我强烈建议从第一次提交代码之前就引入迁移体系,而不是等数据库表稳定后再补。哪怕最终结构的调整会生成很多迁移文件,也好过没有任何历史记录、没有任何回滚路径,只能大家靠微信和口头沟通去对数据库结构的状态。

另外,工作中我还特别习惯在执行迁移前后各拍一张元数据快照。今天之前,如果你让我快速说一下当前最新迁移的版本号,我第一反应就是直接跑一条Update-List命令。这本身就是迁移的额外价值,它是一个团队对数据库认知的共同语言。希望这篇文章能帮你把这个机制用顺,至少别在表结构变更时再靠赌运气了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询