上一篇讲了"为什么用独立数据库"。独立数据库带来一个必须解决的问题:新客户开进来的时候,谁来给他建表、建管理员、分配菜单?
答案就在 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);
}
}
...
}
三个设计点:
- 每一步都是幂等的(先
AnyAsync()判断再插入)。同一个租户重复执行初始化不会插出两条管理员角色。 - 默认密码是
{租户编码}123,比如租户码vip就是vip123;并且ForcePasswordChange = true,第一次登录会强制改密码。 - 密码走 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);
}
这段逻辑解决了一个很实际的问题:界面上勾选的菜单树可能只提交了子节点(尤其是通过组件树回传时)。如果直接同步,父菜单在租户库里不存在,导航渲染不出来,外键也会报错。
所以处理顺序是:
- 从主库按勾选的 Id 递归往上找齐所有祖先;
- 从主库取出这些菜单的完整数据;
- 用 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 的多租户实现:独立数据库、自动建表、菜单同步、权限与文件缓存全隔离。