已发布的《多租户架构——SaaS 系统的核心能力》讲的是"什么是多租户"。这篇进入实现层:租户怎么被识别、数据库怎么被切过去、为什么 EasyAdminBlazor 选择独立数据库而不是 TenantId 行级隔离。
一、三种隔离方案,先摆在一起比
做 SaaS 后台,多租户数据隔离绕不开三条路:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 共享库 + TenantId 行级隔离 | 所有表加 TenantId,每次查询都带租户条件 |
成本最低、扩容简单、跨租户统计容易 | 漏写一次 WHERE TenantId = ? 就是数据泄露;大客户数据混在一起;备份恢复无法按客户做 |
| 共享库 + 独立 Schema | 每个租户一个 schema,表结构相同 | 隔离比行级强,仍共享实例 | Schema 数量多时运维复杂;数据库方言差异大(MySQL / SQLite 没有等价 schema 概念) |
| 独立数据库 | 每个租户一套库,连接串各自独立 | 物理隔离,权限、备份、恢复、迁移都可按租户做 | 连接数和运维成本高;跨租户统计要另行处理 |
EasyAdminBlazor 当前采用的是独立数据库隔离。这不是一个"更高级"的选择,而是一个针对"企业后台交付"场景的取舍:客户数量通常有限(几十到几百),但每个客户对数据隔离的要求很高,而且很多项目本身就是私有化部署 + SaaS 混合。
源码里能看到这个选择的直接证据:
// MultiTenantService.BuildTenantOrm
var fsql = new FreeSqlBuilder()
.UseConnectionString(tenantInfo.DataType, tenantInfo.ConenctionString.Replace("{database}", tenantCode))
.UseAdoConnectionPool(true)
.UseAutoSyncStructure(true)
.Build();
每个租户用自己的 DataType + ConenctionString,{database} 占位符会被租户编码替换。也就是说,租户既可以指向同一台数据库服务器上的不同库,也可以指向完全不同的实例。
二、租户是怎么被识别的
1. 按 Host 识别
MultiTenantService.GetCurrentTenant() 用请求的 Host 去主库匹配:
public SysTenant? GetCurrentTenant()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return null;
// 每个请求只解析一次,后续从 HttpContext.Items 缓存读取
if (httpContext.Items.TryGetValue(TenantContextKey, out var cached) && cached is SysTenant cachedTenant)
return cachedTenant;
var host = httpContext.Request.Host.Value; // e.g. "localhost:7230"
if (string.IsNullOrEmpty(host))
{
httpContext.Items[TenantContextKey] = null;
return null;
}
// 在 SysTenant 表中查找 Host 匹配的租户(支持完整 URL 或纯 host:port)
var tenant = _mainOrm.Select<SysTenant>()
.DisableGlobalFilter()
.Where(a => a.IsEnabled && (a.Host == host || a.Host == $"https://{host}" || a.Host == $"http://{host}"))
.First();
httpContext.Items[TenantContextKey] = tenant;
return tenant;
}
几个细节:
- 主库查询显式
DisableGlobalFilter()——租户表本身不能被软删除过滤器挡住,否则匹配行为会变得难以预测。 SysTenant.Host支持三种写法:localhost:7230、https://localhost:7230、http://localhost:7230。开发环境用端口区分,生产环境用域名。- 每个请求只查一次,结果放在
HttpContext.Items里,避免同一次请求内反复查库。 - 只有
IsEnabled = true的租户才会被解析出来,停用租户等于"访问不到"。
2. Blazor Server 的特殊处理
Blazor Server 的交互回调里 HttpContext 可能已经不可用了,所以 AdminContext 把租户解析结果缓存了一次:
public SysTenant? Tenant
{
get
{
if (!_tenantResolved)
{
_cachedTenant = _tenantService?.GetCurrentTenant();
_tenantResolved = true;
}
return _cachedTenant;
}
}
同时提供了显式的切换与失效方法:
public void InvalidateTenantCache()
{
_tenantResolved = false;
_cachedTenant = null;
}
public void SetTenant(SysTenant? tenant)
{
if (tenant != null && !IsValidTenantCode(tenant.Code))
throw new ArgumentException("租户编码不合法", nameof(tenant));
var oldCode = TenantCode;
_cachedTenant = tenant;
_tenantResolved = true;
if (!string.Equals(oldCode, TenantCode, StringComparison.Ordinal))
{
// 租户发生变化,权限/菜单缓存必须重新构建
Roles = [];
RoleMenus = [];
}
}
"切换租户"在普通 Web 请求里很少发生,但 Blazor Server 的 Circuit 生命周期很长,不及时失效就会一直用旧租户的数据,所以这里显式做了缓存清理。
三、数据库是怎么切过去的
1. AdminContext.Orm 是唯一入口
/// <summary>
/// 获取 FreeSql 实例(多租户模式下自动切换到租户数据库)。
/// </summary>
public IFreeSql Orm => Tenant != null ? _tenantService!.GetTenantFreeSql(Tenant.Code) : _mainOrmHandle.Orm;
这一行是整条链路的关键:
- 没解析到租户(单库模式 / 主站)→ 用主库 ORM;
- 解析到租户 → 用该租户的 FreeSql 实例。
框架注册的 IFreeSql 是从 MainOrmHandle 解析出来的 Singleton,而多租户扩展会把 FreeSqlCloud 注册进来(FreeSqlCloud : FreeSqlCloud<string>, IFreeSql),由它按租户编码返回不同的实例:
public static WebApplicationBuilder AddEasyAdminBlazorMultiTenant(this WebApplicationBuilder builder)
{
// 调用此扩展即启用多租户,无需再设置 EnableMultiTenant = true
builder.Services.PostConfigure<EasyAdminBlazorOptions>(o => o.EnableMultiTenant = true);
// 关键:这里绝对不能用 builder.Services.BuildServiceProvider() 去提前解析 MainOrmHandle,
// 那会创建第二个 DI 容器(导致单例被构造两次、生命周期错乱、配置不生效)。
builder.Services.AddSingleton(sp =>
{
var cloud = new FreeSqlCloud();
var mainOrm = sp.GetRequiredService<IFreeSql>();
cloud.Register("main", () => mainOrm);
return cloud;
});
// 替换默认的 NullTenantService 为 MultiTenantService
builder.Services.RemoveAll<ITenantService>();
builder.Services.AddSingleton<ITenantService>(sp =>
new MultiTenantService(
sp.GetRequiredService<FreeSqlCloud>(),
sp.GetRequiredService<IFreeSql>(),
sp.GetRequiredService<IHttpContextAccessor>()));
return builder;
}
那段注释值得读两遍:不要在注册阶段 BuildServiceProvider()。这会创建第二个容器,单例被构造两次,配置不生效,属于很容易埋进去、后期极难排查的坑。
2. 谁在主库,谁在租户库
理解分工比记住 API 更重要:
| 数据 | 存放位置 | 说明 |
|---|---|---|
SysTenant(租户清单) |
主库 | 租户识别必须查主库 |
| 主站自身的用户、角色、菜单、业务数据 | 主库 | 主租户编码固定为 main |
| 租户的用户、角色、菜单、业务数据 | 租户库 | 每个租户一套完整的组织、权限、业务表 |
| 上传的文件 | 按租户分目录 | wwwroot/uploads/{tenantCode}/yyyy/MM/dd/ |
| 缓存 / Redis Key | 按租户加前缀 | tenant:{code}: |
注意第二行:主站也是一个租户,只是它的编码固定是 main。这让"主站 + 多个客户站点"可以用同一套代码。
文件目录隔离来自 FileService:
private string TenantPrefix => _adminContext.Tenant?.Code is { } code ? $"{code}/" : "";
缓存隔离来自 AdminContext:
public const string MainTenantCode = "main";
/// <summary>
/// 统一的多租户缓存/Redis 键前缀,格式 tenant:{code}:。
/// 所有跨租户共享的缓存、Redis Key 都必须带上此前缀,避免租户之间串数据。
/// </summary>
public string TenantCachePrefix => $"tenant:{TenantCode}:";
数据库隔离了,但缓存和文件如果不隔离,一样会串——这一点会单独用一篇文章展开。
四、为什么是独立数据库
1. 收益
物理隔离。 租户 A 的数据和租户 B 的数据根本不在一个库里。即使某处代码漏写了租户过滤条件,也查不到别人的数据——因为它连的是另一个数据库。这是行级隔离给不了的安全下限。
按租户运维。 备份、恢复、迁移、导出都可以针对单个客户做。客户要"把我的数据给我"时,直接给一个库的备份就是完整交付。
连接串可指向不同实例。 SysTenant 上有独立的 DataType 和 ConenctionString(字段名沿用源码拼写),所以:
- 小客户可以共用一台数据库服务器上的不同库;
- 大客户可以放到独立实例甚至独立机房;
- 私有化部署时,同一个程序集可以指向客户自己的数据库。
升级可控。 表结构变更可以按租户灰度,出了问题也只影响一个租户的库。
2. 代价
连接数与资源开销。 每个租户一套连接池,租户多的时候数据库连接会成为瓶颈。源码用了 UseAdoConnectionPool(true),并缓存已构建的 FreeSql 实例,避免每个请求都重新建连。
建库与迁移要做自动化。 新租户不能靠人工建表,所以必须有自动建表能力——这就是下一篇文章的主角 AutoSyncStructure。
跨租户统计变难。 "所有租户一共多少订单"这类报表,需要主库汇总或者额外的数据仓库。框架提供的是运行时隔离,不提供跨库分析能力。
数据库数量上限。 MySQL / SQL Server 单实例的库数量有实际运维上限,规模化时要提前规划分片。
3. 什么时候不该选它
如果你的场景是"免费用户几万个、每个用户数据量很小",独立数据库的成本会远高于行级隔离。多租户方案没有银弹,EasyAdminBlazor 选择的是面向企业后台交付的那条路——它解决的是"客户要求数据必须分开"这个更常见的商业约束。
五、落地时要注意的几件事
1. 租户编码是"危险输入",必须白名单
租户编码会进入数据库名、文件路径、缓存键和 Redis Key。所以框架在入口统一做了校验:
/// <summary>
/// 租户编码白名单:字母、数字、下划线、短横线,长度 1~50。
/// </summary>
[GeneratedRegex("^[A-Za-z0-9_-]{1,50}$")]
private static partial Regex TenantCodeRegex();
public static bool IsValidTenantCode(string? tenantCode)
{
return !string.IsNullOrWhiteSpace(tenantCode) && TenantCodeRegex().IsMatch(tenantCode);
}
测试里锁定了这些反例:../tenant、tenant/1、tenant\1、tenant;1、tenant";drop、tenant.1、租户1 全部拒绝。租户保存前也会再校验一次,不合法直接拦住:
private async Task OnBeforeSaveAsync(AdminSaveEventArgs<SysTenant> e)
{
e.Item.Code = e.Item.Code.Trim().ToLower();
if (!AdminContext.IsValidTenantCode(e.Item.Code))
{
await SwalService.Error(CommonLocalizer["租户编码只允许字母、数字、下划线与短横线,长度 1~50"]);
e.Cancel = true;
}
}
GetTenantFreeSql 内部还会再校验一次,形成"保存时校验 + 使用时校验"的双重防线。
2. 主租户不允许删除
private async Task OnBeforeDeleteAsync(AdminRemoveEventArgs<SysTenant> e)
{
foreach (var item in e.Items)
{
if (item.Code == "main")
{
await SwalService.Error(CommonLocalizer["默认租户不允许删除"]);
e.Cancel = true;
break;
}
}
...
}
主库同时也是 main 租户的数据库,删掉等于把系统自身的管理数据删了。
3. 租户表上不要被软删除过滤器挡住
GetCurrentTenant 用了 DisableGlobalFilter()。你自己的租户相关查询如果涉及软删除实体,也要同样处理,否则会出现"租户明明存在但解析不到"的诡异现象。
4. 租户内的查询不要跨到主库
AdminContext.Orm 在解析到租户后返回的是租户库。业务代码统一用这个入口(或注入的 IFreeSql)即可。只有租户清单、跨租户统计这类明确要读主库的场景,才用 MainOrmHandle:
[Inject] MainOrmHandle MainOrm { get; set; } = default!;
var allMenus = await MainOrm.Orm.Select<SysMenu>().ToListAsync();
Pages/Tenant.razor 就是这么做的——它要读主库的完整菜单树,再同步到租户库。
5. 关闭多租户时要有回退路径
框架默认注册的是 NullTenantService,它的 GetTenantFreeSql 会直接抛异常:
public class NullTenantService : ITenantService
{
public SysTenant? GetCurrentTenant() => null;
public IFreeSql GetTenantFreeSql(string tenantCode)
=> throw new NotSupportedException("请安装 EasyAdminBlazor.MultiTenant 扩展以启用多租户功能");
public Task<List<SysMenu>> GenerateTenantMenus(string tenantCode)
=> throw new NotSupportedException("请安装 EasyAdminBlazor.MultiTenant 扩展以启用多租户功能");
}
所以单库模式下调用租户 API 会立刻报错,而不是静默拿到主库——这种"显式失败"比"悄悄用错库"安全得多。
六、小结
EasyAdminBlazor 的多租户实现可以压缩成四句话:
- 识别:按请求 Host 在主库
SysTenant表匹配租户,每个请求只解析一次。 - 路由:
AdminContext.Orm根据是否解析到租户,返回主库或租户库的 FreeSql 实例。 - 隔离:数据库物理隔离,文件按
{tenantCode}分目录,缓存/Redis Key 加tenant:{code}:前缀。 - 防注入:租户编码进入数据库名/路径/缓存键之前,必须过白名单。
独立数据库带来的最大代价是"新租户的表结构必须自动建好",也就是 AutoSyncStructure 存在的原因。下一篇专门讲它。
如果你正在用 .NET 10 + Blazor 做多租户 SaaS 后台,可以看看 EasyAdminBlazor 的做法:独立数据库 + 按域名识别 + 菜单/权限/文件/缓存全链路隔离,源码开放,可以按自己的商业模型调整。