EasyAdminBlazor 动态创建租户数据库:AutoSyncStructure 到底做了什么?

Original 2026-09-30 867 views

上一篇讲了"为什么用独立数据库"。独立数据库带来一个必须解决的问题:新客户开进来的时候,谁来给他建表、建管理员、分配菜单?

答案就在 AddEasyAdminBlazorMultiTenant() 到租户第一次登录之间那几百行代码里。


一、从"点保存"到"能登录",中间发生了什么

管理员在 多租户 页面点"保存"之后,系统要完成:

主库写入 SysTenant 记录
   ↓
GetTenantFreeSql(tenantCode)      构建/复用租户的 FreeSql 实例
   ↓
AutoSyncStructure                 按实体在租户库建表 / 补列
   ↓
初始化基础数据                    系统配置、管理员角色、管理员账号
   ↓
同步菜单                          把勾选的菜单(含祖先)写进租户库
   ↓
租户用自己的域名 + 管理员账号登录

下面按源码顺序拆开看。


二、第一步:GetTenantFreeSql 如何构建租户 ORM

这是整个流程的核心方法:

public IFreeSql GetTenantFreeSql(string tenantCode)
{
    // 入口统一校验租户编码,避免其进入数据库名 / 文件路径 / Redis Key 时产生注入或路径问题
    if (!IsValidTenantCode(tenantCode))
    {
        throw new ArgumentException($"租户编码不合法:{tenantCode}。只允许字母、数字、下划线与短横线,长度 1~50。", nameof(tenantCode));
    }

    // 已构建完成:直接复用,避免重复初始化
    if (_tenantOrmCache.TryGetValue(tenantCode, out var cachedOrm))
    {
        _cloud.Use(tenantCode);
        return cachedOrm;
    }

    // 同一个 tenantCode 的初始化必须串行:
    // 防止多个请求同时创建同一个租户的 FreeSql 与表结构。
    var initLock = _initLocks.GetOrAdd(tenantCode, _ => new SemaphoreSlim(1, 1));
    initLock.Wait();
    try
    {
        // 双重检查:等待期间可能已被其他请求初始化完成
        if (_tenantOrmCache.TryGetValue(tenantCode, out cachedOrm))
        {
            _cloud.Use(tenantCode);
            return cachedOrm;
        }

        var tenantInfo = _mainOrm.Select<SysTenant>().DisableGlobalFilter().Where(a => a.Code == tenantCode).First();
        if (tenantInfo == null)
            throw new Exception($"租户 {tenantCode} 不存在");

        IFreeSql? builtOrm = null;
        if (_registered.TryAdd(tenantCode, 0))
        {
            _cloud.Register(tenantCode, () =>
            {
                var fsql = BuildTenantOrm(tenantInfo, tenantCode);
                builtOrm = fsql;
                return fsql;
            });
        }

        // 触发 cloud 的实例工厂(内部有缓存,只会真正执行一次)
        var result = _cloud.Use(tenantCode);
        if (result != null)
        {
            _tenantOrmCache[tenantCode] = result;
        }

        return builtOrm ?? result ?? throw new Exception($"租户 {tenantCode} 初始化失败");
    }
    finally
    {
        initLock.Release();
    }
}

这段代码里有四层保护,一层都不能少:

机制 作用
_tenantOrmCache 快速路径 已初始化过的租户直接复用实例,不重复建库建表
_initLocks + SemaphoreSlim(1,1) 同一个租户的初始化串行化
锁内双重检查 等待期间别人已经初始化完,直接复用,避免重复劳动
_registered 去重 保证 cloud.Register 对同一个租户只调用一次

源码注释也把多实例部署的边界说清楚了:

// 同一个 tenantCode 的初始化必须串行:防止多个请求同时创建同一个租户的 FreeSql 与表结构。
// 多实例部署情况下(进程外并发),需要 Redis/数据库分布式锁配合;
// 此处保证单进程内严格串行,并且 FreeSql 的建表语句本身是幂等的。

也就是说:进程内锁解决进程内并发,跨进程要靠分布式锁 + 建表语句幂等。这一点在后面的"并发"一节展开。


三、第二步:BuildTenantOrm 与 AutoSyncStructure

GetTenantFreeSql 最终调用 BuildTenantOrm:

/// <summary>
/// 创建租户数据库的 FreeSql 实例。
///
/// 注意:<c>UseAutoSyncStructure(true)</c> 是多租户动态创建租户数据库/表结构的设计要求,必须保留。
/// </summary>
private static IFreeSql BuildTenantOrm(SysTenant tenantInfo, string tenantCode)
{
    var fsql = new FreeSqlBuilder()
        .UseConnectionString(tenantInfo.DataType, tenantInfo.ConenctionString.Replace("{database}", tenantCode))
        .UseAdoConnectionPool(true)
        .UseAutoSyncStructure(true)
        .Build();

    var serverTime = fsql.Ado.QuerySingle(() => DateTime.UtcNow);
    var timeOffset = DateTime.UtcNow.Subtract(serverTime);

    fsql.UseJsonMap();
    // 与主库保持一致:软删除实体自动排除已删除数据
    fsql.GlobalFilter.Apply<IEntitySoftDelete>("IsDeleted", a => a.IsDeleted == false);
    fsql.Aop.AuditValue += (_, e) =>
    {
        if (e.Column.CsName == nameof(SysMenu.PathLower) && typeof(SysMenu).IsAssignableFrom(e.Column.Table.Type))
        {
            var path = Convert.ToString(e.Column.Table.ColumnsByCs[nameof(SysMenu.Path)].GetValue(e.Object))?.Trim('/');
            e.Column.Table.ColumnsByCs[nameof(SysMenu.Path)].SetValue(e.Object, path);
            e.Value = path?.ToLower();
            return;
        }
        if ((e.Column.CsType == typeof(DateTime) || e.Column.CsType == typeof(DateTime?))
            && e.Column.Attribute.ServerTime != DateTimeKind.Unspecified
            && (e.Value == null || (DateTime)e.Value == default || (DateTime?)e.Value == default))
        {
            e.Value = (e.Column.Attribute.ServerTime == DateTimeKind.Utc ? DateTime.UtcNow : DateTime.Now).Subtract(timeOffset);
            return;
        }
        if (e.Column.CsType == typeof(long)
            && e.Property.GetCustomAttribute<SnowflakeAttribute>(false) != null
            && (e.Value == null || (long)e.Value == default || (long?)e.Value == default || e.Value?.ToString() == "0"))
        {
            e.Value = YitIdHelper.NextId();
            return;
        }
    };
    return fsql;
}

1. AutoSyncStructure 实际做了什么

UseAutoSyncStructure(true) 开启后,FreeSql 在第一次访问某张表时会做一次 CodeFirst 对比:

实体类(Table / Column 特性)
        ↕ 对比
数据库现有表结构
        ↓
差异 → 建表 / 加列 / 建索引(DDL)

对新租户来说,这意味着不需要任何人工建表脚本:第一次访问租户库时,框架里所有实体对应的表都会被创建出来。

对这个设计而言,它不是"开发期便利选项",而是功能的一部分:

  • 没有它,新租户的库是空的,第一次登录就会报表不存在;
  • 没有它,框架升级新增一个实体列,旧租户的库不会自动补齐;
  • 没有它,"动态创建租户"这个卖点根本不成立。

测试明确锁定了这个行为:

/// <summary>
/// 租户数据库必须启用 AutoSyncStructure,保证新租户首次访问能自动建表。
/// </summary>
[Fact]
public void TenantOrm_AutoSyncStructure_IsEnabled()
{
    var path = Path.Combine(Path.GetTempPath(), $"easyadmin_tenant_sync_{Guid.NewGuid():N}.db");
    using var orm = BuildOrm(path);

    // 未手工建表,AutoSyncStructure 应已自动创建 SysConfig 表
    orm.CodeFirst.GetTableByEntity(typeof(SysConfig)).Should().NotBeNull();
    ...
    orm.Insert(new SysConfig { Code = "X", Content = "Y", IsSystem = true }).ExecuteAffrows();
    orm.Select<SysConfig>().Count().Should().Be(1);
}

所以:不要把 AutoSyncStructure 当成"上线要关掉的开发开关"。主库可以按团队规范关闭它、走发布脚本;租户库的动态初始化流程需要它。

2. 另外三个容易被忽略的配置

UseAdoConnectionPool(true):开启连接池。多租户意味着成倍增加的连接数,没有连接池会很快打满数据库。

服务器时间偏移:

var serverTime = fsql.Ado.QuerySingle(() => DateTime.UtcNow);
var timeOffset = DateTime.UtcNow.Subtract(serverTime);

后续 ServerTime 字段落库时会减去这个偏移量,保证多台机器(应用服务器与数据库服务器时区不一致)写入的时间仍然一致。

审计值处理器:负责三件事——SysMenu.PathLower 自动小写、ServerTime 字段自动填时间、[Snowflake] 主键自动生成 ID。租户库和主库行为保持一致,否则同一份实体在不同库里表现不同,问题会非常难查。


四、第三步:初始化租户的基础数据

FreeSql 只负责建表,建完还是空库。管理员角色、管理员账号、系统配置都要有人写进去——这是 Tenant.razor 里 OnFinishSaveAsync 的职责:

private async Task OnFinishSaveAsync(AdminSaveEventArgs<SysTenant> e)
{
    var fsql = TenantService.GetTenantFreeSql(e.Item.Code);

    if (e.ChangedType == ItemChangedType.Add)
    {
        // 创建配置数据
        if (await fsql.Select<SysConfig>().AnyAsync() == false)
        {
            await fsql.Insert(new SysConfig { Code = "SYSTEM_NAME", Content = e.Item.Title, IsSystem = true, Name = "系统名称" }).ExecuteAffrowsAsync();
            await fsql.Insert(new SysConfig { Code = "SYSTEM_ICON", Content = "/favicon.png", IsSystem = true, Name = "系统图标" }).ExecuteAffrowsAsync();
        }

        // 创建管理员角色
        if (await fsql.Select<SysRole>().Where(a => a.IsAdministrator).AnyAsync() == false)
        {
            await fsql.Insert(new SysRole { Name = "Administrator", Description = "管理员角色", IsAdministrator = true }).ExecuteAffrowsAsync();
        }

        // 创建管理员用户
        if (await fsql.Select<SysUser>().Where(a => a.Roles.Any(b => b.IsAdministrator)).AnyAsync() == false)
        {
            var adminUser = new SysUser
            {
                Username = "admin",
                Password = PBKDF2Encrypt.HashPassword($"{e.Item.Code}123"),
                Nickname = "管理员",
                ForcePasswordChange = true
            };
            adminUser.Roles = [await fsql.Select<SysRole>().Where(a => a.IsAdministrator).FirstAsync()];
            await fsql.GetAggregateRootRepository<SysUser>().InsertAsync(adminUser);
        }
    }
    ...
}

三个设计点:

  1. 每一步都是幂等的(先 AnyAsync() 判断再插入)。同一个租户重复执行初始化不会插出两条管理员角色。
  2. 默认密码是 {租户编码}123,比如租户码 vip 就是 vip123;并且 ForcePasswordChange = true,第一次登录会强制改密码。
  3. 密码走 PBKDF2 哈希,不存明文——和主库的初始化逻辑保持一致。

五、第四步:把菜单同步到租户库

租户库是独立的,菜单表也是独立的。所以"这个租户能用哪些菜单"必须复制进他们的库:

// 从主库获取完整菜单数据(AdminMenuTree 只传了 Id,需要补全)
// 同时补全祖先菜单,避免子菜单选中但父菜单缺失导致外键约束失败
var selectedIds = e.Item.Menus?.Select(m => m.Id).ToHashSet() ?? [];
if (selectedIds.Count > 0)
{
    // 递归补全所有祖先 ParentId
    var allIds = new HashSet<long>(selectedIds);
    var needCheck = new HashSet<long>(selectedIds);
    while (needCheck.Count > 0)
    {
        var parents = await MainOrm.Orm.Select<SysMenu>()
            .Where(m => needCheck.Contains(m.Id) && m.ParentId > 0)
            .ToListAsync(m => m.ParentId);
        needCheck.Clear();
        foreach (var pid in parents)
        {
            if (allIds.Add(pid))
                needCheck.Add(pid);
        }
    }

    var fullMenus = await MainOrm.Orm.Select<SysMenu>()
        .Where(m => allIds.Contains(m.Id))
        .ToListAsync();

    // 同步菜单到租户数据库
    var repoMenu = fsql.GetRepository<SysMenu>();
    var existingMenus = await repoMenu.Select.ToListAsync();
    repoMenu.BeginEdit(existingMenus);
    repoMenu.EndEdit(fullMenus);
}

这段逻辑解决了一个很实际的问题:界面上勾选的菜单树可能只提交了子节点(尤其是通过组件树回传时)。如果直接同步,父菜单在租户库里不存在,导航渲染不出来,外键也会报错。

所以处理顺序是:

  1. 从主库按勾选的 Id 递归往上找齐所有祖先;
  2. 从主库取出这些菜单的完整数据;
  3. 用 FreeSql 仓储的 BeginEdit / EndEdit 做"对比差异后同步"——已存在的不重复插,新增的补进去。

菜单同步是"编辑租户"时也会走的分支,所以改了菜单权限后重新保存,租户库会跟着更新。

另外,框架的 GenerateTenantMenus 会过滤掉部分系统菜单:

public async Task<List<SysMenu>> GenerateTenantMenus(string tenantCode)
{
    var allMenus = await _mainOrm.Select<SysMenu>().ToListAsync();
    var result = new List<SysMenu>();
    var ignoreLabels = new[] { "Tenant" };

    foreach (var menu in allMenus)
    {
        if (!ignoreLabels.Contains(menu.Label) && menu.Type != SysMenuType.Button)
        {
            result.Add(new SysMenu { ... });
        }
    }
    return result;
}

租户自己的库里不需要"租户管理"这个菜单,也不应该把按钮节点当菜单同步过去。


六、并发:同一个租户被两个人同时初始化会怎样

这是动态建租户最容易出问题的地方,源码里的处理分三层。

第一层:单进程内串行

private static readonly ConcurrentDictionary<string, SemaphoreSlim> _initLocks = new();
...
var initLock = _initLocks.GetOrAdd(tenantCode, _ => new SemaphoreSlim(1, 1));
initLock.Wait();
try
{
    // 双重检查:等待期间可能已被其他请求初始化完成
    if (_tenantOrmCache.TryGetValue(tenantCode, out cachedOrm))
    {
        _cloud.Use(tenantCode);
        return cachedOrm;
    }
    ...
}
finally
{
    initLock.Release();
}

同一个进程里,多个请求同时首次访问租户 A 时,只有一个真正执行建库建表,其余等在门口,进去后从缓存里拿到同一个 IFreeSql 实例。

第二层:注册去重

private static readonly ConcurrentDictionary<string, byte> _registered = new();
...
if (_registered.TryAdd(tenantCode, 0))
{
    _cloud.Register(tenantCode, () => { ... });
}

FreeSqlCloud.Register 对同一租户重复调用会抛异常,所以用 _registered 做一次原子去重。注意它是 static 的:同一进程内任何地方重复初始化都安全。

第三层:建表语句本身幂等

即使跨进程并发(多实例部署),AutoSyncStructure 生成的建表语句也是"存在则跳过、缺失则创建"的幂等形式。所以最坏情况是多做了几次结构探测,不会把表建坏。

但要注意源码里那句注释的前提:

多实例部署情况下(进程外并发),需要 Redis/数据库分布式锁配合;
此处保证单进程内严格串行,并且 FreeSql 的建表语句本身是幂等的。

也就是说,如果你的部署是多实例 + 首次创建租户这种强一致要求的场景,需要在业务层加一把分布式锁(框架的 Redis 扩展里有分布式锁能力),或者把"创建租户"收到单个后台任务里执行。

还有一件事:DDL 不能落在业务事务里

这个坑在审批模块里体现得更明显(ApprovalService 专门在事务外先调 CodeFirst.SyncStructure),但原理是通用的:建表语句和业务事务混在一起,在 SQLite 上会直接 database is locked。

所以"初始化租户"这类带 DDL 的操作,应该放在事务之外先完成结构同步,再做数据写入。


七、常见问题排查

现象 原因 处理
保存租户时报"租户编码不合法" 编码含 .、/、中文、空格等 只用字母、数字、下划线、短横线,长度 1~50
租户登录后表不存在 租户 ORM 没开 AutoSyncStructure,或数据库用户没有 DDL 权限 确认 BuildTenantOrm 未被改动;给数据库账号建表权限
连接串报错 {database} 占位符缺失或数据库不存在 连接串写成 Database={database} 形式;确认目标库可创建
租户菜单是空的 创建租户时没有勾选菜单 编辑租户 → 功能菜单 → 勾选后保存
时间差几小时 应用服务器与数据库服务器时区不一致 由 timeOffset 自动校正;检查 ServerTime 特性的使用
租户管理员登录失败 默认密码是 {租户编码}123 例如租户码 vip → 密码 vip123;首次登录会强制改密
多实例下偶发初始化冲突 进程内锁不覆盖跨进程 加分布式锁,或把租户初始化放到单个任务里

八、小结

动态创建租户这条链路,源码给出的答案是:

校验租户编码(白名单)
   ↓
进程内串行 + 双重检查  →  构建 FreeSql 实例(连接串替换 {database})
   ↓
AutoSyncStructure      →  按实体自动建表 / 补列(必须保留)
   ↓
初始化基础数据          →  系统配置 + 管理员角色 + 管理员账号(幂等)
   ↓
同步菜单(含祖先)      →  BeginEdit / EndEdit 差异同步
   ↓
租户用自己的域名和账号登录

这里面最容易被误读的一句话是"上线要关掉 AutoSyncStructure"。对主库它是可选项;对租户库的动态初始化,它是功能实现的一部分。理解了这一点,也就理解了 EasyAdminBlazor 独立数据库多租户为什么能开箱即用。


如果你正在做 SaaS 或私有化交付,需要一套"新客户开箱即用"的多租户后台,可以看看 EasyAdminBlazor 的多租户实现:独立数据库、自动建表、菜单同步、权限与文件缓存全隔离。