☰
EF Code First双库实战:SQL Server与MySQL迁移差异收敛
2026/10/24 4:19:33 网站建设 项目流程

简介:面向.NET开发者的EF Code First双数据库实践示例,聚焦同一套领域模型在SQL Server与MySQL中的建库与适配,适合具备C#与实体框架基础、希望理解代码优先流程的初中级开发者。压缩包共88个文件、约13.61MB,以C#源码、DLL程序集、config配置、exe工具及NuGet依赖包为主,另含项目文件与说明文本。目录按两套数据库项目并列组织,内容围绕实体类、上下文配置和数据库迁移展开,可对照连接配置观察模型自动生成表结构,并借助依赖版本梳理EF与MySQL提供程序的集成要点,整体结构清晰,便于按模块查阅与横向对照。已有707人学习下载,可用作课程示例、项目原型或代码优先初始化排错参考。

1. 一套实体、两条迁移线:这份双库实例到底解决什么

同一份订单实体,先在 SQL Server 上跑通,把连接串换成 MySQL 再执行一次更新,建表结果会给你一个下马威:金额列从decimal(18,2)掉成decimal(65,30),时间列从datetime2变成datetime,唯一索引的名字和长度判定也全变了。这不是配置写错了,是 EF 代码优先在两个提供程序上本来就长两副样子。这份 EF Code First 实例(SQL Server + MySQL 双库)要解决的就是这件事:一份模型、两套迁移、一台机器上把两种库都跑起来,再对着差异逐条收敛。它适合已经会写DbContext、但一换库就被字符集、精度、索引长度卡住的后端同学,也适合需要给产品留一条可切换存储底座的团队。下面按我拆包复现的顺序讲:模型怎么摆、两个提供程序怎么注册、迁移怎么分家,以及那几个让我返工过的现场。

2. 实体与映射:一份模型怎么同时喂给两个提供程序

2.1 先决定要不要拆成两个 DbContext

拿到实例先别急着Add-Migration,第一件事是看DbContext的继承结构。绝大多数翻车都出在这一步:有人图省事,只写一个ShopDbContext,在OnConfiguring里判断配置项,SQL Server 时UseSqlServer,MySQL 时UseMySql。看起来优雅,跑起来是玄学——EF Core 的模型是按DbContext的运行时类型缓存的,同一个类型第二次进OnModelCreating可能直接被跳过,MySQL 拿到手的是第一次为 SQL Server 构建好的模型,于是datetime2、nvarchar全跑到 MySQL 的建表脚本里。

正确做法是派生两个上下文,共享同一个基类与同一批DbSet,只在OnConfiguring或依赖注入阶段绑定各自的提供程序。这样模型缓存按类型隔离,谁也不污染谁。实例里就是这么切的:

// 共享基类:实体集合与全局约定全放这里 public abstract class ShopDbContextBase : DbContext { protected ShopDbContextBase(DbContextOptions options) : base(options) { } public DbSet<Order> Orders => Set<Order>(); public DbSet<OrderItem> OrderItems => Set<OrderItem>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // 先加载通用配置,再按提供程序做差异化覆盖 modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopDbContextBase).Assembly); ApplyProviderSpecific(modelBuilder); } // 两个子类各自实现,负责补本库特有的类型、索引、并发策略 protected abstract void ApplyProviderSpecific(ModelBuilder modelBuilder); } public sealed class SqlServerShopContext : ShopDbContextBase { public SqlServerShopContext(DbContextOptions<SqlServerShopContext> o) : base(o) { } protected override void ApplyProviderSpecific(ModelBuilder mb) { /* 见 2.3 */ } } public sealed class MySqlShopContext : ShopDbContextBase { public MySqlShopContext(DbContextOptions<MySqlShopContext> o) : base(o) { } protected override void ApplyProviderSpecific(ModelBuilder mb) { /* 见 2.3 */ } }

逻辑上分三层:抽象基类管实体与通用映射,两个子类管提供程序差异,差异部分用抽象方法强制实现。参数上唯一要注意的是子类构造函数必须接收自己类型的DbContextOptions<T>,写成DbContextOptions会让依赖注入容器在注册时找不到对应的 options,启动直接抛InvalidOperationException。这个坑很隐蔽,因为编译期完全不报错。

提示:如果你确实只想保留一个DbContext类型,那就必须实现IModelCacheKeyFactory,把ProviderName拼进缓存键。代价是每次切换都要重新建模型,启动多几十毫秒,而且容易漏。我一般不建议走这条路。

2.2 用 Fluent API 把差异压到最小面

映射配置的取舍原则只有一条:能通用的绝不下放到子类。HasMaxLength、HasPrecision、IsRequired、HasIndex这些在两个提供程序上都有对应实现,放在IEntityTypeConfiguration里最省心。只有HasColumnType、IsRowVersion这类和存储引擎强绑定的,才丢给ApplyProviderSpecific。

// 通用配置:SQL Server 与 MySQL 都能正确翻译 public class OrderConfiguration : IEntityTypeConfiguration<Order> { public void Configure(EntityTypeBuilder<Order> b) { b.ToTable("orders"); b.HasKey(x => x.Id); // 列名统一小写下划线,避开 MySQL 在 Linux 上的大小写敏感问题 b.Property(x => x.OrderNo) .HasColumnName("order_no") .HasMaxLength(32) // 索引前缀长度可控,utf8mb4 下 32*4=128 字节 .IsRequired(); // 金额必须显式给精度,否则 MySQL 侧默认落到 decimal(65,30) b.Property(x => x.TotalAmount) .HasColumnName("total_amount") .HasPrecision(18, 2); b.Property(x => x.CreatedAt).HasColumnName("created_at"); b.Property(x => x.UpdatedAt).HasColumnName("updated_at"); b.HasIndex(x => x.OrderNo) .IsUnique() .HasDatabaseName("ux_orders_order_no"); b.HasMany(x => x.Items) .WithOne() .HasForeignKey(i => i.OrderId) .OnDelete(DeleteBehavior.Cascade); } }

HasPrecision(18, 2)是这份实例里最值钱的一行。SQL Server 的decimal默认就是 (18,2),不写也看不出问题;MySQL 的默认标度是 30,等于你存 9.9 进去,读回来后面挂着 28 个零,做等值比较、做金额汇总、导出对账单全对不上。显式声明之后两边都收敛到 (18,2),跨库校验才有意义。

HasMaxLength(32)同样是有意为之。MySQL 的 InnoDB 在utf8mb4下索引键长上限是 3072 字节,varchar(768)刚好卡线,超过就报Specified key was too long。把业务列宽控制在 191 或 255 以内,是给自己留后悔药。

2.3 主键、并发令牌与值生成策略的统一

自增主键两边都是ValueGeneratedOnAdd,不用管,EF 会自动翻译成 SQL Server 的IDENTITY(1,1)和 MySQL 的AUTO_INCREMENT。真正需要决策的是并发令牌。

SQL Server 玩家习惯用rowversion:数据库自动维护,每次更新自增,零代码。但这玩意儿在 MySQL 上没有等价物——timestamp的ON UPDATE CURRENT_TIMESTAMP语义、精度、以及 EF 的读取方式都不一样。我在一份实例里见过有人硬写IsRowVersion(),结果 MySQL 侧建出来一个row_version二进制列,永远不变,并发检查形同虚设。

比较稳的方案是两库统一用业务时间戳UpdatedAt做并发令牌,在应用层写入时赋值。牺牲一点自动化,换来的是一致行为:

// SQL Server 子类:时间列用 datetime2(3),并发令牌挂 UpdatedAt protected override void ApplyProviderSpecific(ModelBuilder mb) { mb.Entity<Order>(b => { b.Property(x => x.CreatedAt).HasColumnType("datetime2(3)"); b.Property(x => x.UpdatedAt).HasColumnType("datetime2(3)").IsConcurrencyToken(); }); } // MySQL 子类:显式 datetime(6),其余保持一致 protected override void ApplyProviderSpecific(ModelBuilder mb) { mb.Entity<Order>(b => { b.Property(x => x.CreatedAt).HasColumnType("datetime(6)"); b.Property(x => x.UpdatedAt).HasColumnType("datetime(6)").IsConcurrencyToken(); }); }

IsConcurrencyToken会让 EF 在UPDATE的WHERE里带上旧值,受影响行数为 0 时抛DbUpdateConcurrencyException。两边语义一致,测试用例可以共用。至于精度,SQL Server 的datetime2默认 7 位小数,MySQL 的datetime默认 0 位,你写DateTime.Now存进去,MySQL 会把毫秒直接砍掉,随后并发检查因为值不相等而永远失败——所以datetime(6)和datetime2(3)最好显式写死,别依赖默认。

3. 连接与启动装配:把两库切换做成一个配置项

3.1 连接字符串与 ServerVersion 的取舍

配置文件里两条连接串并存,用哪个由Database:Provider决定。这样本地开发可以随时切,不用改代码重新编译。

{ "ConnectionStrings": { "SqlServer": "Server=127.0.0.1,1433;Database=shop_dev;User Id=sa;Password=Your_password123;TrustServerCertificate=True;Encrypt=False", "MySql": "Server=127.0.0.1;Port=3306;Database=shop_dev;User Id=root;Password=Your_password123;CharSet=utf8mb4;AllowUserVariables=True" }, "Database": { "Provider": "MySql" } }

AllowUserVariables=True是 Pomelo 驱动执行某些带变量脚本时的必需项,不加会在跑迁移脚本时抛语法错误。CharSet=utf8mb4建议显式写,虽然新版驱动默认就是它,但老库、老镜像、复制来的容器经常是utf8(三字节),存个 emoji 直接炸。

UseMySql的第二个参数是ServerVersion。示例里常见写法是ServerVersion.AutoDetect(connStr),它会真的去连一次库探版本,启动阶段多一次网络往返;更要命的是离线环境、CI 容器、只跑migrations script不连库的场景会直接抛异常。我的习惯是写死:

var provider = cfg["Database:Provider"] ?? "SqlServer"; var conn = cfg.GetConnectionString(provider) ?? throw new InvalidOperationException($"缺少 {provider} 的连接字符串"); if (provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)) { // 版本号按你实际部署的 MySQL 写,别用 AutoDetect var version = ServerVersion.Parse("8.0.36-mysql"); services.AddDbContext<MySqlShopContext>(o => o.UseMySql(conn, version, my => my.EnableRetryOnFailure(3))); } else { services.AddDbContext<SqlServerShopContext>(o => o.UseSqlServer(conn, s => s.EnableRetryOnFailure(3))); }

EnableRetryOnFailure(3)是执行策略,遇到瞬时连接错误自动重试三次。注意它和手动事务有冲突:开启重试策略后,BeginTransaction包起来的多条语句如果中途失败,EF 无法安全重放,会抛The configured execution strategy does not support user-initiated transactions。要用手动事务,就得套CreateExecutionStrategy().ExecuteAsync(...),这点在第 5 章还会遇到。

3.2 迁移程序集分家与依赖注入注册顺序

迁移文件放哪,决定了后面命令行怎么写。实例的目录结构大致是这样:

目录职责是否含迁移
src/Shop.Domain实体、值对象否
src/Shop.Data基类上下文、通用配置否
src/Shop.Data.SqlServerSQL Server 子类 + 迁移是
src/Shop.Data.MySqlMySQL 子类 + 迁移是
src/Shop.Api启动装配、控制器否

拆项目不是为了好看,是为了让两套迁移快照文件彻底隔离。如果硬塞进同一个程序集,靠--output-dir分目录也能跑,但两个ModelSnapshot混在一起,后面谁改错了列、谁漏了一次迁移,diff 起来非常痛苦。分开之后,每个数据项目只引用自己需要的驱动包,Shop.Api同时引用两个,靠配置决定用哪个。

注册顺序上有个细节:两个AddDbContext注册的是不同泛型类型,不会互相覆盖,但如果你的仓储层用的是ShopDbContextBase抽象基类做注入,容器会解析失败——基类没被注册过。要么注册时补一行services.AddScoped<ShopDbContextBase>(sp => sp.GetRequiredService<MySqlShopContext>()),要么仓储泛型参数直接用具体子类。实例里选的是后者,简单直接。

3.3 dotnet-ef 落地:加迁移、更新库、导脚本

工具装一次就够,注意全局工具和项目本地工具的区别,CI 里建议用本地工具并提交dotnet-tools.json。

# 安装(本地工具方式,锁版本,CI 可复现) dotnet new tool-manifest dotnet tool install dotnet-ef # 为 SQL Server 生成首次迁移 dotnet ef migrations add Init \ --context SqlServerShopContext \ --project src/Shop.Data.SqlServer \ --startup-project src/Shop.Api \ --output-dir Migrations # 为 MySQL 生成首次迁移 dotnet ef migrations add Init \ --context MySqlShopContext \ --project src/Shop.Data.MySql \ --startup-project src/Shop.Api \ --output-dir Migrations # 落到实际库 dotnet ef database update --context SqlServerShopContext --project src/Shop.Data.SqlServer --startup-project src/Shop.Api dotnet ef database update --context MySqlShopContext --project src/Shop.Data.MySql --startup-project src/Shop.Api

几个参数值得说清楚。--context必须显式给,因为Shop.Api里注册了两个上下文,不给会报「发现多个 DbContext」并列出候选。--startup-project指向真正有Program.cs和配置文件的工程,EF 工具会去那里读连接串并构建服务容器。--project指向放迁移的工程,写错会把迁移文件生成到 Api 项目里。

--output-dir在拆项目方案里可以省略,因为每个数据项目只有一套迁移;但如果你的迁移已经生成了,后续再加就千万别改--output-dir,否则 EF 会认为所有旧迁移都丢了,生成一份包含全量建表语句的「新」迁移,跑起来直接table already exists。

4. 迁移与建表:Add-Migration 之后两库到底差在哪

4.1 第一次迁移生成的代码长什么样

Add-Migration出来的是 C# 迁移类,不是 SQL。Up()里面全是migrationBuilder.CreateTable、CreateIndex,字段类型由当前上下文的提供程序注解在模型上决定。同一份实体,两个上下文生成的迁移代码结构一致,细节不同:

// Shop.Data.SqlServer/Migrations/xxx_Init.cs(节选) migrationBuilder.CreateTable( name: "orders", columns: table => new { id = table.Column<long>(type: "bigint", nullable: false) .Annotation("SqlServer:Identity", "1, 1"), order_no = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false), total_amount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), created_at = table.Column<DateTime>(type: "datetime2(3)", nullable: false), updated_at = table.Column<DateTime>(type: "datetime2(3)", nullable: false) }, constraints: table => table.PrimaryKey("PK_orders", x => x.id));

MySQL 那份对应位置的type会变成bigint、varchar(32)、decimal(18,2)、datetime(6),自增注解换成MySql:ValueGenerationStrategy。看type字符串是判断映射有没有生效最快的方式,别去看数据库里建出来什么样——中间还隔着一层 SQL 生成。

一个常被忽略的点:HasPrecision(18,2)如果写在通用配置里,两边都会带上;只写在 MySQL 子类里,SQL Server 侧就退回decimal(18,2)默认值,看起来结果一样,但只要有人改了模型默认约定,两边立刻分叉。

4.2 列类型对照表与必须显式写的字段

下面这张表是复现时对照用的,左两列是两库实际建出来的类型,右列是我建议的写法。凡是右列写了「显式」的,都别靠默认值。

实体属性写法SQL ServerMySQL (Pomelo)建议
string无长度nvarchar(max)longtext一律HasMaxLength
string+HasMaxLength(32)nvarchar(32)varchar(32)通用配置里写
decimal无精度decimal(18,2)decimal(65,30)显式HasPrecision(18,2)
DateTime无HasColumnTypedatetime2datetime(6)显式指定精度
boolbittinyint(1)通用,无需处理
Guiduniqueidentifierchar(36)需要紧凑存储时 MySQL 侧转binary(16)
byte[]varbinary(max)longblob大字段建议拆表
long主键bigint IDENTITYbigint AUTO_INCREMENT通用
rowversionrowversion无对应改用时间戳并发令牌

Guid那行要多说一句。SQL Server 的uniqueidentifier是 16 字节原生类型,MySQL 侧 Pomelo 默认映射成char(36),索引体积直接翻一倍多,几十万行之后写入就明显变慢。要压回去,得在 MySQL 子类里加HasConversion,把Guid转成byte[16],读取时再转回来。这个转换对上层透明,但要注意排序语义会变——char(36)按字符串排,binary(16)按字节排,如果业务里有「按 Id 排序取最新」的逻辑,两边结果不一样。

4.3 导出两套 SQL 做 diff,比肉眼看迁移类靠谱

迁移类读起来累,直接导 SQL 更直观,也方便交给 DBA 审。

# 导出两套建表脚本(不连库,纯离线生成) dotnet ef migrations script \ --context SqlServerShopContext \ --project src/Shop.Data.SqlServer \ --startup-project src/Shop.Api \ --idempotent -o out/sqlserver.sql dotnet ef migrations script \ --context MySqlShopContext \ --project src/Shop.Data.MySql \ --startup-project src/Shop.Api \ --idempotent -o out/mysql.sql

--idempotent生成的是可重复执行的脚本,靠查迁移历史表决定跑不跑。SQL Server 侧这个参数很成熟;MySQL 侧在部分版本上生成的脚本仍会带上START TRANSACTION和条件判断的混合体,我一般会先在一个空库上完整跑一遍确认无误,再拿去生产。

导出来之后做 diff,重点看三处:字符串列的长度、decimal的精度标度、索引的列顺序。这三处一旦两边不一致,后面的业务代码就会开始出现「SQL Server 上没问题,MySQL 上偶尔报错」的经典现象。

5. 避坑与排查:两库行为不一致的五个现场

5.1 切到 MySQL 后建表语句里冒出 datetime2

现象:执行dotnet ef migrations add Init --context MySqlShopContext,生成的迁移里created_at的类型是datetime2(3),dotnet ef database update直接报Unknown column type 'datetime2'。

原因:两个上下文如果共享了同一个DbContextOptionsBuilder实例,或者干脆只注册了一个DbContext类型切换提供程序,EF 的模型缓存会把先构建的那份模型复用给第二个提供程序。SQL Server 的映射注解就这么被带进了 MySQL。

解决:确保每个提供程序对应独立的DbContext子类;已经踩了的,清掉bin/obj重新生成迁移,别手动改迁移文件里的类型字符串——改了这一份,下次Add-Migration又会按错误模型生成新的差异迁移。判断是否中招有个快办法:在OnModelCreating末尾打一行Console.WriteLine(Database.ProviderName),看两个上下文输出的值是否真的不同。

5.2 Linux 上表名大小写与唯一索引的重复判定

现象:开发机 Windows 上一切正常,部署到 Linux 容器后orders表「不存在」,或者两个只差大小写的OrderNo值被判定为重复插入失败。

原因有两层。MySQL 的表名是否大小写敏感取决于lower_case_table_names,Linux 默认 0(敏感),Windows 默认 1(不敏感)。EF 生成的 SQL 里表名如果大小写和实际建表时不一致,敏感模式下就查不到。排序规则则决定了字符串比较,utf8mb4_general_ci是大小写不敏感,utf8mb4_bin是敏感,唯一索引在这两种排序规则下的判重结果完全不同。

解决:表名、列名一律在 Fluent API 里用ToTable("orders")、HasColumnName("order_no")写死成小写下划线,别依赖类名约定;排序规则在建库时就统一指定为utf8mb4_general_ci并在迁移脚本里显式带上,不要让它跟着服务器默认值走。排查时执行SHOW VARIABLES LIKE 'lower_case_table_names'和SELECT @@collation_database,两行就能定性。

5.3 decimal 默认 (65,30) 让金额对不上账

现象:同一笔 199.90 的订单,SQL Server 侧汇总出来是 199.90,MySQL 侧是 199.900000000000000000000000000000,序列化成 JSON 之后前端显示一长串零;或者用Where(x => x.TotalAmount == 199.90m)查询,MySQL 上查不到。

原因:EF 对decimal没有默认精度约定,各提供程序自己定。SQL Server 提供程序默认 (18,2),MySQL 提供程序默认 (65,30)。存进去的时候按列精度截断,比较的时候按参数精度比较,自然对不上。

解决:所有金额、比率、数量字段统一HasPrecision(18, 2)或HasPrecision(18, 4),写在通用配置里。已经上线的库改精度需要走一次AlterColumn迁移,注意 MySQL 侧修改decimal精度会重建表,大表要挑低峰期。

5.4 DateTime 丢毫秒,以及 0000-00-00 读取异常

现象:MySQL 上存的CreatedAt取出来毫秒全是 0,导致基于时间戳的并发检查永远失败;或者从老库迁过来的数据里有0000-00-00 00:00:00,EF 读取时抛Unable to convert MySQL date/time value to System.DateTime。

原因:MySQL 的datetime默认精度是 0,不写datetime(6)就存不进毫秒。0000-00-00是 MySQL 的历史遗留「零日期」,在非严格模式下合法,但 .NET 的DateTime表达不了,驱动转换时直接抛。

解决:时间列显式HasColumnType("datetime(6)");并发令牌用UpdatedAt时,赋值统一走一个IClock抽象,别在业务代码里到处DateTime.Now,避免时区混用。零日期要么清洗成1970-01-01,要么连接串加Convert Zero Datetime=True让驱动返回DateTime.MinValue。后者只是止血,根子还是数据本身不干净。

5.5 MySQL 的 DDL 不在事务里,迁移失败留下半张表

现象:dotnet ef database update在 MySQL 上跑到一半报错,重跑时报Table 'orders' already exists,而__EFMigrationsHistory里没有这条记录。

原因:MySQL 的 DDL 语句是隐式提交的,没有事务回滚一说。EF 把迁移里的所有语句放在一个事务里提交,SQL Server 能整体回滚,MySQL 只能眼睁睁看着前几条建表成功、后面的失败。加上开了EnableRetryOnFailure之后,执行策略无法重放手动事务,报错信息还会变成一堆与根因无关的执行策略异常。

解决:MySQL 侧迁移前先手动备份或用快照库;database update失败后先查SHOW TABLES和__EFMigrationsHistory的实际状态,把残留表手工清掉再重跑,别上来就删迁移。生产环境我一般不用database update,而是导出migrations script交给发布流程逐条执行,出错能定位到具体哪一句。另外,开了重试策略又需要手动事务的地方,套一层context.Database.CreateExecutionStrategy().ExecuteAsync(...),否则事务根本起不来。

6. 把两库行为锁死:契约测试 + 元数据对账

差异逐条收敛完之后,最怕的是「今天对上了,三个月后某人加了个字段又分叉」。所以这套实例最后一步是给它上双保险,一个跑在 CI 里,一个跑在发布前。

第一道是契约测试:同一组仓储断言,参数化跑两遍,两套连接串各一次。关键点在于断言要挑那些容易分叉的行为——金额比较、时间戳精度、唯一索引冲突、并发更新失败。

public class OrderRepositoryContractTests { // 两套连接串从环境变量读,CI 里起两个容器分别灌进去 public static IEnumerable<object[]> Providers => new[] { new object[] { "SqlServer" }, new object[] { "MySql" } }; [Theory] [MemberData(nameof(Providers))] public async Task 金额保留两位且可等值比较(string provider) { await using var ctx = TestContextFactory.Create(provider); // 内部按 provider 建对应上下文 var order = new Order { OrderNo = $"T{Guid.NewGuid():N}"[..20], TotalAmount = 199.90m, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; ctx.Add(order); await ctx.SaveChangesAsync(); var loaded = await ctx.Orders.SingleAsync(x => x.OrderNo == order.OrderNo); Assert.Equal(199.90m, loaded.TotalAmount); // 精度没收敛就会挂 Assert.Equal(199.90m, loaded.TotalAmount * 1.0000m); // 唯一索引冲突必须在两边都抛 DbUpdateException ctx.Add(new Order { OrderNo = order.OrderNo, TotalAmount = 1m, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); await Assert.ThrowsAsync<DbUpdateException>(() => ctx.SaveChangesAsync()); } }

MemberData把 provider 名传进去,TestContextFactory按名字建上下文,其余代码零分支。写成这样有个好处:任何一次迁移引入的差异,都会在 CI 里以「MySQL 挂了、SQL Server 过了」的形式暴露出来,而不是等上线后由业务方告诉你。

第二道是元数据对账,发布前跑一次,用同一段查询把两边的列定义拉出来比对:

-- 两个库都能跑,取交集字段做归一化比较 SELECT table_name, column_name, data_type, COALESCE(character_maximum_length, 0) AS len, COALESCE(numeric_precision, 0) AS p, COALESCE(numeric_scale, 0) AS s, is_nullable FROM information_schema.columns WHERE table_schema = 'shop_dev' ORDER BY table_name, ordinal_position;

SQL Server 的information_schema.columns里character_maximum_length单位是字节(nvarchar要除以 2),MySQL 是字符数,归一化时得按data_type分个类,别直接 diff。我习惯把两边结果导成两张表,只比较table_name + column_name的交集,缺列和多列单独报警——这比逐条看迁移文件快得多,也更容易交给不熟悉 EF 的同事执行。

血泪经验是:这套对账脚本一定要在「加完迁移、还没合主干」的时候跑,而不是发版前夜。我有一次图省事,把 MySQL 侧一个varchar(500)改成了varchar(2000),SQL Server 侧忘了同步,结果唯一索引在 MySQL 上直接超长建不出来,整条流水线卡了两小时。从那以后我每次Add-Migration之后都强制走一遍导脚本 + 对账 + 契约测试三件套,宁可多花十分钟,也不想在发布窗口里找后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询