很多人把EF Core的迁移机制当成项目里的"常规操作",加个表执行dotnet ef migrations add,部署时跑一遍database update,一切看起来都有章可循。但真实项目跑到一年以上,打开Migrations目录数一数,四十个文件一点也不稀奇。我也是在这种背景下开始研究迁移记录合并的——不是简单的删掉重来,而是在不破坏已有生产库的前提下,把手上一堆碎迁移压缩成干净利落的几条。这个过程里踩了不少坑,其中一些做法和官方文档里的"标准答案"很不一样,说是"非常规操作"一点也不夸张。这篇文章把我两次合并迁移记录的经历完整写出来,包括开发期的常规流程、生产环境上线后的替代方案、合并时容易丢失的内容,以及合并完成后的验证思路,希望对正准备收拾迁移历史的同行有帮助。
1. 迁移记录是怎么堆积起来的,以及合并解决什么问题
1.1 打开Migrations文件夹后的真实名场面
一年前接手某个老项目时,Migrations目录里已经有三十多个文件。明明是一次很小的需求,往往会因为赶工细分出好几条迁移:先AddOrderStatusField,过两天发现类型不对又FixOrderStatusType,再后来为了加个索引又AddOrderStatusIndex。这些迁移单独看都没问题,但堆在一起就成了历史包袱。
更麻烦的是分支合并。你和同事同时改了模型,git合并代码时冲突最多的地方往往就是迁移文件——因为迁移文件带时间戳,且依赖前一个迁移的Snapshot。两个人各自生成一条迁移,合并到主干时,EF Core会认为两条迁移都要执行,顺序还得手动调整。这在小团队里还能忍,项目一大人一多,每次处理迁移冲突都能耗掉半天。
1.2 合并能解决什么,不能解决什么
先说合并带来的实际好处。
- 初始迁移从几十个压缩到一个,部署脚本和代码审查都干净很多。
- 减少PR里因迁移文件时间戳冲突导致的无效改动。
- 新成员从零搭建本地环境时,执行一次迁移就能拿到完整结构,不用像看电影一样追几十个碎迁移。
ModelSnapshot的对比路径变短,后续生成迁移时的diff效率更高,也更不容易误判。
但我要泼一盆冷水:合并迁移记录不解决任何数据问题。如果某个迁移当年做过一次不可逆的数据修复,合并后这条逻辑就找不回来了。也不能根治模型本身的不合理设计,那需要重构代码,不是整理迁移文件能搞定的。
1.3 动手前必须回答的三个问题
很多人上来就问"怎么合并",我一般先反问三个问题。
- 目标数据库有没有真正上线?如果只是本地开发或预生产,路子可以很野;如果线上有用户数据,路子必须很稳。
- 有没有别的分支也在频繁改迁移?有的话先合并完代码再动手,否则你会体会一把"迁移合并完直接冲突爆炸"的滋味。
- 当前代码模型和数据库结构是否完全一致?不一致的情况下做合并,后面查问题会非常痛苦。
这三个问题分别对应了后面章节里两条完全不同的操作路线,先想清楚再动手,别急着删文件。
2. 开发期合并的"标准套路"与四个容易翻车的细节
2.1 先搞懂EF Core在迁移里到底记住了什么
要合并迁移,至少要明白迁移文件里的三件套是什么。一个迁移20250101000000_AddOrders.cs看起来是一个类,实际上背后关联三样东西:
Up和Down方法:定义了应用和回滚该迁移时的具体数据库操作。Designer.cs里的[DbContext]特性和BuildTargetModel方法:保存了应用该迁移后数据库对应的完整模型快照。- 整个
Migrations目录里的{DcContext}ModelSnapshot.cs:相当于所有迁移的"总和基线",下一个新迁移就是拿当前模型和这个快照做差异对比生成的。
这就是关键所在:EF Core生成新迁移从来不是"全量建库",而是"增量对比"。当你删掉所有老迁移再重新add一个InitialCreate时,相当于把快照归零,让EF认为所有表都是新增的,一次性全量生成建库脚本。开发期的合并利用的正是这个机制。
2.2 标准命令序列:回滚、清空、重建
如果确定数据库还没上线,或者可以接受推倒重建,操作路径非常直接。
# 1. 把所有迁移回滚到空状态 dotnet ef database update 0 \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api # 2. 从最新开始逐个删除迁移 dotnet ef migrations remove \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api # 重复执行直到Migrations目录为空 # 3. 生成全新的初始迁移 dotnet ef migrations add InitialCreate \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api # 4. 在空库上应用 dotnet ef database update \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api流程本身不难,但有几个细节值得展开说。
第一,database update 0并不等于删库,它是按顺序执行所有迁移的Down方法,把数据库结构一步步回退到"没有迁移"的状态。如果某个老迁移的Down方法写得不完善,比如删除列后又建了依赖它的索引,执行到一半就可能报错。我在一个老项目里遇到过Down方法里直接drop table的情况,回滚非常酸爽。
第二,migrations remove也得按顺序从新到旧执行,每次移除一条并同步更新Snapshot。理论上你可以直接暴力删掉整个Migrations目录再重新add一个,这样也能用,代价是Snapshot文件整个重新生成,命名空间和引用关系如果原本有多处自定义配置,可能留下隐性坑。
第三,开发库如果保留了老历史,执行完新迁移后可能因为"表已存在"直接报错。最省事的做法是把开发库整个drop掉重新create,反正在开发初期数据库里没什么不能丢的东西。
第四,重新生成的InitialCreate迁移里,所有表结构、索引、外键都是当时模型的全量快照。如果之前有某个迁移手写过migrationBuilder.Sql("...")去创建存储过程或视图,这些内容在这个新迁移里是不存在的。
2.3 为什么"删文件夹重建"这条路只适合开发期
删掉所有迁移文件,重新跑一次migrations add,这个思路在开发环境没有任何问题。但到了生产环境就完全行不通,因为线上数据库结构已经应用了老迁移,且__EFMigrationsHistory表里存着完整的迁移ID列表。如果你把代码里的迁移清空重来,生产库的表结构还在,但EF跑database update时会发现新基线迁移没有记录,于是尝试全量建库,结果每建一张表都报"对象已存在"。数据库结构被搞乱还是小事,更怕的是中间有迁移做过数据回填,一旦重跑结构会造成不可挽回的重复操作。
所以生产环境的合并要换一种思路:不是让EF真的去执行基线迁移,而是让EF以为基线迁移早就执行过了。
3. 生产环境不能回滚:基线迁移加历史表手工衔接
3.1 先想通一个反直觉的点:迁移历史不一定要"连续"
EF Core在运行时判断哪些迁移需要执行,逻辑其实很简单:它加载程序集里所有迁移类,和数据库中__EFMigrationsHistory表里的MigrationId列表做差集,差集里的迁移才会被应用。注意这里没有要求"代码里的迁移必须完整覆盖历史表里的每条记录",也没有要求"中间不能有空档"。
也就是说,你在历史表里手动插入一条新基线迁移的记录,只要这条记录的MigrationId和代码里存在的迁移ID一致,并且数据库结构和该迁移生成的结构一致,整个状态就是合法的。这有点像档案室改台账:账本上补了一条"2025年初已经完成整体初始化",后面查账的人不会质疑中间那几十个历史批次为什么不见了。
这就是生产环境合并的核心原理。掌握它之后,操作路径就变得非常清晰。
3.2 生产环境合并的完整五步
这里给出我验证过的完整操作序列,以SQL Server为例。
第一步,备份生产库。这不是例行公事,而是整个操作的保险绳。建议在备份前先确认当前实例没有被长事务卡住,备份文件单独存到异地。
第二步,在代码中精简迁移记录。最简单的方式是把Migrations目录里所有旧迁移文件删除,保留{DbContext}ModelSnapshot.cs(其实也可以一起删,migrations add会重新生成),然后执行:
dotnet ef migrations add InitialConsolidated \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api这步生成的InitialConsolidated里包含了当前模型的全部建库逻辑。
第三步,验证新基线迁移和生产库结构一致。这个步骤如果跳过,后面会在运行时莫名其妙报"列名无效"之类的错误。
验证方式我推荐两种:
- 找一台空数据库,从零执行新基线迁移,得到一张新库。然后把新库和生产库的Schema做一次对比,可以用数据库工具自带的Schema Compare,没有就手动抽查关键表和索引。
- 用
Scaffold-DbContext把生产库反推成EF模型,再用一个临时上下文对比模型和代码模型的差异。
第四步,在生产库的历史表里插入基线迁移记录:
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20250301120000_InitialConsolidated', '9.0.5');这里两个细节必须说清楚。MigrationId的格式是"时间戳+下划线+迁移名",时间戳部分不能随便编,最好和你代码里生成的InitialConsolidated类名完全一致;ProductVersion要取项目当前实际引用的EF Core版本,可以从csproj文件或dotnet ef --version输出里查。
如果项目自定义了历史表名,比如在UseSqlServer里配置了MigrationsHistoryTable("__EFMigrationsHistory", "schema"),插入语句要跟着改。我一同事就因为没看历史表配置,把SQL插到了默认表里,结果项目在另一个Schema查历史记录,直接抓瞎。
第五步,执行一次空操作验证:
dotnet ef database update \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api \ --connection "生产库连接串"如果一切正确,控制台会提示没有待应用的迁移。此时合并就算完成了。
3.3 旧的历史记录删不删?我的建议是不删
生产库历史表里的几十条旧迁移ID,在新合并后会不会有问题?我在实践中确认不会。原因还是差集判断逻辑:EF只关心程序集里存在的迁移ID是否都已在历史表里出现,多出来的旧ID不会触发任何操作,也不影响下一次增量迁移的生成。
那我为什么不建议顺手清掉旧记录?因为旧ID本身是一份审计痕迹。哪天你需要排查某个数据变更是什么时候发生的,历史表里的记录配合迁移文件(哪怕是Git历史里的老文件)能帮你快速定位。清理它们除了让表好看点,没有任何实际收益,反而增加了出事的窗口。
3.4 它的局限性在哪
这个方案要求"代码中的模型快照"和"生产库实际结构"完全对齐,否则插入历史记录等于自欺欺人,后续每次查询和迁移都会漏风。所以合并前一定要把一致性验证做扎实。
另外,如果生产库有多个,比如租户分库或只读副本,每个库都得执行一遍第五步的验证和第四步的插入。千万不要只改主库就完事,我见过分库项目只插了主库、结果从库和代码对不上导致报表查询崩溃的真实案例。
4. 合并时最容易丢的东西:自定义Sql与种子数据处理
4.1 重新生成的初始迁移并不包含Sql写进去的内容
EF Core自动生成的迁移文件只会反映模型层面的结构变更,比如创建表、加字段、加索引、建外键。但真实项目里,很多人会直接在迁移文件里手写SQL,做以下事情:
- 创建存储过程、视图、函数
- 给已有表做数据回填
- 修改排序规则或者打上触发器和审计字段
- 插入权限数据
这些内容只存在于特定迁移文件的Up方法里,一旦删掉迁移文件,这些SQL就跟着没了。而重新生成的InitialConsolidated是根据模型快照来的,根本不知道这些自定义操作曾经存在过。
我在第二次合并迁移时就踩过这个坑。项目里有个sp_GetDashboardData存储过程,是在第三十七条迁移里用migrationBuilder.Sql建进去的。合并后代码跑了几个星期一切正常,直到有一天报表模块调用存储过程报"对象不存在",系统拉出警报我才反应过来。
4.2 合并前先做一次"自定义内容抢救"
动手清空Migrations目录前,我建议做这么几步:
- 逐个打开迁移文件,搜索
migrationBuilder.Sql(,把找到的SQL全部复制到一个独立文档里,按功能分好类。 - 搜索
HasData(,虽然它是模型层面的种子数据,通常不需要手工处理,但问题在于合并后种子数据是否还保留在初始迁移里,会影响后面的数据一致性。 - 把存储过程、视图这类结构对象整理成一个新的独立迁移,放在
InitialConsolidated之后。比如:
public partial class AddCriticalStoredProcedures : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(""" CREATE OR ALTER PROCEDURE [dbo].[sp_GetDashboardData] AS BEGIN SET NOCOUNT ON; SELECT [Status], COUNT(*) AS [Count] FROM [Orders] GROUP BY [Status]; END """); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.Sql("DROP PROCEDURE [dbo].[sp_GetDashboardData]"); } }注意一个坑:migrationBuilder.Sql()默认不支持GO批处理命令。如果你从SSMS里把脚本直接复制过来,里面带着GO,执行时就可能因为批处理解析错误而失败。需要把GO拆成多条migrationBuilder.Sql调用,或者改写成以分号结尾的多语句脚本。
而对于数据回填类SQL,我的建议是单独创建一个数据修复迁移,语义上明确"这是数据操作,不是结构操作",并且在日志里留好说明。这样即使在非常规合并后,后人追查数据变化也有据可依。
4.3 HasData与生产库的重复执行问题
HasData种子数据在重新生成的基线迁移里会保留。有人会担心:生产库里这些数据已经有了,基线迁移虽然被手工标记为"已应用",但如果以后其他开发环境从零执行基线迁移,会不会又插一遍种子数据?
答案是分环境看。新环境从零执行基线迁移时没有任何数据,种子数据正常插入,没问题。生产环境因为基线迁移已经被标记为已应用,根本不会执行Up方法,所以也不会重复插入。真正需要小心的是那些"开发库不想重建,只插了历史记录"的情况——如果开发库结构是用老迁移一路升上来的,表里已经有了种子数据,此时新基线迁移被标记为已应用,那数据不会重复插入,但如果结构有出入,问题就会在migrations add生成下一个增量时暴露出来。稳妥起见,开发库要么推倒重建,要么自动应用一次全面的对比校验。
5. 多程序集、多DbContext场景下合并操作的注意事项
5.1 迁移分散在类库时,命令别漏参数
很多项目的DbContext并不在Web启动项目里,而是放在独立的类库,比如src/MyApp.Infrastructure。这种情况下执行迁移命令,一定要带上--project和--startup-project,否则EF会默认去启动项目里找DbContext,大概率直接报错或生成到错误的位置。
dotnet ef migrations add InitialConsolidated \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api \ --output-dir Migrations如果连这个都配不对,执行前可以先跑一下dotnet ef migrations list,看EF最终是从哪个程序集加载迁移的。
5.2 多DbContext各管一摊时,合并要分开做
一个比较容易被忽略的场景是:项目里同时有AppDbContext和IdentityDbContext,它们的迁移分别存在不同的目录。合并时必须按DbContext分别操作,因为每个DbContext都有自己的ModelSnapshot文件,混淆之后会生成牛头不对马嘴的迁移。
操作时记得指定上下文名称:
dotnet ef migrations add InitialConsolidated \ --context AppDbContext \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api \ --output-dir Migrations/App dotnet ef migrations add InitialConsolidated \ --context IdentityDbContext \ --project src/MyApp.Infrastructure \ --startup-project src/MyApp.Api \ --output-dir Migrations/Identity如果两个DbContext共用同一个物理库,生产库历史表插入记录时也要一条一条把对应ID插进去,别插串了。
5.3 分支合并的"窗口期"管理
迁移合并最容易被低估的其实是协作流程问题。一旦你删掉老迁移并推送了新基线,其他还停留在老迁移版本上的同事,pull到代码后运行migrations add会看到系统认为不存在任何迁移(因为新基线已应用),但本地数据库历史表又对不上,各种奇异报错接踵而至。
我现在的做法是:合并迁移前确认所有特性分支都先并入主干,并清理掉那些"上游已经被重置"的旧迁移。然后在合并完成后的同一天,通知所有相关同事执行一次数据库重建或按新基线重新同步。过了这个窗口期再去rebase旧分支,代价会小很多。
6. 合并完成后的验证清单,以及什么情况下别合并
6.1 一份可以照着检查的验证清单
合并完成后,我每次都会做下面这套验证,建议原样照抄。
- 用
dotnet ef migrations list检查代码里的迁移链是否符合预期,基线迁移排在最前。 - 在一台空库上执行
dotnet ef database update,检查能否从零构建完整结构,并且无报错。 - 对生产库执行一次
dotnet ef database update,确认输出"没有待应用的迁移"。 - 对比生产库历史表和代码迁移ID集合,确保代码里每条迁移都有对应记录,不多不少。
- 把模型里一个字段类型随手改一下,执行
dotnet ef migrations add VerifyIncremental,打开新生成的迁移文件,确认里面只包含你对字段的那条AlterColumn操作,而不是整个库的全量建表脚本。验证完立刻删除这个测试迁移,保持目录干净。这一步是整个验证里最有效的,能一下子暴露"快照对不上"的问题。 - 让测试环境跑一轮完整的冒烟测试,重点覆盖存储过程、视图和种子数据相关功能。
6.2 我踩过的坑:ProductVersion写错导致运行时异常
有一次我在插入历史记录时图省事,把ProductVersion写成了自己记忆里的一个版本号,结果项目引用的EF Core实际是另一个版本。当时表面上database update提示无待应用迁移,以为没问题了,结果程序启动时EF做迁移装配校验,发现历史记录里的ProductVersion与当前运行时版本不一致,抛了异常。虽然报错信息比较直观,但这种低级失误完全可以通过从csproj中确认版本号的方式避免。
还有一次是在没有备份的情况下直接操作,好在当时是预生产环境,数据库能重建,换成生产环境就是事故了。
6.3 什么时候不要合并,或者说只合并到某个阶段
并不是所有项目都适合把迁移记录合并成一个。如果历史迁移里有大量数据回填脚本、临时修复逻辑、按季度执行的数据归档操作,把这些全部压进一个基线会让后人完全无法判断"某张表的数据为什么和别的不一样"。这种情况下我更推荐"阶段式基线"策略:保留最近几个版本的迁移作为增量,把最早的一段历史合并到一个基线,比如InitialCreateConsolidated_2025,然后后续继续以增量迁移前进。
说到底,合并迁移是一个"代码整理"动作,不是"数据库重构"动作。它最大的价值是减少噪音、降低新人上手成本、缓解分支冲突,而不是帮你修复任何结构或数据上的历史问题。每次动手前先想清楚这三个问题:数据库能不能重建,同事分支有没有同步,结构是否一致。三个问题都有明确答案了,再按生产环境和开发环境选择对应的操作路线,就不会出大乱子。