一、先聊聊生产环境里的种子数据到底是怎么回事

很多开发者第一次接触“种子数据”这个概念,是在用 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 语句,并且还要通过网络传给数据库。这时候有几个优化办法:

  1. 使用 SqlBulkCopy(针对 SQL Server)或 Npgsql 的 BinaryImporter。
  2. 使用第三方库 EFCore.BulkExtensions,它封装了批量插入、批量更新。
  3. 自己拼接 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 只适合非常简单的场景,一旦涉及复杂的初始数据导入,我们更应当自定义种子服务,并使用迁移和启动时执行两种方式相结合的策略。最重要的是,永远不要忘了备份,永远不要小看生产环境的数据敏感度。只有当我们把种子数据当成一项严肃的工程任务去对待,数据库里的初始数据才能真正成为应用安全的基石。