一、先聊聊生产环境里的种子数据到底是怎么回事
很多开发者第一次接触“种子数据”这个概念,是在用 Entity Framework Core 做本地开发的时候。那时候我们写几句 HasData,或者写个 Seed 方法,往数据库里塞几条测试账号、几行配置项,感觉特别简单。可一旦这套东西要上生产环境,事情就完全不一样了。生产环境里的数据库不是一张白纸,里面可能有几百万条真实订单、几十万用户,还有各种我们根本不敢动的关联数据。如果我们在程序启动时直接跑一个“无脑插入”的种子逻辑,轻则插入重复数据导致唯一索引报错,重则把整张表锁住,拖垮线上服务。更可怕的是,如果种子逻辑里写了 EnsureDeleted(),哪怕只是不小心,生产数据库就会瞬间清空,那可是真正的“删库跑路”事故。
所以,生产环境下的初始数据导入,绝对不是一个简单的“数据写入”问题,而是一个关于“如何安全、可控、可回滚地变更线上数据”的工程问题。这篇文章会用最贴近生活的大白话,把 Entity Framework Core 中种子数据的各种坑和应对方法讲清楚,而且会给出完整的、可以直接参考的代码示例。
二、先搞明白:EF Core 自带的种子功能和它的局限性
2.1 自带 HasData 是个什么玩意儿
EF Core 从 2.1 版本开始支持在 OnModelCreating 里用 HasData 来配置种子数据。它的好处是,当你执行 migrations 命令时,EF Core 会自动生成对应的 Insert 或 Update 语句,并且这些语句会被记入迁移历史。也就是说,种子数据的变更会跟着数据库迁移走,看起来非常“正规”。
// 技术栈:Entity Framework Core 6.0 + C# 10
// 这个示例展示了如何使用 HasData 配置种子数据
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class AppDbContext : DbContext
{
public DbSet<Country> Countries { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 配置实体的一些属性约束
modelBuilder.Entity<Country>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Code).HasMaxLength(2).IsRequired();
entity.HasIndex(c => c.Code).IsUnique();
});
// 使用 HasData 配置种子数据
modelBuilder.Entity<Country>().HasData(
new Country { Id = 1, Code = "CN", Name = "中国" },
new Country { Id = 2, Code = "US", Name = "美国" },
new Country { Id = 3, Code = "JP", Name = "日本" }
);
}
}
public class Country
{
public int Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
2.2 自带的 HasData 在生产环境有什么毛病
首先,HasData 要求种子数据必须有一个固定的主键值。这个主键值一旦在生产环境里被占用,比如某条业务数据的 Id 恰好也是 1,那么插入就会冲突。其次,HasData 生成的迁移脚本是静态的,它不会去判断“这条数据是否已经存在”,只会按照迁移顺序去执行 INSERT 或 UPDATE。如果生产数据库里有手动修改过的数据,或者程序版本升级时有人改了种子数据,那么迁移可能直接报错,或者把已经修改过的数据给覆盖掉。
再说一个更实际的问题:HasData 不适合大批量数据导入。如果你有几百上千条基础配置数据,写进 OnModelCreating 里会让代码变得极其臃肿,而且每次迁移都要对比整个数据集的差异,效率很低。更重要的是,HasData 不会产生任何日志,一旦出问题,你根本不知道是哪条数据导致的失败。
三、生产环境初始数据导入的正确姿势:自己控制迁移流程
既然自带的 HasData 不够灵活,那我们就不要在模型配置里搞种子数据。正确的思路是:把数据导入逻辑写成独立的、可重复执行的、带事务保护的代码,然后在迁移过程中或者程序启动时手动调用。下面详细说两种最常用的方案。
3.1 方案一:在迁移文件里写 Seed 逻辑
当你执行 dotnet ef migrations add AddSeedData 之后,会生成一个带有 Up 和 Down 方法的迁移类。我们可以把种子数据的写入逻辑放在 Up 方法里,并且用 Sql 语句来控制插入条件。这种方法的好处是:迁移是版本化的,团队成员每次更新都会执行一次,而且可以配合 Down 方法实现回滚。
// 技术栈:Entity Framework Core 6.0 + C# 10
// 迁移文件示例:向 Countries 表插入种子数据,使用条件判断避免重复插入
using Microsoft.EntityFrameworkCore.Migrations;
public partial class AddSeedData : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// 使用 SQL 语句插入,条件为“不存在才插入”,保证幂等性
migrationBuilder.Sql(@"
-- 如果表里没有 Code 为 'CN' 的记录,才执行插入
IF NOT EXISTS (SELECT 1 FROM ""Countries"" WHERE ""Code"" = 'CN')
BEGIN
INSERT INTO ""Countries"" (""Id"", ""Code"", ""Name"") VALUES (1, 'CN', '中国');
END
IF NOT EXISTS (SELECT 1 FROM ""Countries"" WHERE ""Code"" = 'US')
BEGIN
INSERT INTO ""Countries"" (""Id"", ""Code"", ""Name"") VALUES (2, 'US', '美国');
END
IF NOT EXISTS (SELECT 1 FROM ""Countries"" WHERE ""Code"" = 'JP')
BEGIN
INSERT INTO ""Countries"" (""Id"", ""Code"", ""Name"") VALUES (3, 'JP', '日本');
END
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// 回滚时删除这些种子数据,但要注意:如果生产环境已经引用这些数据,删除会报外键错误
// 所以这里我们要谨慎处理,可以只删除没有被引用的记录
migrationBuilder.Sql(@"
DELETE FROM ""Countries"" WHERE ""Code"" IN ('CN', 'US', 'JP')
AND NOT EXISTS (SELECT 1 FROM ""SomethingElse"" WHERE ""CountryCode"" = ""Countries"".""Code"");
");
}
}
这种写法的好处是逻辑清晰,但缺点也很明显:如果种子数据非常多,迁移文件会变得特别长。而且写 SQL 的字符串拼接容易出错,特别是涉及到特殊字符的时候。那么我们就可以换成方案二。
3.2 方案二:在程序启动时使用自定义的种子服务
我们写一个独立的种子服务类,然后在 Program.cs 里启动时调用它。这个方法最大的优点是灵活性高,可以使用完整的 C# 代码来读取 JSON 文件、调用外部接口、做数据校验和转换。而且我们可以把种子服务设计成幂等的,可以安全地重复执行。
// 技术栈:ASP.NET Core 6.0 + Entity Framework Core 6.0 + C# 10
// 自定义种子服务示例
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading.Tasks;
public interface IDataSeeder
{
Task SeedAsync();
}
public class CountrySeeder : IDataSeeder
{
private readonly AppDbContext _context;
private readonly ILogger<CountrySeeder> _logger;
public CountrySeeder(AppDbContext context, ILogger<CountrySeeder> logger)
{
_context = context;
_logger = logger;
}
public async Task SeedAsync()
{
// 使用事务,保证要么全部成功,要么全部回滚
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 1. 检查数据是否存在,如果存在则跳过或更新
var existingCN = await _context.Countries
.FirstOrDefaultAsync(c => c.Code == "CN");
if (existingCN == null)
{
_logger.LogInformation("正在插入国家:中国");
_context.Countries.Add(new Country { Code = "CN", Name = "中国" });
}
else
{
// 如果已经存在,但名称不一致,可以更新为最新值
if (existingCN.Name != "中国")
{
existingCN.Name = "中国";
_logger.LogInformation("正在更新国家:中国");
}
}
// 2. 同样的逻辑处理其他数据
var existingUS = await _context.Countries.FirstOrDefaultAsync(c => c.Code == "US");
if (existingUS == null)
{
_logger.LogInformation("正在插入国家:美国");
_context.Countries.Add(new Country { Code = "US", Name = "美国" });
}
var existingJP = await _context.Countries.FirstOrDefaultAsync(c => c.Code == "JP");
if (existingJP == null)
{
_logger.LogInformation("正在插入国家:日本");
_context.Countries.Add(new Country { Code = "JP", Name = "日本" });
}
// 3. 保存所有更改
await _context.SaveChangesAsync();
// 4. 提交事务
await transaction.CommitAsync();
_logger.LogInformation("国家种子数据同步完成");
}
catch (Exception ex)
{
// 出错了,回滚事务,并记录日志
await transaction.RollbackAsync();
_logger.LogError(ex, "国家种子数据同步失败");
throw; // 让上层调用者知道本次启动失败了
}
}
}
然后在 Program.cs 里调用这个服务:
// 技术栈:ASP.NET Core 6.0 + C# 10
// 在程序启动时执行种子数据导入
var builder = WebApplication.CreateBuilder(args);
// 注册 DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// 注册种子服务
builder.Services.AddScoped<CountrySeeder>();
var app = builder.Build();
// 在应用启动时调用种子服务
using (var scope = app.Services.CreateScope())
{
var seeder = scope.ServiceProvider.GetRequiredService<CountrySeeder>();
try
{
await seeder.SeedAsync();
}
catch (Exception ex)
{
// 这里可以决定是继续启动还是终止启动
// 对于关键的基础数据,建议终止启动,避免后续请求用到错误数据
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogCritical(ex, "关键种子数据导入失败,应用已终止启动");
return;
}
}
app.MapControllers();
app.Run();
这种方案非常推荐,因为它把业务逻辑和数据校验都放进 C# 代码里,我们写起来舒服,调试也方便。而且我们可以对这个种子服务做单元测试、集成测试,完全可以像正常业务代码一样对待。
3.3 细说幂等性:为什么“可重复执行”这么重要
所谓幂等性,就是说同一个操作执行一次和执行一百次,结果是一样的。生产环境里经常会出现这种情况:迁移执行到一半网络断了,或者服务器重启了,种子代码又被执行了一次。如果种子代码不是幂等的,就会出现重复数据。上面的示例已经展示了如何通过“先查后插”来保证幂等性。但还有一种更稳妥的方式,就是利用数据库的唯一索引来兜底。
// 技术栈:Entity Framework Core 6.0 + C# 10
// 使用唯一索引 + 捕获异常 来保证种子数据不重复
public async Task SeedCountryWithUniqueIndexAsync()
{
// 假设我们已经给 Code 字段加了唯一索引
var newCountry = new Country { Code = "FR", Name = "法国" };
_context.Countries.Add(newCountry);
try
{
await _context.SaveChangesAsync();
_logger.LogInformation("国家 {Code} 插入成功", newCountry.Code);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("unique") == true)
{
// 如果唯一索引冲突,说明数据已经存在,直接忽略
_logger.LogWarning("国家 {Code} 已存在,跳过插入", newCountry.Code);
// 把失败的实体从追踪中移除,避免干扰后续操作
_context.Entry(newCountry).State = EntityState.Detached;
}
}
这个方法的思路是:让数据库去保证唯一性,我们的代码只管尝试插入,如果插入失败并且原因是唯一键冲突,那么就当作“已经存在”处理。比“先查后插”要少一次查询,而且在并发环境下更安全。不过缺点是要依赖数据库特定错误信息,比如 SQLite 和 SQL Server 的错误文本不一样,跨数据库时有点麻烦。
四、大型数据导入:分批处理才是王道
如果你的种子数据有几千条甚至几万条,直接在一个 SaveChanges 里插入,很可能把数据库连接池占满,甚至导致锁升级,影响线上业务。正确的做法是分批提交。
// 技术栈:Entity Framework Core 6.0 + C# 10
// 批量插入种子数据示例
public async Task SeedManyCountriesAsync(IEnumerable<Country> countries)
{
// 设置每次提交的数量
const int batchSize = 100;
var batchCount = 0;
// 获取要插入的列表,并清洗数据
var distinctCountries = countries
.GroupBy(c => c.Code) // 去掉重复的 Code
.Select(g => g.First())
.ToList();
_logger.LogInformation("需要导入的国家数量:{Count}", distinctCountries.Count);
foreach (var batch in distinctCountries.Chunk(batchSize))
{
var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 对于每一批数据,逐条判断是否存在,避免重复
foreach (var country in batch)
{
bool exists = await _context.Countries
.AnyAsync(c => c.Code == country.Code);
if (!exists)
{
_context.Countries.Add(country);
}
}
// 保存这一批
await _context.SaveChangesAsync();
await transaction.CommitAsync();
batchCount++;
_logger.LogInformation("已提交第 {BatchNumber} 批,共 {BatchSize} 条", batchCount, batchSize);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "第 {BatchNumber} 批导入失败,回滚该批次", batchCount + 1);
throw;
}
}
_logger.LogInformation("所有国家种子数据导入完成");
}
这里使用了 C# 的 Chunk 方法,它可以把一个大的集合切成多个小集合。每一批都单独开一个事务,这样即使某一批失败了,也不会影响已经提交的前几批。当然了,如果真的需要“全有或全无”,那最好还是用一个事务包住所有批次,但那样会拉长事务时间,锁的粒度也会变大。生产环境里通常建议“分批+独立事务”,并且配合重试机制,而不是一次性大事务。
五、处理数据变更:是更新还是跳过?使用“版本号”概念
种子数据并不是一成不变的。比如国家名称从“中国”变更为“中华人民共和国”,或者某个配置项的默认值变了。这时候我们需要决定:是更新生产环境的数据,还是保留用户可能修改过的值?
一个常见的做法是给种子数据表加一个“种子版本号”或者“数据标识”列。比如我们知道这条数据是种子提供的,那么我们可以用 SeedVersion 来标记。如果当前数据库里的 SeedVersion 比代码里的版本旧,就强制覆盖;否则就跳过。
// 技术栈:Entity Framework Core 6.0 + C# 10
// 使用种子版本控制数据变更
public class ConfigItem
{
public int Id { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public string Description { get; set; }
public int SeedVersion { get; set; } // 标记这是哪个版本的种子数据
}
public class ConfigSeeder
{
private readonly AppDbContext _context;
private readonly ILogger<ConfigSeeder> _logger;
public ConfigSeeder(AppDbContext context, ILogger<ConfigSeeder> logger)
{
_context = context;
_logger = logger;
}
public async Task SeedConfigsAsync()
{
// 当前代码的种子版本号,每次修改种子数据时手动增加
const int currentVersion = 3;
// 这是一组种子配置项
var configs = new[]
{
new ConfigItem { Key = "SiteName", Value = "我的电商网站", Description = "站点名称", SeedVersion = 3 },
new ConfigItem { Key = "SupportEmail", Value = "support@example.com", Description = "客服邮箱", SeedVersion = 2 },
new ConfigItem { Key = "PageSize", Value = "20", Description = "默认分页大小", SeedVersion = 1 }
};
foreach (var config in configs)
{
var existing = await _context.ConfigItems
.FirstOrDefaultAsync(c => c.Key == config.Key);
if (existing == null)
{
// 不存在,直接插入
_context.ConfigItems.Add(config);
_logger.LogInformation("新增配置项 {Key},种子版本 {Version}", config.Key, config.SeedVersion);
}
else
{
// 如果现有记录的种子版本小于当前版本,则更新
if (existing.SeedVersion < config.SeedVersion)
{
existing.Value = config.Value;
existing.Description = config.Description;
existing.SeedVersion = config.SeedVersion;
_logger.LogInformation("更新配置项 {Key} 到版本 {Version}", config.Key, config.SeedVersion);
}
else
{
// 版本相同或更高,说明用户可能手动改过,或者已经更新过,跳过
_logger.LogInformation("配置项 {Key} 版本为 {Version},不需要更新", config.Key, existing.SeedVersion);
}
}
}
await _context.SaveChangesAsync();
}
}
这个方法的精髓在于:种子数据不是“永远覆盖”,而是“按版本升级”。用户在生产环境手动修改过的配置项,只要种子版本号没变,就不会被覆盖。这样既保证了基础数据的演进,又不会粗暴干预线上数据。
六、事务和回滚的艺术:如何避免“一半成功,一半失败”
在种子数据导入过程中,最容易出现的问题就是中途报错,导致一部分数据插入成功,另外一部分失败。如果没有事务保护,数据库里就会残留半截数据。使用事务是必须的,但事务本身也有开销,所以我们要合理控制事务的范围。
下面是一个更稳健的种子数据导入模式,它把“校验”和“写入”分离开来:
// 技术栈:Entity Framework Core 6.0 + C# 10
// 先校验,后写入,使用事务保护整体一致性
public async Task SeedWithValidationAsync()
{
// 第一步:收集所有要写入的数据
var countriesToAdd = new List<Country>();
var countriesToUpdate = new List<Country>();
// 假设我们从外部数据源读取国家列表
var externalCountries = await GetExternalCountriesAsync();
foreach (var external in externalCountries)
{
var existing = await _context.Countries.AsNoTracking()
.FirstOrDefaultAsync(c => c.Code == external.Code);
if (existing == null)
{
// 不存在,加入新增列表
countriesToAdd.Add(new Country
{
Code = external.Code,
Name = external.Name
});
}
else if (existing.Name != external.Name)
{
// 存在但内容不同,加入更新列表
existing.Name = external.Name;
countriesToUpdate.Add(existing);
}
}
// 如果没有任何变更,直接返回,避免开启无谓的事务
if (countriesToAdd.Count == 0 && countriesToUpdate.Count == 0)
{
_logger.LogInformation("没有需要变更的数据");
return;
}
// 第二步:开启事务,执行写入
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
await _context.BulkInsertAsync(countriesToAdd); // 这里假设使用 EFCore.BulkExtensions
await _context.BulkUpdateAsync(countriesToUpdate); // 如果有批量更新库
// 如果你不想引入第三方库,也可以用 AddRange 和 UpdateRange
// await _context.Countries.AddRangeAsync(countriesToAdd);
// _context.Countries.UpdateRange(countriesToUpdate);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "种子数据写入失败,事务已回滚");
throw;
}
}
private Task<List<ExternalCountry>> GetExternalCountriesAsync()
{
// 模拟从外部 API 或 JSON 文件读取数据
// 这里可以替换成 HttpClient 调用,也可以读取嵌入资源
return Task.FromResult(new List<ExternalCountry>
{
new ExternalCountry { Code = "CN", Name = "中华人民共和国" },
new ExternalCountry { Code = "US", Name = "美利坚合众国" }
});
}
public class ExternalCountry
{
public string Code { get; set; }
public string Name { get; set; }
}
注意,上面用了 BulkInsert 和 BulkUpdate,这是第三方库 EFCore.BulkExtensions 提供的方法,适合大批量操作。如果不想引入额外库,直接使用 EF Core 自带的 AddRange 和 UpdateRange 也完全可以,只是大数据量下性能会差一些。生产环境里,如果单次种子数据超过一千条,建议考虑使用批量库。
七、异常情况分类处理:什么是可忽略的,什么是致命的
种子数据导入时,我们会遇到各种异常。有些异常我们可以吞掉,比如唯一键冲突(说明数据已存在);有些异常则必须终止程序,比如数据库连接字符串错误、表结构不存在。所以在编写种子逻辑时,要对异常进行分类。
我们可以定义一种自定义异常来表示“种子数据冲突”:
// 技术栈:C# 10
// 自定义种子数据异常类型
public class SeedDataException : Exception
{
public string Code { get; }
public string Message { get; }
public SeedDataException(string code, string message, Exception innerException = null)
: base(message, innerException)
{
Code = code;
Message = message;
}
}
然后在种子服务里这样使用:
// 技术栈:Entity Framework Core 6.0 + C# 10
// 异常分类处理示例
public async Task SeedWithExceptionHandlingAsync()
{
try
{
// 执行种子逻辑
await DoSeedAsync();
}
catch (DbUpdateException ex)
{
// 数据库更新异常,判断是不是唯一索引冲突
if (IsUniqueViolation(ex))
{
// 如果是唯一冲突,说明是重复数据,我们可以记录下来并继续
_logger.LogWarning("检测到唯一索引冲突,跳过重复种子数据:{Error}", ex.Message);
return;
}
// 其他数据库更新错误,比如字段长度超限、非空约束违反等,这些可能是数据质量问题
throw new SeedDataException("DB_UPDATE_ERROR", "种子数据更新失败,请检查数据格式", ex);
}
catch (DbUpdateConcurrencyException ex)
{
// 并发冲突,可能是另一个进程也在写种子数据
_logger.LogError("并发冲突,可能有多个种子数据导入进程同时运行", ex);
throw new SeedDataException("CONCURRENCY_CONFLICT", "并发冲突,请稍后重试", ex);
}
catch (Exception ex)
{
// 其他未知异常,直接包装抛出
throw new SeedDataException("UNKNOWN_ERROR", "种子数据导入发生未知异常", ex);
}
}
private bool IsUniqueViolation(DbUpdateException ex)
{
// 这里根据你的数据库提供者判断,比如 SQL Server 的错误码 2601 和 2627
// 如果是 Npgsql,可以检查 SqlState 等于 "23505"
// 为了简化示例,只判断消息内容包含 "unique" 或 "duplicate"
var message = ex.InnerException?.Message ?? "";
return message.Contains("unique", StringComparison.OrdinalIgnoreCase)
|| message.Contains("duplicate", StringComparison.OrdinalIgnoreCase);
}
通过这种分类,我们就能明白什么时候可以“睁一只眼闭一只眼”,什么时候必须“拉响警报”。生产环境的日志监控也一样,我们不能把所有异常都一视同仁地发警报,否则半夜会因为一条重复数据把开发者叫起来,那是很折磨人的。
八、实战:一个完整的可嵌入项目中的种子数据模块
下面我们把前面讲到的所有技巧组合起来,设计一个完整的种子数据模块。这个模块放在一个独立的文件夹 Seeders 里,包含三个文件:IDataSeeder.cs、CountrySeeder.cs、ConfigSeeder.cs,然后有一个统一的 DataSeedRunner 来调度它们。
// 技术栈:ASP.NET Core 6.0 + Entity Framework Core 6.0 + C# 10
// 文件:IDataSeeder.cs
// 描述:种子数据接口
namespace MyApp.Seeders
{
public interface IDataSeeder
{
string Name { get; }
Task SeedAsync(CancellationToken cancellationToken = default);
}
}
// 技术栈:ASP.NET Core 6.0 + Entity Framework Core 6.0 + C# 10
// 文件:CountrySeeder.cs
// 描述:国家表种子数据实现
using Microsoft.EntityFrameworkCore;
namespace MyApp.Seeders
{
public class CountrySeeder : IDataSeeder
{
private readonly AppDbContext _context;
private readonly ILogger<CountrySeeder> _logger;
public string Name => "国家基础数据";
public CountrySeeder(AppDbContext context, ILogger<CountrySeeder> logger)
{
_context = context;
_logger = logger;
}
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
var seedCountries = new[]
{
new { Code = "CN", Name = "中国" },
new { Code = "US", Name = "美国" },
new { Code = "JP", Name = "日本" },
new { Code = "DE", Name = "德国" }
};
foreach (var item in seedCountries)
{
var exists = await _context.Countries
.AsNoTracking()
.AnyAsync(c => c.Code == item.Code, cancellationToken);
if (!exists)
{
_logger.LogInformation("新增国家:{Code} - {Name}", item.Code, item.Name);
await _context.Countries.AddAsync(new Country
{
Code = item.Code,
Name = item.Name
}, cancellationToken);
}
else
{
_logger.LogDebug("国家已存在:{Code},跳过", item.Code);
}
}
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
_logger.LogInformation("国家种子数据完成");
}
catch (Exception ex)
{
await transaction.RollbackAsync(cancellationToken);
_logger.LogError(ex, "国家种子数据失败");
throw;
}
}
}
}
// 技术栈:ASP.NET Core 6.0 + Entity Framework Core 6.0 + C# 10
// 文件:DataSeedRunner.cs
// 描述:统一调度所有种子数据执行,并支持按名字选择
namespace MyApp.Seeders
{
public class DataSeedRunner
{
private readonly IEnumerable<IDataSeeder> _seeders;
private readonly ILogger<DataSeedRunner> _logger;
public DataSeedRunner(
IEnumerable<IDataSeeder> seeders,
ILogger<DataSeedRunner> logger)
{
_seeders = seeders;
_logger = logger;
}
public async Task RunAllAsync(CancellationToken cancellationToken = default)
{
foreach (var seeder in _seeders)
{
_logger.LogInformation("开始执行种子数据:{Name}", seeder.Name);
try
{
await seeder.SeedAsync(cancellationToken);
}
catch (Exception ex)
{
// 可以记录后继续执行其他的,也可以停止。
// 对于基础数据,建议停止,防止半初始化状态
_logger.LogCritical(ex, "种子数据 {Name} 执行失败,终止后续任务", seeder.Name);
throw;
}
}
}
public async Task RunSingleAsync(string seederName, CancellationToken cancellationToken = default)
{
var seeder = _seeders.FirstOrDefault(s => s.Name == seederName);
if (seeder == null)
{
_logger.LogWarning("找不到名为 {Name} 的种子数据", seederName);
return;
}
await seeder.SeedAsync(cancellationToken);
}
}
}
在 Program.cs 里注册这些服务的时候,要注意注册多个 IDataSeeder 的顺序:
// 技术栈:ASP.NET Core 6.0 + C# 10
// Program.cs 注册种子服务
builder.Services.AddScoped<IDataSeeder, CountrySeeder>();
builder.Services.AddScoped<IDataSeeder, ConfigSeeder>();
builder.Services.AddScoped<DataSeedRunner>();
这样 IEnumerable<IDataSeeder> 会按照注册顺序注入到 DataSeedRunner 中,执行时就会按顺序执行。如果你还需要在执行前判断数据库是否已经初始化,可以加一个检查:
// 技术栈:ASP.NET Core 6.0 + C# 10
// 安全的种子数据执行入口
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 如果数据库尚未创建,先执行 Migrate
// 注意:生产环境通常不会用 EnsureCreated,因为会绕过迁移记录
await dbContext.Database.MigrateAsync();
var runner = scope.ServiceProvider.GetRequiredService<DataSeedRunner>();
await runner.RunAllAsync();
这里要强调一下,MigrateAsync 会把所有未应用的迁移全部执行,其中包括我们之前在迁移文件里写的 SQL 种子逻辑。如果我们同时在迁移里写了种子数据,又在启动时用 DataSeedRunner 写了种子数据,就可能会有重复。所以我们要养成一个好习惯:要么把种子数据放在迁移里,要么放在启动时,不要两边都放。我个人的经验是,如果种子数据与业务表结构强相关(例如新建了一张配置表,默认值必须随表一起存在),那就放迁移里;如果种子数据是业务初始化数据(例如导入历史国家列表),那就放启动时。
九、关联技术:如何通过配置文件控制种子数据的开关
有时候我们并不希望每次程序启动都执行种子数据,比如在多实例部署的情况下,每个实例启动都会跑一遍,虽然我们的逻辑是幂等的,但总归会有不必要的开销,而且有并发风险。我们可以使用配置文件来控制种子数据的启用状态。
// 技术栈:ASP.NET Core 6.0 + C# 10
// 使用 IConfiguration 控制种子数据开关
public class DataSeedRunner
{
private readonly IEnumerable<IDataSeeder> _seeders;
private readonly ILogger<DataSeedRunner> _logger;
private readonly IConfiguration _configuration;
public DataSeedRunner(
IEnumerable<IDataSeeder> seeders,
ILogger<DataSeedRunner> logger,
IConfiguration configuration)
{
_seeders = seeders;
_logger = logger;
_configuration = configuration;
}
public async Task RunAllAsync(CancellationToken cancellationToken = default)
{
// 读取配置,如果为 false 则跳过
var isSeedEnabled = _configuration.GetValue<bool>("SeedData:Enabled");
if (!isSeedEnabled)
{
_logger.LogInformation("种子数据功能已禁用,跳过执行");
return;
}
// 还可以读白名单,只执行某些 seeder
var allowedSeeders = _configuration
.GetSection("SeedData:AllowedSeeders")
.Get<List<string>>();
foreach (var seeder in _seeders)
{
if (allowedSeeders != null && allowedSeeders.Count > 0)
{
if (!allowedSeeders.Contains(seeder.Name))
{
_logger.LogInformation("种子数据 {Name} 不在白名单中,跳过", seeder.Name);
continue;
}
}
_logger.LogInformation("开始执行种子数据:{Name}", seeder.Name);
await seeder.SeedAsync(cancellationToken);
}
}
}
对应的 appsettings.json 示例:
{
"SeedData": {
"Enabled": true,
"AllowedSeeders": [
"国家基础数据",
"配置项数据"
]
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Mydb;User Id=sa;Password=mypassword;"
}
}
这样,我们在本地开发时可以把 Enabled 设为 true,方便调试;在正式环境,如果要手动执行种子数据,可以通过环境变量覆盖,比如:
# 使用环境变量覆盖配置
export SeedData__Enabled=true
dotnet MyApp.dll
这种方式非常灵活,也很安全。
十、生产环境中的注意事项和最佳实践
10.1 备份先行,永远不要相信你的种子代码
哪怕你写了再多次测试,在生产环境执行种子数据之前,一定要做数据库备份,或者至少做一个针对相关表的备份。数据库备份是一项基本操作,有些人觉得“我的种子逻辑不是幂等的吗?不会影响的”。但是别忘了,种子逻辑可能会因为一个 SQL 语法错误,导致 EF Core 自动生成一个 DELETE 语句,把整张表删了。所以,无论你对自己的代码多有信心,备份是最后的保险丝。
10.2 避免在业务高峰期执行
种子数据导入会占用数据库的 IO、锁和事务资源。如果在一个千万级订单的数据库中跑批量插入,可能会拖慢其他业务。所以,尽量安排在业务低峰期,最好通过运维平台触发,而不是依赖应用启动时自动执行。如果真的需要在启动时执行,可以加上一个“维护模式”标识,让负载均衡器先把流量切走。
10.3 日志要详尽,但不要泄露敏感信息
种子数据通常不涉及敏感信息,但也要注意别把连接字符串、密码等打到日志里。记录插入的条数、耗时、异常堆栈等信息即可。还有,中文日志在处理编码时要注意,确保控制台或日志系统支持 UTF-8。
10.4 考虑多实例并发执行的问题
如果你有多个应用实例同时启动,它们会同时执行种子数据。虽然我们用了“先查后插”或唯一索引兜底,但在极端情况下还是可能产生死锁。推荐的解决方案是使用分布式锁,比如基于数据库的锁表,或者使用 Redis 锁。如果你不想引入分布式锁,也可以在种子服务启动时使用 MUTEX 或 SemaphoreSlim 限制本机的并发,但跨机器的并发还是需要外部锁。
下面是一个基于数据库锁的简单示例:
// 技术栈:Entity Framework Core 6.0 + C# 10
// 利用数据库行锁实现互斥的种子数据执行
public async Task SeedWithLockAsync()
{
// 我们假设有一张 SeedExecutionLock 表,里面只有一行记录
// 通过开启事务并更新这一行,获得互斥锁
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 试着更新锁记录,如果这行被其他事务锁住,这里会等待或超时
var lockRecord = await _context.SeedExecutionLocks
.FromSqlRaw("SELECT * FROM SeedExecutionLocks WITH (UPDLOCK, HOLDLOCK) WHERE Id = 1")
.FirstOrDefaultAsync();
if (lockRecord == null)
{
// 不存在则创建锁记录
_context.SeedExecutionLocks.Add(new SeedExecutionLock { Id = 1, LastExecutionTime = DateTime.UtcNow });
await _context.SaveChangesAsync();
}
else
{
// 更新锁记录的时间
lockRecord.LastExecutionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
// 拿到锁后,执行种子逻辑
// ...
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "获取种子数据执行锁失败");
throw;
}
}
这种锁方式虽然简单,但要注意事务的超时时间以及数据库本身的锁等待时间。更简单的方式其实是一个进程内锁加上数据库唯一约束,但那样无法跨实例,所以生产环境还是建议用 Redis 分布式锁。
十一、举一个更复杂的真实案例:初始化一个包含外键关联的数据
假设我们要给一个新上线的商城系统初始化“分类”和“商品”数据。分类和商品存在外键关系。如果我们按顺序插入分类,然后插入商品,那么在插入商品的时候需要知道分类的 Id。但如果 Id 是数据库自动生成的,我们怎么在内存中关联它们?
我们可以先在插入分类后,保存到数据库,EF Core 会自动给这些实体生成 Id。然后,我们再用这些 Id 去创建商品对象。
// 技术栈:Entity Framework Core 6.0 + C# 10
// 初始化含外键的种子数据
public async Task SeedCategoriesAndProductsAsync()
{
// 先插入分类
var foodCategory = new Category { Name = "食品" };
var drinkCategory = new Category { Name = "饮料" };
_context.Categories.AddRange(foodCategory, drinkCategory);
await _context.SaveChangesAsync(); // 到这里 FoodCategory.Id 已经生成
// 现在可以引用分类的 Id 来创建商品
var products = new List<Product>
{
new Product { Name = "苹果", CategoryId = foodCategory.Id, Price = 5.5m },
new Product { Name = "矿泉水", CategoryId = drinkCategory.Id, Price = 2.0m },
new Product { Name = "薯片", CategoryId = foodCategory.Id, Price = 8.0m }
};
_context.Products.AddRange(products);
await _context.SaveChangesAsync();
}
如果不想分两次保存,也可以在同一个事务里,使用导航属性来建立关系:
// 技术栈:Entity Framework Core 6.0 + C# 10
// 使用导航属性关联种子数据
var food = new Category { Name = "食品" };
var drink = new Category { Name = "饮料" };
var products = new List<Product>
{
new Product { Name = "苹果", Price = 5.5m, Category = food },
new Product { Name = "矿泉水", Price = 2.0m, Category = drink },
new Product { Name = "薯片", Price = 8.0m, Category = food }
};
_context.Products.AddRange(products);
await _context.SaveChangesAsync();
EF Core 通过导航属性会自动建立外键关系,这样做更简洁,也减少了显式分配 Id 的麻烦。但有一点要注意,如果种子数据来自外部文件,而外部文件里已经指定了分类的 Id,那么使用导航属性反而容易导致冲突。所以要根据数据源来决定。
十二、关联技术:如何使用 JSON 文件作为种子数据源
很多公司喜欢把种子数据放在 JSON 文件里,这样非开发人员也能维护。EF Core 本身没有提供直接读取 JSON 的种子支持,但我们可以自己写一个简单的读取器。
// 技术栈:JSON
// 示例文件:countries.json
[
{
"code": "CN",
"name": "中国"
},
{
"code": "US",
"name": "美国"
},
{
"code": "JP",
"name": "日本"
}
]
然后在 C# 里:
// 技术栈:ASP.NET Core 6.0 + C# 10
// 从 JSON 文件读取种子数据
using System.Text.Json;
public class JsonCountrySeeder : IDataSeeder
{
private readonly AppDbContext _context;
private readonly ILogger<JsonCountrySeeder> _logger;
private readonly IWebHostEnvironment _env;
public string Name => "JSON 国家数据";
public JsonCountrySeeder(
AppDbContext context,
ILogger<JsonCountrySeeder> logger,
IWebHostEnvironment env)
{
_context = context;
_logger = logger;
_env = env;
}
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
// 读取 JSON 文件,路径可以放在配置里,这里直接写在 wwwroot/data 下
var filePath = Path.Combine(_env.WebRootPath, "data", "countries.json");
if (!File.Exists(filePath))
{
_logger.LogWarning("找不到种子数据文件:{Path}", filePath);
return;
}
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
var countries = JsonSerializer.Deserialize<List<CountryDto>>(json);
if (countries == null || countries.Count == 0)
{
_logger.LogInformation("JSON 文件中没有国家数据");
return;
}
foreach (var item in countries)
{
bool exists = await _context.Countries
.AnyAsync(c => c.Code == item.Code, cancellationToken);
if (!exists)
{
_context.Countries.Add(new Country
{
Code = item.Code,
Name = item.Name
});
}
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("从 JSON 导入了 {Count} 个国家", countries.Count);
}
private class CountryDto
{
public string Code { get; set; }
public string Name { get; set; }
}
}
这样做的好处是:种子数据从代码中抽离出来,业务人员可以修改 JSON 而不用改代码。但也要注意,JSON 文件会被打包到发布目录,如果运行时需要修改它,可能会导致权限问题。而且在容器环境下,修改只读文件层是不被允许的,所以可以把文件路径配置到外部挂载卷中。
十三、谈谈性能优化:如何让几万条数据快速导入
当你需要导入几万条配置数据时,EF Core 的 SaveChanges 效率会非常低,因为每条数据都要生成一条 INSERT 语句,并且还要通过网络传给数据库。这时候有几个优化办法:
- 使用
SqlBulkCopy(针对 SQL Server)或Npgsql的BinaryImporter。 - 使用第三方库
EFCore.BulkExtensions,它封装了批量插入、批量更新。 - 自己拼接
ExecuteSqlRaw多值插入语句,但要注意参数化,防止 SQL 注入(虽然数据是内部数据,但习惯还是要好)。
这里用 EFCore.BulkExtensions 演示:
// 技术栈:Entity Framework Core 6.0 + EFCore.BulkExtensions 6.x + C# 10
// 使用 BulkInsert 快速导入大量数据
using EFCore.BulkExtensions;
public async Task BulkSeedCountriesAsync(List<Country> countries)
{
// 过滤掉已经存在的数据
var existingCodes = await _context.Countries
.Select(c => c.Code)
.ToListAsync();
var newCountries = countries
.Where(c => !existingCodes.Contains(c.Code))
.DistinctBy(c => c.Code) // 去重
.ToList();
if (newCountries.Count == 0)
{
_logger.LogInformation("没有新国家需要插入");
return;
}
// 使用批量插入,大幅提高性能
await _context.BulkInsertAsync(newCountries);
// 如果需要,还可以做 BulkUpdate
// await _context.BulkUpdateAsync(countriesToUpdate);
}
BulkExtensions 原理就是生成高效的 SQL 命令,并且绕过 EF Core 的变更跟踪器。但注意,它不会自动调用 SaveChanges,也不会触发 EF Core 的拦截器。对于种子数据来说,这些不是大问题。
不过这里要提醒一句:批量插入会绕过 EF Core 的一些校验,比如 MaxLength 属性是不是符合,数据库约束是否满足等。所以在大批量导入之前,最好自己先做一轮内存校验,否则遇到中途错误,回滚也有成本。
十四、如何设计“可观测”的种子数据系统
生产环境里,我们不能像本地开发一样,跑到控制台看输出。所以种子数据执行过程中,必须留下足够的痕迹。通常我们会这样做:
- 在执行开始和执行结束时,各打一条带耗时统计的日志。
- 每插入成功一批数据,累计计数。
- 如果失败,不仅记录异常,还要记录当前处理到了哪一条,方便定位。
- 把种子数据执行结果写入一个专门的状态表,这样运维可以查询历史上执行了哪些种子数据,什么时候执行的。
下面是一个简单的执行历史类:
// 技术栈:Entity Framework Core 6.0 + C# 10
// 种子数据执行历史记录
public class SeedDataExecutionHistory
{
public int Id { get; set; }
public string SeederName { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public bool IsSuccess { get; set; }
public string ErrorMessage { get; set; }
public long AffectedRows { get; set; }
}
然后在种子服务里,每个 seeder 执行完都记录一条:
// 技术栈:Entity Framework Core 6.0 + C# 10
// 记录执行历史
public async Task SeedAndRecordAsync()
{
var history = new SeedDataExecutionHistory
{
SeederName = this.Name,
StartedAt = DateTime.UtcNow,
IsSuccess = false
};
_context.SeedDataExecutionHistories.Add(history);
await _context.SaveChangesAsync(); // 先保存一条,用来获取 Id
try
{
// 执行真正的种子逻辑
int affectedRows = await DoSeedCoreAsync();
// 更新历史状态
history.IsSuccess = true;
history.FinishedAt = DateTime.UtcNow;
history.AffectedRows = affectedRows;
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
history.IsSuccess = false;
history.FinishedAt = DateTime.UtcNow;
history.ErrorMessage = ex.ToString();
await _context.SaveChangesAsync();
throw;
}
}
这种方法的好处是,即使种子服务本身出了异常,我们也可以通过历史表看到完整的失败信息。但也要注意,如果 DoSeedCoreAsync 内部开启了事务并且回滚了,那么添加历史记录的 SaveChanges 会不会也被回滚?这里要小心,最好把历史记录写入放在独立的事务中,或者使用不同的 DbContext 实例。否则的话,事务回滚会把历史记录也回滚掉,那就失去追踪的意义了。
为了避免这个问题,我们可以采用“临时表+最终记录”的方式,或者直接把日志写到文件/日志管道里,而不是数据库。这里就不深入展开了,知道这个坑就好。
十五、总结
生产环境中的种子数据导入,与其说是“插入数据”,不如说是一场“受控的变更操作”。我们要极力避免“一股脑把数据塞进数据库”的做法,而是要有规划地考虑幂等性、事务、异常分类、批量性能、可观测性和并发控制。Entity Framework Core 自带的 HasData 只适合非常简单的场景,一旦涉及复杂的初始数据导入,我们更应当自定义种子服务,并使用迁移和启动时执行两种方式相结合的策略。最重要的是,永远不要忘了备份,永远不要小看生产环境的数据敏感度。只有当我们把种子数据当成一项严肃的工程任务去对待,数据库里的初始数据才能真正成为应用安全的基石。
评论
围绕“Entity Framework Core数据种子:生产环境中初始数据导入的异常处理方案”参与讨论