EasyAdminBlazor 多租户源码解析:为什么采用独立数据库隔离?

原创 2026-09-29 718 次阅读

已发布的《多租户架构——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 的多租户实现可以压缩成四句话:

  1. 识别:按请求 Host 在主库 SysTenant 表匹配租户,每个请求只解析一次。
  2. 路由:AdminContext.Orm 根据是否解析到租户,返回主库或租户库的 FreeSql 实例。
  3. 隔离:数据库物理隔离,文件按 {tenantCode} 分目录,缓存/Redis Key 加 tenant:{code}: 前缀。
  4. 防注入:租户编码进入数据库名/路径/缓存键之前,必须过白名单。

独立数据库带来的最大代价是"新租户的表结构必须自动建好",也就是 AutoSyncStructure 存在的原因。下一篇专门讲它。


如果你正在用 .NET 10 + Blazor 做多租户 SaaS 后台,可以看看 EasyAdminBlazor 的做法:独立数据库 + 按域名识别 + 菜单/权限/文件/缓存全链路隔离,源码开放,可以按自己的商业模型调整。