PAYATHON 2026

Docker Compose 重启后 EF Core 重复创建数据库

排障赵师傅

结论

每次启动应用时执行 MigrateAsync() 是正常的。它只会应用尚未执行的迁移,不会重复创建状态完整的数据库。

当前配置有两个问题:

  1. SQL Server 的 volume 挂载目录有误。容器的默认数据目录是 /var/opt/mssql,不是 /var/lib/mssql/data。
  2. depends_on 默认只保证数据库容器先启动,并不会等待 SQL Server 完成初始化和数据库恢复。如果 API 启动过快,EF Core 可能在数据库尚未可用时执行检查,随后尝试创建数据库,最终收到“数据库已存在”的错误。

本地开发没有出现这个问题,通常是因为启动 API 时 SQL Server 已经运行完毕,没有遇到启动时序冲突。

修正 SQL Server 的 volume

可以持久化整个 SQL Server 工作目录:

volumes:
  - db-data:/var/opt/mssql

也可以只持久化数据目录:

volumes:
  - db-data:/var/opt/mssql/data

持久化整个工作目录会保存系统数据库、用户数据库以及其他 SQL Server 状态,用起来通常更省事。

建议为 volume 指定固定名称,以免切换 Compose 项目名或工作目录后创建新的 volume:

volumes:
  db-data:
    name: room-reservation-sql-data

下面几组命令的行为不同:

docker compose stop
docker compose start

这组命令只会停止并重新启动原容器,不会删除数据。

docker compose down
docker compose up -d

这组命令会重新创建容器,但默认会保留命名 volume。

docker compose down -v

其中,-v 会删除 Compose 管理的 volume,数据库数据也会被删除。

可以用以下命令确认当前实际使用的 volume:

docker volume ls
docker volume inspect room-reservation-sql-data

等待 SQL Server 真正可用

可以在修正后的 Compose 配置中加入健康检查,让 API 等到数据库进入健康状态后再启动:

services:
  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: db-mssql
    environment:
      MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}
      ACCEPT_EULA: "Y"
    ports:
      - "1433:1433"
    networks:
      - db-network
    volumes:
      - db-data:/var/opt/mssql
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q \"SELECT 1\" -b -o /dev/null"
        ]
      interval: 10s
      timeout: 5s
      retries: 20
      start_period: 20s

  app:
    build:
      context: .
      dockerfile: API/Dockerfile
    container_name: meeting-room-reservation
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ASPNETCORE_URLS: http://+:80
      ConnectionStrings__DefaultConnection: Server=db,1433;Database=RoomReservationDB;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=True;
    ports:
      - "5000:80"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - db-network

networks:
  db-network:
    driver: bridge

volumes:
  db-data:
    name: room-reservation-sql-data

不同 SQL Server 镜像中的 sqlcmd 路径可能不同。有些镜像使用:

/opt/mssql-tools/bin/sqlcmd

如果健康检查提示找不到命令,需要进入容器确认实际路径,再修改 healthcheck.test。

给 EF Core 增加瞬时故障重试

健康检查可以处理启动顺序问题,应用本身仍应应对数据库启动、恢复或网络连接期间的短暂故障。

注册 DbContext 时可以开启 SQL Server 重试:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection"),
        sqlOptions =>
        {
            sqlOptions.EnableRetryOnFailure(
                maxRetryCount: 5,
                maxRetryDelay: TimeSpan.FromSeconds(10),
                errorNumbersToAdd: null);
        }));

迁移代码可以继续保留:

try
{
    await using var scope = app.Services.CreateAsyncScope();

    var context = scope.ServiceProvider
        .GetRequiredService<AppDbContext>();

    await context.Database.MigrateAsync();
    await DbSeeder.SeedData(context);
}
catch (Exception ex)
{
    Console.WriteLine(
        $"An error occurred while migrating or seeding the database: {ex}");

    throw;
}

记录完整的 ex,不要只记录 ex.Message。这样可以看到内部异常、SQL Server 错误编号和调用栈,也更容易判断问题来自连接时序还是迁移脚本。

检查迁移历史是否完整

EF Core 根据数据库中的 __EFMigrationsHistory 表判断哪些迁移已经执行。需要检查:

  • RoomReservationDB 中是否存在 __EFMigrationsHistory。
  • 表中是否包含当前项目的初始迁移记录。
  • 连接字符串是否始终指向同一个 SQL Server 实例和数据库。
  • 迁移代码中是否手动执行了 CREATE DATABASE。
  • 项目是否在其他位置调用了 EnsureCreated() 或 EnsureDeleted()。

使用 migrations 时,不要同时调用:

await context.Database.EnsureCreatedAsync();

EnsureCreatedAsync() 不使用迁移历史机制。它与 MigrateAsync() 混用时,可能出现数据库对象已经存在,但 EF Core 无法正确识别迁移状态的情况。

迁移历史表存在且记录完整,并且 SQL Server 已经可用时,MigrateAsync() 应该直接结束,或者只应用新增的迁移。

种子数据也要支持重复执行

应用每次启动都会执行 DbSeeder.SeedData(context),所以种子逻辑需要具备幂等性。例如:

public static async Task SeedData(AppDbContext context)
{
    if (!await context.Rooms.AnyAsync())
    {
        context.Rooms.AddRange(
            new Room { Name = "Meeting Room A" },
            new Room { Name = "Meeting Room B" });

        await context.SaveChangesAsync();
    }
}

对于必须保持唯一的数据,最好同时建立唯一索引或唯一约束,避免多个应用实例同时启动时插入重复记录。

如果只执行过 docker compose stop 和 docker compose start,容器本身并未删除。即使 volume 挂载有误,数据通常仍保存在容器可写层中。此时应先检查 SQL Server 的就绪时序,并查看完整异常。修正 volume 路径,是为了在容器重新创建后仍能可靠保留数据库。

备注:内容仅供参考。