Blazor 多租户 SaaS 怎么实现租户隔离?FreeSql 全局过滤器落地实践
一、一个很常见的翻车现场
一个外包团队给客户做了套后台管理系统,单体应用、单库、单套数据。半年后客户想把系统做成 SaaS 卖给同行,于是开始加 TenantId。
改造过程还算顺利,加字段、改查询、补索引,测试环境跑了两周。上线一个月后收到投诉:B 公司的运营登录后,在某个报表页看到了 A 公司的订单明细。
排查结果是那个报表页面用了手写 SQL,拼接的时候漏了 where tenant_id = @tid。写这段代码的人在改造期间刚好离职。
这类事故在多租户改造里出现频率非常高,而且几乎每次都是同一个原因。
二、问题不在"忘了加 Where"
很多人第一反应是"下次注意"。但把隔离逻辑放在业务代码里,本质上就是在赌没人忘记——只要有加急需求、有新同事、有从别处复制粘贴的代码,就一定会有漏网之鱼。
所以正确的方向不是"提醒大家记得写",而是把隔离下沉到数据访问层,让漏写这件事在架构上变得困难,或者至少能被一个地方统一管住。
三、先选隔离粒度
多租户的数据隔离通常有三档:
- 独立数据库:一个租户一套库。隔离最强,成本最高,连接和迁移都麻烦。
- 共享库、独立 Schema:PostgreSQL 支持得不错,MySQL 基本可以放弃这条路。
- 共享库、共享表 +
TenantId字段:成本最低,运维最简单,也是绝大多数中小 SaaS 起步阶段的选择。
本文讲第 3 种。租户体量拉开差距之后,可以再给大客户单独开库,做成混合模式。
四、隔离要做的三件事
选了共享表方案,隔离实际上分解成三个动作:
- 查:所有查询自动附加
TenantId条件 - 写:所有新增自动写入当前租户
- 改删:走主键时,必须防止跨租户 ID 命中
这三件事都放在数据访问层做,业务代码里就不应该再出现 TenantId。
五、FreeSql 落地
1. 定义租户上下文
public sealed class TenantContext
{
public Guid TenantId { get; private set; }
public bool IsResolved => TenantId != Guid.Empty;
public void Resolve(Guid id) => TenantId = id;
}
注册成 Scoped 服务:
services.AddScoped<TenantContext>();
2. 实体打标记
public interface ITenantEntity
{
Guid TenantId { get; set; }
}
业务实体实现这个接口即可,不用每张表都去记。
3. 按租户构建 IFreeSql,挂全局过滤器
FreeSql 的 GlobalFilter 是挂在 IFreeSql 实例上的,所以最干净的做法是每个租户一个实例,过滤器里直接捕获常量:
public interface ITenantFreeSqlFactory
{
IFreeSql Get(Guid tenantId);
}
public sealed class TenantFreeSqlFactory : ITenantFreeSqlFactory
{
private readonly ConcurrentDictionary<Guid, IFreeSql> _cache = new();
private readonly string _connStr;
public TenantFreeSqlFactory(IConfiguration cfg)
=> _connStr = cfg.GetConnectionString("Default")!;
public IFreeSql Get(Guid tenantId)
=> _cache.GetOrAdd(tenantId, Build);
private IFreeSql Build(Guid tenantId)
{
var fsql = new FreeSqlBuilder()
.UseConnectionString(DataType.MySql, _connStr)
.UseAutoSyncStructure(false)
.Build();
// 查询自动过滤
fsql.GlobalFilter.Apply<ITenantEntity>(
"TenantFilter",
a => a.TenantId == tenantId);
// 新增自动填充
fsql.Aop.AuditValue += (_, e) =>
{
if (e.Object is ITenantEntity entity
&& e.AuditValueType == AuditValueType.Insert
&& entity.TenantId == Guid.Empty)
{
entity.TenantId = tenantId;
}
};
return fsql;
}
}
这样写有两个好处:过滤器里的 tenantId 是闭包常量,不存在跨请求串号的问题;将来某个大客户要单独开库,只要在 Build 里换连接串就行,上层代码不用动。
4. 在 Blazor 里解析租户
不要从 HttpContext 拿租户。Blazor Server 的大量交互发生在 SignalR Circuit 内部,这时候 HttpContext 可能是 null,或者根本不是当前用户的那一个。
用 AuthenticationStateProvider 从 Claims 里解析:
public class TenantResolver
{
private readonly AuthenticationStateProvider _auth;
private readonly TenantContext _ctx;
public TenantResolver(AuthenticationStateProvider auth, TenantContext ctx)
{
_auth = auth;
_ctx = ctx;
}
public async Task<Guid> EnsureResolvedAsync()
{
if (_ctx.IsResolved) return _ctx.TenantId;
var state = await _auth.GetAuthenticationStateAsync();
var claim = state.User.FindFirst("tenant_id")
?? throw new InvalidOperationException("当前会话没有租户信息");
var id = Guid.Parse(claim.Value);
_ctx.Resolve(id);
return id;
}
}
在页面基类或布局组件初始化时调用一次,后面所有仓储都通过工厂拿 IFreeSql。
六、几个真会踩的坑
唯一索引必须带上 TenantId。 用 (username) 做唯一索引,A 公司的 admin 会让 B 公司的 admin 建不出来。改成联合唯一 (tenant_id, username)。
原生 SQL 不过滤。 GlobalFilter 只作用于 FreeSql 的表达式查询,Ado.Query<T> 这类原生执行路径不经过它。报表、统计这类手写 SQL 的地方,TenantId 条件必须自己写,而且要写进代码评审清单。
后台跨租户查询要显式放行,并且及时收回。
fsql.GlobalFilter.Disable("TenantFilter");
try
{
// 平台运营侧的跨租户统计
}
finally
{
fsql.GlobalFilter.Enable("TenantFilter");
}
不要图省事全局关掉,那等于把隔离彻底放弃。
缓存 key 要带租户前缀。 否则 user:1 这种 key 会在租户之间互相覆盖。Redis、FusionCache 都一样。
文件存储路径带租户目录。 上传、导出、打印模板生成的文件,路径里加上租户 ID,避免覆盖和越权下载。
定时任务没有登录用户。 后台任务里 TenantContext 是空的,得遍历租户列表逐个执行,或者按任务配置指定租户。
IFreeSql 实例数量要有个上限。 租户上千之后,每个租户常驻一个实例的连接池会很夸张。可以只对活跃租户保留实例,其余降级到共享实例 + 手工条件,或者在 ConcurrentDictionary 外面加一层 LRU 淘汰。
登录前怎么知道是哪个租户。 常见做法是子域名(a.example.com)、登录页填租户码,或者用邮箱域名反查。这一步要在认证之前完成。
七、再往前一步
- PostgreSQL RLS:在数据库层再兜一道,作为应用层隔离的保险。
- 审计日志带 TenantId:出了问题能快速定位是哪条链路漏了。
- 和数据权限叠加:租户隔离解决的是"哪家公司",数据权限解决的是"公司里哪个人",两者是叠加关系,不要混在一个过滤器里。
- 混合部署:中小租户共享表,头部客户独立库,工厂方法里按租户切连接串。
八、如果你不想从零搭这一套
上面这些逻辑单看都不复杂,但真正落到项目里,要连着登录鉴权、租户解析、权限、缓存、定时任务、文件存储一起考虑,工作量并不小。
EasyAdminBlazor 是一个 .NET 10 + Blazor 的企业后台框架,UI 用 BootstrapBlazor,ORM 用 FreeSql,内置了用户/角色/菜单、RBAC、按钮权限、数据权限、组织机构、多租户、文件管理、日志、定时任务、在线聊天、多语言、Excel 导入导出、可视化打印模板、富文本、Redis/FusionCache、SMTP、验证码、微信支付和代码生成器。代码生成器可以直接生成常见 CRUD 页面,连菜单和 CRUD 按钮这些基础结构也一起生成。
它主要面向个人开发者、自由职业者和外包开发者这类需要快速交付后台项目的人群。源码可以修改,能用于个人、商业和外包项目,但源码本身不能转售或重新打包销售。
如果你的项目正好卡在多租户改造这一步,可以先看看它的多租户和数据权限模块是怎么组织的:
关键词:Blazor 多租户、FreeSql 全局过滤器、SaaS 租户隔离、Blazor Server 多租户、.NET 多租户实现、TenantId 数据隔离、Blazor SaaS 后台、FreeSql Aop 自动填充

浙公网安备 33010602011771号