ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ASP.NET Core 多租户怎么实现?为什么选择数据库隔离?

ASP.NET Core 多租户怎么实现?为什么选择数据库隔离? 已发布的《多租户架构——SaaS 系统的核心能力》讲的是什么是多租户。这篇进入实现层租户怎么被识别、数据库怎么被切过去、为什么 EasyAdminBlazor 选择独立数据库而不是TenantId行级隔离。一、三种隔离方案先摆在一起比做 SaaS 后台多租户数据隔离绕不开三条路方案做法优点代价共享库 TenantId 行级隔离所有表加TenantId每次查询都带租户条件成本最低、扩容简单、跨租户统计容易漏写一次WHERE TenantId ?就是数据泄露大客户数据混在一起备份恢复无法按客户做共享库 独立 Schema每个租户一个 schema表结构相同隔离比行级强仍共享实例Schema 数量多时运维复杂数据库方言差异大MySQL / SQLite 没有等价 schema 概念独立数据库每个租户一套库连接串各自独立物理隔离权限、备份、恢复、迁移都可按租户做连接数和运维成本高跨租户统计要另行处理EasyAdminBlazor 当前采用的是独立数据库隔离。这不是一个更高级的选择而是一个针对企业后台交付场景的取舍客户数量通常有限几十到几百但每个客户对数据隔离的要求很高而且很多项目本身就是私有化部署 SaaS 混合。源码里能看到这个选择的直接证据// MultiTenantService.BuildTenantOrmvarfsqlnewFreeSqlBuilder().UseConnectionString(tenantInfo.DataType,tenantInfo.ConenctionString.Replace({database},tenantCode)).UseAdoConnectionPool(true).UseAutoSyncStructure(true).Build();每个租户用自己的DataTypeConenctionString{database}占位符会被租户编码替换。也就是说租户既可以指向同一台数据库服务器上的不同库也可以指向完全不同的实例。二、租户是怎么被识别的1. 按 Host 识别MultiTenantService.GetCurrentTenant()用请求的 Host 去主库匹配publicSysTenant?GetCurrentTenant(){varhttpContext_httpContextAccessor.HttpContext;if(httpContextnull)returnnull;// 每个请求只解析一次后续从 HttpContext.Items 缓存读取if(httpContext.Items.TryGetValue(TenantContextKey,outvarcached)cachedisSysTenantcachedTenant)returncachedTenant;varhosthttpContext.Request.Host.Value;// e.g. localhost:7230if(string.IsNullOrEmpty(host)){httpContext.Items[TenantContextKey]null;returnnull;}// 在 SysTenant 表中查找 Host 匹配的租户支持完整 URL 或纯 host:portvartenant_mainOrm.SelectSysTenant().DisableGlobalFilter().Where(aa.IsEnabled(a.Hosthost||a.Host$https://{host}||a.Host$http://{host})).First();httpContext.Items[TenantContextKey]tenant;returntenant;}几个细节主库查询显式DisableGlobalFilter()——租户表本身不能被软删除过滤器挡住否则匹配行为会变得难以预测。SysTenant.Host支持三种写法localhost:7230、https://localhost:7230、http://localhost:7230。开发环境用端口区分生产环境用域名。每个请求只查一次结果放在HttpContext.Items里避免同一次请求内反复查库。只有IsEnabled true的租户才会被解析出来停用租户等于访问不到。2. Blazor Server 的特殊处理Blazor Server 的交互回调里HttpContext可能已经不可用了所以AdminContext把租户解析结果缓存了一次publicSysTenant?Tenant{get{if(!_tenantResolved){_cachedTenant_tenantService?.GetCurrentTenant();_tenantResolvedtrue;}return_cachedTenant;}}同时提供了显式的切换与失效方法publicvoidInvalidateTenantCache(){_tenantResolvedfalse;_cachedTenantnull;}publicvoidSetTenant(SysTenant?tenant){if(tenant!null!IsValidTenantCode(tenant.Code))thrownewArgumentException(租户编码不合法,nameof(tenant));varoldCodeTenantCode;_cachedTenanttenant;_tenantResolvedtrue;if(!string.Equals(oldCode,TenantCode,StringComparison.Ordinal)){// 租户发生变化权限/菜单缓存必须重新构建Roles[];RoleMenus[];}}切换租户在普通 Web 请求里很少发生但 Blazor Server 的 Circuit 生命周期很长不及时失效就会一直用旧租户的数据所以这里显式做了缓存清理。三、数据库是怎么切过去的1. AdminContext.Orm 是唯一入口/// summary/// 获取 FreeSql 实例多租户模式下自动切换到租户数据库。/// /summarypublicIFreeSqlOrmTenant!null?_tenantService!.GetTenantFreeSql(Tenant.Code):_mainOrmHandle.Orm;这一行是整条链路的关键没解析到租户单库模式 / 主站→ 用主库 ORM解析到租户 → 用该租户的 FreeSql 实例。框架注册的IFreeSql是从MainOrmHandle解析出来的 Singleton而多租户扩展会把FreeSqlCloud注册进来FreeSqlCloud : FreeSqlCloudstring, IFreeSql由它按租户编码返回不同的实例publicstaticWebApplicationBuilderAddEasyAdminBlazorMultiTenant(thisWebApplicationBuilderbuilder){// 调用此扩展即启用多租户无需再设置 EnableMultiTenant truebuilder.Services.PostConfigureEasyAdminBlazorOptions(oo.EnableMultiTenanttrue);// 关键这里绝对不能用 builder.Services.BuildServiceProvider() 去提前解析 MainOrmHandle// 那会创建第二个 DI 容器导致单例被构造两次、生命周期错乱、配置不生效。builder.Services.AddSingleton(sp{varcloudnewFreeSqlCloud();varmainOrmsp.GetRequiredServiceIFreeSql();cloud.Register(main,()mainOrm);returncloud;});// 替换默认的 NullTenantService 为 MultiTenantServicebuilder.Services.RemoveAllITenantService();builder.Services.AddSingletonITenantService(spnewMultiTenantService(sp.GetRequiredServiceFreeSqlCloud(),sp.GetRequiredServiceIFreeSql(),sp.GetRequiredServiceIHttpContextAccessor()));returnbuilder;}那段注释值得读两遍不要在注册阶段BuildServiceProvider()。这会创建第二个容器单例被构造两次配置不生效属于很容易埋进去、后期极难排查的坑。2. 谁在主库谁在租户库理解分工比记住 API 更重要数据存放位置说明SysTenant租户清单主库租户识别必须查主库主站自身的用户、角色、菜单、业务数据主库主租户编码固定为main租户的用户、角色、菜单、业务数据租户库每个租户一套完整的组织、权限、业务表上传的文件按租户分目录wwwroot/uploads/{tenantCode}/yyyy/MM/dd/缓存 / Redis Key按租户加前缀tenant:{code}:注意第二行主站也是一个租户只是它的编码固定是main。这让主站 多个客户站点可以用同一套代码。文件目录隔离来自FileServiceprivatestringTenantPrefix_adminContext.Tenant?.Codeis{}code?${code}/:;缓存隔离来自AdminContextpublicconststringMainTenantCodemain;/// summary/// 统一的多租户缓存/Redis 键前缀格式 tenant:{code}:。/// 所有跨租户共享的缓存、Redis Key 都必须带上此前缀避免租户之间串数据。/// /summarypublicstringTenantCachePrefix$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}$)]privatestaticpartialRegexTenantCodeRegex();publicstaticboolIsValidTenantCode(string?tenantCode){return!string.IsNullOrWhiteSpace(tenantCode)TenantCodeRegex().IsMatch(tenantCode);}测试里锁定了这些反例../tenant、tenant/1、tenant\1、tenant;1、tenant;drop、tenant.1、租户1全部拒绝。租户保存前也会再校验一次不合法直接拦住privateasyncTaskOnBeforeSaveAsync(AdminSaveEventArgsSysTenante){e.Item.Codee.Item.Code.Trim().ToLower();if(!AdminContext.IsValidTenantCode(e.Item.Code)){awaitSwalService.Error(CommonLocalizer[租户编码只允许字母、数字、下划线与短横线长度 1~50]);e.Canceltrue;}}GetTenantFreeSql内部还会再校验一次形成保存时校验 使用时校验的双重防线。2. 主租户不允许删除privateasyncTaskOnBeforeDeleteAsync(AdminRemoveEventArgsSysTenante){foreach(varitemine.Items){if(item.Codemain){awaitSwalService.Error(CommonLocalizer[默认租户不允许删除]);e.Canceltrue;break;}}...}主库同时也是main租户的数据库删掉等于把系统自身的管理数据删了。3. 租户表上不要被软删除过滤器挡住GetCurrentTenant用了DisableGlobalFilter()。你自己的租户相关查询如果涉及软删除实体也要同样处理否则会出现租户明明存在但解析不到的诡异现象。4. 租户内的查询不要跨到主库AdminContext.Orm在解析到租户后返回的是租户库。业务代码统一用这个入口或注入的IFreeSql即可。只有租户清单、跨租户统计这类明确要读主库的场景才用MainOrmHandle[Inject]MainOrmHandleMainOrm{get;set;}default!;varallMenusawaitMainOrm.Orm.SelectSysMenu().ToListAsync();Pages/Tenant.razor就是这么做的——它要读主库的完整菜单树再同步到租户库。5. 关闭多租户时要有回退路径框架默认注册的是NullTenantService它的GetTenantFreeSql会直接抛异常publicclassNullTenantService:ITenantService{publicSysTenant?GetCurrentTenant()null;publicIFreeSqlGetTenantFreeSql(stringtenantCode)thrownewNotSupportedException(请安装 EasyAdminBlazor.MultiTenant 扩展以启用多租户功能);publicTaskListSysMenuGenerateTenantMenus(stringtenantCode)thrownewNotSupportedException(请安装 EasyAdminBlazor.MultiTenant 扩展以启用多租户功能);}所以单库模式下调用租户 API 会立刻报错而不是静默拿到主库——这种显式失败比悄悄用错库安全得多。六、小结EasyAdminBlazor 的多租户实现可以压缩成四句话识别按请求 Host 在主库SysTenant表匹配租户每个请求只解析一次。路由AdminContext.Orm根据是否解析到租户返回主库或租户库的 FreeSql 实例。隔离数据库物理隔离文件按{tenantCode}分目录缓存/Redis Key 加tenant:{code}:前缀。防注入租户编码进入数据库名/路径/缓存键之前必须过白名单。独立数据库带来的最大代价是新租户的表结构必须自动建好也就是AutoSyncStructure存在的原因。下一篇专门讲它。如果你正在用 .NET 10 Blazor 做多租户 SaaS 后台可以看看 EasyAdminBlazor 的做法独立数据库 按域名识别 菜单/权限/文件/缓存全链路隔离源码开放可以按自己的商业模型调整。文档https://easyadmin.wang-zhan.com.cn/doc源码https://gitee.com/gudufy/EasyAdminBlazor
返回列表