首页 / 资讯中心 / 文章详情

EF Code First 一套模型双库:SQL Server 与 MySQL 迁移实践

EF Code First 一套模型双库:SQL Server 与 MySQL 迁移实践 ★ FEATURED ARTICLE
简介这份示例面向具备C#与Entity Framework基础、希望掌握Code First开发方式的.NET开发者以及需要快速搭建数据访问原型的技术人员。它围绕同一套领域模型分别连接SQL Server与MySQL演示从数据类定义到数据库自动生成的完整过程可用于学习多数据库提供程序切换、实体映射、连接字符串配置、数据库初始化与迁移等常见场景。压缩包共88个文件约13.61MB以cs源码、dll程序集、config配置、xml文档及exe工具为主并包含解决方案、项目工程与包管理相关文件目录按示例工程组织便于直接打开解决方案对照运行。目前已有707人学习下载可作为小型实战样本帮助读者理解EF Code First在两种数据库下的建库思路、依赖组织与配置差异并在此基础上扩展数据访问层、补充自己的实体与迁移逻辑或用于课程演示与项目原型验证。1. 一套模型代码两个数据库EF Code First 到底省去了哪部分工作同一套业务代码生产环境跑 SQL Server另一套部署或者本地开发环境跑 MySQL这种组合在实际项目里并不少见。麻烦在于模型一变两边的手写 DDL 就要各改一遍改漏一个字段、少一个索引上线时才暴露排查成本极高。EF Code First 把顺序倒过来先写 C# 实体和 DbContext建表语句由模型推导字段增删改由迁移Migration记录成带版本号的 C# 文件SQL Server 和 MySQL 各自执行自己那份。这套做法适合已经用 .NET 做业务、且数据库不止一种的团队。它不能让你完全不懂 SQL但能把「模型定义」收成唯一事实来源让两种数据库的差异变成一处可配置、可测试的映射规则。下面按可复现的路径走一遍。2. 从空目录到第一个迁移Code First 最小可跑骨架先不考虑双库的问题把一条最短链路跑通写实体、写 DbContext、生成迁移、建出表。这一章的所有产物后面都要复用所以工程结构一开始就按「模型与映射集中、提供程序分离」来切。2.1 工程分层与三个包的引用边界常见做法是拆三个项目ShopDemo.Domain实体、ShopDemo.InfrastructureDbContext、映射配置、迁移、ShopDemo.Api启动与配置。Domain 不引用任何 EF 包保证实体是普通 POCOInfrastructure 引用 EF Core 和两个提供程序Api 只引用 Infrastructure。# 建解决方案与三个项目 dotnet new sln -n ShopDemo dotnet new classlib -n ShopDemo.Domain dotnet new classlib -n ShopDemo.Infrastructure dotnet new webapi -n ShopDemo.Api dotnet sln add ShopDemo.Domain ShopDemo.Infrastructure ShopDemo.Api dotnet add ShopDemo.Infrastructure reference ShopDemo.Domain dotnet add ShopDemo.Api reference ShopDemo.Infrastructure # Infrastructure 里装 EF Core 核心、设计时工具、两个提供程序 cd ShopDemo.Infrastructure dotnet add package Microsoft.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Pomelo.EntityFrameworkCore.MySql逻辑说明Design包只在生成迁移时需要它提供dotnet ef命令用的设计时服务真正参与运行时的是两个提供程序包。参数上唯一要留意的是版本对齐——EF Core 主版本、SQL Server 提供程序、MySQL 提供程序三者的主版本号必须一致否则运行时会在OnConfiguring阶段抛出类型加载异常。我的习惯是先确定 EF Core 主版本再去找同主版本的 MySQL 提供程序而不是反过来。提示MySQL 提供程序不要用旧版MySql.Data.EntityFrameworkCore它的维护节奏和 EF Core 主版本脱节类型映射也有历史遗留问题。社区维护的 Pomelo 系列在类型映射和迁移支持上更贴近 EF Core 的更新节奏。另外把dotnet-ef工具装上全局或本地都行dotnet tool install --global dotnet-ef dotnet ef --version # 能打印版本号即安装成功2.2 实体与 DbContext 的最小写法实体保持干净不放任何 EF 特性映射全部放 Fluent API。这样同一份实体可以被两个 DbContext 复用也不会因为某个提供程序专属的特性污染模型层。// ShopDemo.Domain/Order.cs public class Order { public int Id { get; set; } public string OrderNo { get; set; } string.Empty; public decimal TotalAmount { get; set; } public DateTime CreatedAt { get; set; } public bool IsPaid { get; set; } public ListOrderItem Items { get; set; } new(); } // ShopDemo.Domain/OrderItem.cs public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public string SkuCode { get; set; } string.Empty; public int Quantity { get; set; } public decimal UnitPrice { get; set; } public Order? Order { get; set; } }映射配置单独成类实现IEntityTypeConfigurationT这是后面双库共用的关键资产// ShopDemo.Infrastructure/Mapping/OrderMap.cs public class OrderMap : IEntityTypeConfigurationOrder { public void Configure(EntityTypeBuilderOrder b) { b.ToTable(orders); b.HasKey(x x.Id); b.Property(x x.OrderNo).HasMaxLength(32).IsRequired(); // 精度必须显式写不能靠约定 b.Property(x x.TotalAmount).HasPrecision(18, 2); b.Property(x x.CreatedAt).IsRequired(); b.HasIndex(x x.OrderNo).IsUnique(); } }两个 DbContext 各自独立只负责「用哪个提供程序」模型配置调用同一个静态入口// ShopDemo.Infrastructure/ModelConfigurator.cs public static class ModelConfigurator { public static void Configure(ModelBuilder mb) { // 扫描当前程序集下所有 IEntityTypeConfiguration mb.ApplyConfigurationsFromAssembly(typeof(ModelConfigurator).Assembly); } } // ShopDemo.Infrastructure/SqlServerAppDbContext.cs public class SqlServerAppDbContext : DbContext { public SqlServerAppDbContext(DbContextOptionsSqlServerAppDbContext options) : base(options) { } public DbSetOrder Orders SetOrder(); public DbSetOrderItem OrderItems SetOrderItem(); protected override void OnModelCreating(ModelBuilder mb) ModelConfigurator.Configure(mb); } // ShopDemo.Infrastructure/MySqlAppDbContext.cs public class MySqlAppDbContext : DbContext { public MySqlAppDbContext(DbContextOptionsMySqlAppDbContext options) : base(options) { } public DbSetOrder Orders SetOrder(); public DbSetOrderItem OrderItems SetOrderItem(); protected override void OnModelCreating(ModelBuilder mb) ModelConfigurator.Configure(mb); }这里有一个刻意的设计选择不用继承基类 DbContext 的方式复用模型。继承看起来更省代码但迁移快照ModelSnapshot是按 DbContext 类型生成的父子类共享同一个OnModelCreating时两个 Snapshot 会写出几乎相同的内容却带不同的提供程序注解后期排查哪份是准的会很费神。两个平级 DbContext 一个共享的配置入口结构上多写十几行维护成本低得多。2.3 用 dotnet ef 生成迁移并写入数据库dotnet ef需要在设计时构造 DbContext。如果 DbContext 的构造函数只有DbContextOptionsT工具不会自动去读Program.cs的依赖注入容器直接跑命令会报「Unable to create an object of type」。所以给每个 DbContext 配一个设计时工厂把连接串写死在里面即可这属于工具专用代码不进运行时。// ShopDemo.Infrastructure/DesignTime/SqlServerDesignTimeFactory.cs public class SqlServerDesignTimeFactory : IDesignTimeDbContextFactorySqlServerAppDbContext { public SqlServerAppDbContext CreateDbContext(string[] args) { var options new DbContextOptionsBuilderSqlServerAppDbContext() // 设计时只用于生成迁移连接串不对也不会真的连库 .UseSqlServer(Serverlocalhost;DatabaseShopDemo;User Idapp_user;Passwordapp_pwd;TrustServerCertificateTrue) .Options; return new SqlServerAppDbContext(options); } }MySQL 侧同理只是把UseSqlServer换成UseMySql。注意 MySQL 的UseMySql需要显式给服务端版本用ServerVersion.AutoDetect(connectionString)让它连一次库自动探测避免手写版本号写错。接着在 Infrastructure 目录下执行迁移命令# 为 SQL Server 生成第一份迁移 dotnet ef migrations add InitSqlServer \ --context SqlServerAppDbContext \ --output-dir Migrations/SqlServer \ --namespace ShopDemo.Infrastructure.Migrations.SqlServer # 套用到数据库 dotnet ef database update --context SqlServerAppDbContext参数含义--context指定针对哪个 DbContext 生成双 DbContext 项目里这个参数是必须的不写会报「More than one DbContext was found」--output-dir决定迁移文件落盘位置--namespace决定生成文件的命名空间不指定会按目录名推导目录带斜杠时容易生成奇怪的名字显式写更稳。命令跑完检查三件事Migrations/SqlServer下有xxxx_InitSqlServer.cs和SqlServerAppDbContextModelSnapshot.csUp()方法里有CreateTable调用ModelSnapshot里的列类型是nvarchar(32)、decimal(18,2)而不是泛型类型。第三点最关键它说明提供程序正确介入了类型映射。3. 一份模型喂两个库提供程序注册与连接串的切换方式骨架跑通后第二个库的接入不是再写一遍模型而是让同一套映射在另一个提供程序下产出正确的 DDL。这一步的核心矛盾是模型层必须共享提供程序层必须隔离。3.1 两个提供程序的差异从哪几个地方体现SQL Server 和 MySQL 在 EF Core 的映射链路上有四个明确的分叉点理解这些分叉点比记住具体的类型对照表更有用分叉点SQL Server 表现MySQL 表现影响字符串类型nvarchar(n)/nvarchar(max)varchar(n)/longtext长度语义不同max对应关系不直观自增主键IDENTITY(1,1)AUTO_INCREMENT迁移 DDL 完全不同不能共用一份脚本日期时间精度datetime2(7)默认datetime(6)需要显式默认精度丢失导致并发校验失败布尔类型bittinyint(1)用原生 SQL 读写时结果格式不一致GUIDuniqueidentifierchar(36)或binary(16)索引体积和排序行为差别大这些差异不会在编译期暴露只会在迁移生成的 DDL 里出现或者在跨库跑同一段 LINQ 时出现行为不一致。所以「一份模型两个库」在工程上必须做到映射规则集中写、提供程序分别注册、迁移分别生成。3.2 用配置开关在启动时选择 SQL Server 或 MySQL运行时只需要注册其中一个 DbContext具体哪一个由配置项决定。这种写法在本地开发MySQL与预发/生产SQL Server之间切换时特别顺手改一行appsettings就够。// appsettings.json { Database: { Provider: MySql, SqlServer: Serverlocalhost;DatabaseShopDemo;User Idapp_user;Passwordapp_pwd;TrustServerCertificateTrue, MySql: Serverlocalhost;Port3306;Databaseshop_demo;Userapp_user;Passwordapp_pwd;CharSetutf8mb4 } }// ShopDemo.Api/Program.cs 片段 var db builder.Configuration.GetSection(Database); var provider db[Provider]; if (string.Equals(provider, SqlServer, StringComparison.OrdinalIgnoreCase)) { builder.Services.AddDbContextSqlServerAppDbContext(opt opt.UseSqlServer(db[SqlServer])); } else { var conn db[MySql]!; builder.Services.AddDbContextMySqlAppDbContext(opt // AutoDetect 会连一次库读版本号启动阶段多一次往返 opt.UseMySql(conn, ServerVersion.AutoDetect(conn))); }逻辑说明两个分支注册的是不同的 DbContext 类型所以业务层不能直接依赖具体类型要么在更上一层再按同样的开关决定注入哪个要么抽一个仓储接口。参数上要留意ServerVersion.AutoDetect会在启动时建立一次连接如果数据库在容器里还没起来应用启动就会抛异常——我一般在这种场景下改成显式new MySqlServerVersion(...)把探测动作去掉用配置项承担版本声明的责任。注意连接串里的CharSetutf8mb4不是可选项。少了它中文和四字节字符比如部分符号写进去可能变成问号而且是静默的读出来才发现。3.3 双库迁移分目录两套 Migrations 怎么生成和套用迁移文件不能共用。原因很直接迁移的Up()方法里写的是migrationBuilder.CreateTable(...)这种提供程序无关的调用但ModelSnapshot里记录的是带提供程序注解的最终列类型。两个提供程序共用一份 Snapshot第二次生成迁移时 EF 会认为「模型没变」生成一个空的迁移文件于是表结构永远同步不过去。正确的做法是两个 DbContext 各自一套目录# SQL Server 侧 dotnet ef migrations add InitSqlServer \ --context SqlServerAppDbContext \ --output-dir Migrations/SqlServer \ --namespace ShopDemo.Infrastructure.Migrations.SqlServer # MySQL 侧 dotnet ef migrations add InitMySql \ --context MySqlAppDbContext \ --output-dir Migrations/MySql \ --namespace ShopDemo.Infrastructure.Migrations.MySql # 分别落库 dotnet ef database update --context SqlServerAppDbContext dotnet ef database update --context MySqlAppDbContext执行完可以用dotnet ef migrations list --context MySqlAppDbContext确认迁移被正确归属输出里列出的迁移名应该只有 MySQL 那一套。这一步是验收双库配置是否成功的低成本手段比直接连库看表快得多。后续每次改模型两条migrations add命令都要跑。这是双库方案绕不开的成本模型改一次迁移生成两次。好在这两步可以写进构建脚本实际手工负担不大。4. 让模型说两种方言类型映射、精度与命名策略前两章解决「能跑」这一章解决「跑得对」。双库场景下最容易出的问题不是编译错误而是两边表结构看起来都建成了但字段长度、精度、大小写规则不一致导致同一段业务代码在一个库上正常、在另一个库上出错。4.1 用 Fluent API 把列类型固定下来EF Core 的约定Convention在两个提供程序下的推导结果并不完全一致。比如string无长度限制时SQL Server 推导为nvarchar(max)MySQL 推导为longtext两者在索引场景下行为完全不同——nvarchar(max)不能直接建索引longtext建索引必须指定前缀长度。所以凡是会参与查询、排序、索引的字符串列长度一律显式声明public class OrderMap : IEntityTypeConfigurationOrder { public void Configure(EntityTypeBuilderOrder b) { b.ToTable(orders); b.HasKey(x x.Id); // 长度显式写避免两个提供程序推导出不同结果 b.Property(x x.OrderNo).HasMaxLength(32).IsRequired(); b.Property(x x.Remark).HasMaxLength(500); // 金额精度必须写否则 MySQL 默认 decimal(65,30) b.Property(x x.TotalAmount).HasPrecision(18, 2); // 时间精度显式声明到微秒两边都对齐 b.Property(x x.CreatedAt).HasPrecision(6).IsRequired(); b.Property(x x.IsPaid).IsRequired(); b.HasIndex(x x.OrderNo).IsUnique(); } }参数说明HasPrecision(18, 2)表示总共 18 位有效数字、小数 2 位两个提供程序都会生成decimal(18,2)这一条不写MySQL 侧的默认精度会让金额字段变成超长小数前端展示和报表汇总都会出偏差。HasPrecision(6)用在DateTime上生成的是datetime(6)MySQL与datetime2(6)SQL Server精度统一到微秒避免毫秒截断带来的「明明保存了却查不到」的怪现象。有些类型没法靠 Fluent API 抹平只能按提供程序分情况写。这种情况我一般用HasColumnType在各自的 DbContext 里覆盖而不是在共享的IEntityTypeConfiguration里加if// 在 MySqlAppDbContext.OnModelCreating 里追加的 MySQL 专属覆盖 protected override void OnModelCreating(ModelBuilder mb) { ModelConfigurator.Configure(mb); // MySQL 没有 rowversion用时间戳列模拟并发令牌 mb.EntityOrder() .PropertyDateTime(RowVersion) .HasColumnName(row_version) .HasPrecision(6) .IsConcurrencyToken(); }这样共享映射里只放通用规则提供程序专属的补丁各自留在自己的 DbContext改一处不会影响另一处。4.2 decimal 精度、字符串长度、索引的显式声明索引是双库方案里第二容易出问题的地方因为两个库对索引键长度的限制不同。MySQL 在utf8mb4字符集下一个字符占 4 字节varchar(255)的完整索引就是 1020 字节。MySQL 8 默认行格式下索引前缀上限是 3072 字节够用但如果目标环境是更早的版本或者用了紧凑行格式767 字节的上限会让建表直接失败报Specified key was too long。我的处理方式是把所有会被索引的字符串列长度控制在 191 以内或者在配置里明确用前缀索引// 方案一控制长度最省事 b.Property(x x.OrderNo).HasMaxLength(64).IsRequired(); b.HasIndex(x x.OrderNo).IsUnique(); // 方案二确实需要长列就显式指定前缀长度 b.HasIndex(x x.Remark) .HasDatabaseName(ix_orders_remark) .HasFilter(null);方案二在 MySQL 侧要配合HasColumnType(varchar(500))加上提供程序扩展方法指定前缀写法依提供程序版本而异所以除非业务真的需要对长文本做索引否则我更倾向方案一把长文本的检索需求交给独立的搜索方案。复合索引也要注意顺序。SQL Server 的查询优化器对复合索引的前导列选择比较宽容MySQL 在部分版本上对前导列缺失的查询直接不走索引。索引定义按「等值条件列 → 范围条件列」的顺序排列两个库都能吃到。4.3 MySQL 大小写与排序规则对查询的隐性影响这一条是双库方案里最不容易提前发现的问题。SQL Server 的默认排序规则通常不区分大小写WHERE OrderNo abc能匹配到ABCMySQL 在 Linux 上表名的比较默认区分大小写取决于lower_case_table_names参数而列值的比较又取决于列的排序规则collation。结果就是同一段 LINQ 代码在 SQL Server 上查得到在 MySQL 上查不到或者反过来唯一索引在一边能拦住重复在另一边拦不住。处理办法有三层按优先级来第一层表名和列名在映射里全部用小写且显式声明不依赖约定推导b.ToTable(orders); b.Property(x x.OrderNo).HasColumnName(order_no);第二层在 MySQL 建库时就固定排序规则别用服务端默认值CREATE DATABASE shop_demo DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;_ci结尾表示大小写不敏感和 SQL Server 默认行为对齐如果业务确实需要区分大小写就用utf8mb4_bin但要清楚这意味着两个库的行为从此不一致业务代码里的比较逻辑必须自己兜住。第三层涉及唯一性约束的列订单号、编码、邮箱在应用层统一做规范化——存之前ToUpperInvariant()或者ToLowerInvariant()别把正确性押在数据库的排序规则上。这一条是血泪经验曾经有个场景唯一索引在 SQL Server 上生效在 MySQL 上因为排序规则不同而形同虚设重复数据写进去两周后才在报表里被发现。5. 双库 Code First 的翻车现场5 个排查清单下面这五条都是我在实际项目里真踩过的按出现频率排序。每条按现象、原因、解决三段写方便对照排查。5.1 迁移文件全堆在一个目录第二次生成出来是空的现象模型加了新字段跑migrations add之后生成的迁移文件里Up()方法是空的只有注释没有AddColumn。数据库更新执行成功但表结构没变。原因两个 DbContext 共用同一个--output-dir导致只有一个ModelSnapshot文件存活后生成的覆盖了先生成的。EF 在生成新迁移时会拿当前模型和 Snapshot 比对而 Snapshot 已经被另一个提供程序的模型覆盖比对结果自然是「无变化」。解决两个 DbContext 的--output-dir和--namespace必须完全隔离且migrations list --context X的输出里只能出现该 Context 自己的迁移。这一步建议写进 CI 校验解析migrations list的输出出现交叉归属就 fail。5.2 dotnet ef 报「Unable to create an object of type」现象迁移命令执行失败错误信息说无法创建 DbContext 实例并列出几种设计时模式。原因DbContext 的构造函数只有DbContextOptionsT参数dotnet ef不会去读应用的依赖注入容器它需要无参构造或者IDesignTimeDbContextFactoryT实现两者都没有就构造不出来。解决给每个 DbContext 配一个设计时工厂。工厂里连接串写死也无所谓生成迁移不需要真的连库database update除外。另一个常见变体是工厂写在了 Api 项目里但命令在 Infrastructure 目录下执行工具找不到——统一把工厂放在 DbContext 所在项目命令也在该项目目录下执行最省心。5.3 MySQL 里中文变问号或者插入带表情的文本报错现象本地开发用 MySQL写入的中文读出来是一串问号或者用户昵称里带一个四字节符号插入直接抛异常。原因连接串没写CharSetutf8mb4或者库/表的字符集还是latin1、utf8MySQL 的utf8是三个字节的存不下四字节字符。这两处只要有一处没对上就会静默截断或者报错。解决三层同时确认——连接串加CharSetutf8mb4建库语句显式指定CHARACTER SET utf8mb4迁移生成的建表语句里检查列定义是否带CHARSETutf8mb4。第三层最容易被忽略因为库是对的、连接串是对的但某张表是从旧环境导过来的字符集还是老的。5.4 并发令牌在 SQL Server 上正常在 MySQL 上完全不生效现象用并发令牌做乐观锁SQL Server 侧两个用户同时改同一条记录后提交的会抛并发异常MySQL 侧同样操作后提交的直接覆盖没有任何异常。原因并发令牌用的是rowversion类型这是 SQL Server 特有的。MySQL 没有对应类型提供程序在生成 DDL 时会忽略或者降级处理导致更新语句的WHERE里根本没带版本列条件。解决不要用rowversion改用DateTime或者自增的long列做并发令牌并在共享映射里统一声明。前面 4.1 节给的写法就是这种方案加一个row_version列IsConcurrencyToken()每次保存时在SaveChanges的重写里手动更新它。多写几行代码换来两个库行为一致是划算的。5.5 建表时报索引键过长SQL Server 那边却毫无问题现象MySQL 侧执行database update失败错误提示索引键长度超限具体字节数超过限制同一份迁移在 SQL Server 上执行成功。原因utf8mb4下每字符 4 字节varchar(255)的完整索引占 1020 字节超过目标 MySQL 实例的索引前缀上限。SQL Server 对非聚集索引键的限制是 1700 字节1020 字节完全在范围内所以那边不会报错。解决要么把被索引的字符串列长度压到 191 以内要么显式声明前缀索引长度。前者更省事而且 191 这个数字在多数业务场景下够用订单号 32、编码 64、邮箱 128 都在范围内。真正需要长文本检索的单独走全文索引或者外部检索方案不要硬塞进 B 树索引。6. 验证与收尾技巧把「一套模型两个库」做成可重复动作双库方案上线前必须验证的一件事是两边的 schema 是不是真的由同一份模型推导出来的。光看迁移文件不够得看实际生成的 DDL。dotnet ef migrations script可以从迁移生成完整 SQL加上--idempotent参数会生成可重复执行的版本内部用IF NOT EXISTS判断这个脚本可以直接拿去比对# 分别导出两套 DDL dotnet ef migrations script --idempotent \ --context SqlServerAppDbContext \ -o artifacts/schema-sqlserver.sql dotnet ef migrations script --idempotent \ --context MySqlAppDbContext \ -o artifacts/schema-mysql.sql拿到两份脚本后重点核对四类信息表名与列名是否完全一致大小写也算、decimal列的精度、日期时间列的精度、唯一索引的列组合。这四项对上了业务层面的查询行为基本就一致了对不上就去改共享映射不要在下游用代码兜。第二个技巧是自动迁移的取舍。很多人习惯在启动时调Database.Migrate()图省事。我的做法是开发环境用生产环境不用。开发环境图快启动即迁移省掉手工执行生产环境改成「发布流水线里执行migrations script生成的脚本」好处是脚本可以被审阅、可以被回滚、出问题时能看到具体执行了哪几条 DDL。自动迁移在生产上出过一次事故之后我就再没在任何生产环境开过这个开关——迁移执行到一半失败事务边界不清晰回滚脚本还得现写那个过程相当难受。最后一个习惯每次改模型两条migrations add命令连在一起跑跑完立刻用migrations list双向确认归属再提交。这个小动作花不到一分钟能挡掉后面 90% 的「迁移生成了但没生效」类问题。双库方案本身不复杂复杂的是两边状态失步之后的重建成本前期把校验做成肌肉记忆比事后补救划算得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站